极客时间已完结课程限时免费阅读

03|Token:如何降低用户身份鉴权的流量压力?

03|Token:如何降低用户身份鉴权的流量压力?-极客时间

03|Token:如何降低用户身份鉴权的流量压力?

讲述:徐长龙

时长08:59大小8.21M

你好,我是徐长龙,这节课我们来看看如何用 token 算法降低用户中心的身份鉴权流量压力。
很多网站初期通常会用 Session 方式实现登录用户的用户鉴权,也就是在用户登录成功后,将这个用户的具体信息写在服务端的 Session 缓存中,并分配一个 session_id 保存在用户的 Cookie 中。该用户的每次请求时候都会带上这个 ID,通过 ID 可以获取到登录时写入服务端 Session 缓存中的记录。
流程图如下所示:
Session Cache实现的用户鉴权
这种方式的好处在于信息都在服务端储存,对客户端不暴露任何用户敏感的数据信息,并且每个登录用户都有共享的缓存空间(Session Cache)。
但是随着流量的增长,这个设计也暴露出很大的问题——用户中心的身份鉴权在大流量下很不稳定。因为用户中心需要维护的 Session Cache 空间很大,并且被各个业务频繁访问,那么缓存一旦出现故障,就会导致所有的子系统无法确认用户身份,进而无法正常对外服务。
这主要是由于 Session Cache 和各个子系统的耦合极高,全站的请求都会对这个缓存至少访问一次,这就导致缓存的内容长度和响应速度,直接决定了全站的 QPS 上限,让整个系统的隔离性很差,各子系统间极易相互影响。
那么,如何降低用户中心与各个子系统间的耦合度,提高系统的性能呢?我们一起来看看。

JWT 登陆和 token 校验

常见方式是采用签名加密的 token,这是登录的一个行业标准,即 JWT(JSON Web Token):
token流程
上图就是 JWT 的登陆流程,用户登录后会将用户信息放到一个加密签名的 token 中,每次请求都把这个串放到 header 或 cookie 内带到服务端,服务端直接将这个 token 解开即可直接获取到用户的信息,无需和用户中心做任何交互请求。
token 生成代码如下:
import "github.com/dgrijalva/jwt-go"
//签名所需混淆密钥 不要太简单 容易被破解
//也可以使用非对称加密,这样可以在客户端用公钥验签
var secretString = []byte("jwt secret string 137 rick")
type TokenPayLoad struct {
UserId uint64 `json:"userId"` //用户id
NickName string `json:"nickname"` //昵称
jwt.StandardClaims //私有部分
}
// 生成JWT token
func GenToken(userId uint64, nickname string) (string, error) {
c := TokenPayLoad{
UserId: userId, //uid
NickName: nickname, //昵称
//这里可以追加一些其他加密的数据进来
//不要明文放敏感信息,如果需要放,必须再加密
//私有部分
StandardClaims: jwt.StandardClaims{
//两小时后失效
ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
//颁发者
Issuer: "geekbang",
},
}
//创建签名 使用hs256
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// 签名,获取token结果
return token.SignedString(secretString)
}
可以看到,这个 token 内部包含过期时间,快过期的 token 会在客户端自动和服务端通讯更换,这种方式可以大幅提高截取客户端 token 并伪造用户身份的难度。
同时,服务端也可以和用户中心解耦,业务服务端直接解析请求带来的 token 即可获取用户信息,无需每次请求都去用户中心获取。而 token 的刷新可以完全由 App 客户端主动请求用户中心来完成,而不再需要业务服务端业务请求用户中心去更换。
JWT 是如何保证数据不会被篡改,并且保证数据的完整性呢,我们先看看它的组成。
如上图所示,加密签名的 token 分为三个部分,彼此之间用点来分割,其中,Header 用来保存加密算法类型;PayLoad 是我们自定义的内容;Signature 是防篡改签名。
JWT token 解密后的数据结构如下图所示:
//header
//加密头
{
"alg": "HS256", // 加密算法,注意检测个别攻击会在这里设置为none绕过签名
"typ": "JWT" //协议类型
}
//PAYLOAD
//负载部分,存在JWT标准字段及我们自定义的数据字段
{
"userid": "9527", //我们放的一些明文信息,如果涉及敏感信息,建议再次加密
"nickname": "Rick.Xu", // 我们放的一些明文信息,如果涉及隐私,建议再次加密
"iss": "geekbang",
"iat": 1516239022, //token发放时间
"exp": 1516246222, //token过期时间
}
//签名
//签名用于鉴定上两段内容是否被篡改,如果篡改那么签名会发生变化
//校验时会对不上
JWT 如何验证 token 是否有效,还有 token 是否过期、是否合法,具体方法如下:
func DecodeToken(token string) (*TokenPayLoad, error) {
token, err := jwt.ParseWithClaims(token, &TokenPayLoad{}, func(tk *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, err
}
if decodeToken, ok := token.Claims.(*TokenPayLoad); ok && token.Valid {
return decodeToken, nil
}
return nil, errors.New("token wrong")
}
JWT 的 token 解密很简单,第一段和第二段都是通过 base64 编码的。直接解开这两段数据就可以拿到 payload 中所有的数据,其中包括用户昵称、uid、用户权限和 token 过期时间。要验证 token 是否过期,只需将其中的过期时间和本地时间对比一下,就能确认当前 token 是不是有效。
而验证 token 是否合法则是通过签名验证完成的,任何信息修改都会无法通过签名验证。要是通过了签名验证,就表明 token 没有被篡改过,是一个合法的 token,可以直接使用。
这个过程如下图所示:
我们可以看到,通过 token 方式,用户中心压力最大的接口可以下线了,每个业务的服务端只要解开 token 验证其合法性,就可以拿到用户信息。不过这种方式也有缺点,就是用户如果被拉黑,客户端最快也要在 token 过期后才能退出登陆,这让我们的管理存在一定的延迟。
如果我们希望对用户进行实时管理,可以把新生成的 token 在服务端暂存一份,每次用户请求就和缓存中的 token 对比一下,但这样很影响性能,极少数公司会这么做。同时,为了提高 JWT 系统的安全性,token 一般会设置较短的过期时间,通常是十五分钟左右,过期后客户端会自动更换 token。

token 的更换和离线

那么如何对 JWT 的 token 进行更换和离线验签呢?
具体的服务端换签很简单,只要客户端检测到当前的 token 快过期了,就主动请求用户中心更换 token 接口,重新生成一个离当前还有十五分钟超时的 token。
但是期间如果超过十五分钟还没换到,就会导致客户端登录失败。为了减少这类问题,同时保证客户端长时间离线仍能正常工作,行业内普遍使用双 token 方式,具体你可以看看后面的流程图:
可以看到,这个方案里有两种 token:一种是 refresh_token,用于更换 access_token,有效期是 30 天;另一种是 access_token,用于保存当前用户信息和权限信息,每隔 15 分钟更换一次。如果请求用户中心失败,并且 App 处于离线状态,只要检测到本地 refresh_token 没有过期,系统仍可以继续工作,直到 refresh_token 过期为止,然后提示用户重新登陆。这样即使用户中心坏掉了,业务也能正常运转一段时间。
用户中心检测更换 token 的实现如下:
//如果还有五分钟token要过期,那么换token
if decodeToken.StandardClaims.ExpiresAt < TimestampNow() - 300 {
//请求下用户中心,问问这个人禁登陆没
//....略具体
//重新发放token
token, err := GenToken(.....)
if err != nil {
return nil, err
}
//更新返回cookie中token
resp.setCookie("xxxx", token)
}
这段代码只是对当前的 token 做了超时更换。JWT 对离线 App 端十分友好,因为 App 可以将它保存在本地,在使用用户信息时直接从本地解析出来即可。

安全建议

最后我再啰嗦几句,除了上述代码中的注释外,在使用 JWT 方案的时候还有一些关键的注意事项,这里分享给你。
第一,通讯过程必须使用 HTTPS 协议,这样才可以降低被拦截的可能。
第二,要注意限制 token 的更换次数,并定期刷新 token,比如用户的 access_token 每天只能更换 50 次,超过了就要求用户重新登陆,同时 token 每隔 15 分钟更换一次。这样可以降低 token 被盗取后给用户带来的影响。
第三,Web 用户的 token 保存在 cookie 中时,建议加上 httponly、SameSite=Strict 限制,以防止 cookie 被一些特殊脚本偷走。

总结

传统的 Session 方式是把用户的登录信息通过 SessionID 统一缓存到服务端中,客户端和子系统每次请求都需要到用户中心去“提取”,这就会导致用户中心的流量很大,所有业务都很依赖用户中心。
为了降低用户中心的流量压力,同时让各个子系统与用户中心脱耦,我们采用信任“签名”的 token,把用户信息加密发放到客户端,让客户端本地拥有这些信息。而子系统只需通过签名算法对 token 进行验证,就能获取到用户信息。
这种方式的核心是把用户信息放在服务端外做传递和维护,以此解决用户中心的流量性能瓶颈。此外,通过定期更换 token,用户中心还拥有一定的用户控制能力,也加大了破解难度,可谓一举多得。
其实,还有很多类似的设计简化系统压力,比如文件 crc32 校验签名可以帮我们确认文件在传输过程中是否损坏;通过 Bloom Filter 可以确认某个 key 是否存在于某个数据集合文件中等等,这些都可以大大提高系统的工作效率,减少系统的交互压力。这些技巧在硬件能力腾飞的阶段,仍旧适用。

思考题

用户如果更换了昵称,如何快速更换 token 中保存的用户昵称呢?
欢迎你在留言区与我交流讨论,我们下节课见!
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 6

提建议

上一篇
02|缓存一致:读多写少时,如何解决数据更新缓存不同步?
下一篇
04|同城双活:如何实现机房之间的数据同步?
unpreview
 写留言

精选留言(19)

  • 徐曙辉
    置顶
    2022-10-28 来自北京
    Q1:在token过期很短的时候,通过refresh_token频繁更新token,怎么实现对用户实时管理?是不是还是跟用户人数相关,一般这种场景是后台系统,删除一个用户后该用户账号立刻不能登录,后台人数比C端人数少很多,所以管理起来代价比较小,更看重权限安全,放在缓存中进行管理。 A: 如果我来做快速更换昵称的功能,两种方式, a.在用户修改昵称后,内存中加入个用户标识,解析token后读取该标识,有则返回特定code,让客户端重新拿token。甚至可以不用客户端参与,返回301重定向到获取新token的路由。 b. token里面不存用户信息,只存用户ID,需要用户信息的时候从缓存读。
    展开

    作者回复: 你好,徐曙辉,很高兴收到你的再次留言 对于session 方式来说,由于用户每次请求都会读取session cache,客户端本地是不会保存token,所以不存在token内用户头像更新不及时问题。可以说后台系统用session管理用户很方便,因为这个可以做到用户实时管理,当我们禁用用户的时候把session的缓存登陆标志删掉即可。不过这个方式适合少量用户,对于QPS超过10w QPS请求的API则不太适合。 所以使用token方式来签名发给客户端,客户端请求其他子系统的时候,会带上它,子系统只要验证这个token的签名就不需要再去用户中心问一句。所以token使用后,用户中心不会被其他子系统频繁请求,但是也导致token发出去没法再次更改,即使我们用户中心给他拉黑了,其他子系统只认印章,不会过来问问。 同时为了方便token内会保存当前用户一些基础信息,减少其他系统过来询问的次数,这导致,用户更新头像,token没更换,是不会同步更新的 第一个很暴力,但是很有趣~ 第二个方式也很有趣,同时补一个技巧我们可以通过 设定 固定网址 user/用户uid/heaer.jpg方式直接获取用户头像,这样也不用考虑更新问题了

    共 10 条评论
    1
  • 极客
    2022-10-28 来自北京
    客户端可以缓存修改后的昵称,直到更换了access token再清除缓存,类似弹幕本地先发送让用户自己认为发送成功了

    作者回复: 你好,极客,感谢你的留言,这个思路很有意思,是个方法,印象里这个技巧对于读多写多的服务的客户端也会做类似的事情

    7
  • 7S
    2022-10-28 来自北京
    access_token由于安全问题设置过期的时间非常短,但是refresh_token有效时间非常长,如果refresh_token被泄漏掉,是不是能一直刷新access_token呢。。

    作者回复: 你好,7S,很高兴收到你的思考,关于这里有一些特殊的小技巧,如请求时带上一些客户端特征,如:请求更换access_token时,带上的refresh_token的请求 同时 需要特殊的签名,存储在本地的token不用明文保存,与服务端通讯时用特殊协议加密等~

    共 3 条评论
    4
  • 小林coding
    2022-11-15 来自北京
    PAYLOAD 中定义的 token 发放时间 iat 字段的值是绝对时间戳,如果服务端的系统时间被往前修改了,这时在校验token是否过期的时候,是不是还需要增加一个处理:如果「当前时间戳 < token 发放时间戳 」,就认为 token 过期了。

    作者回复: 你好,小林,经常看你的公众号,这里建议如果只是一两秒建议忽略,原因在于,我们的服务器时间都是有误差的即使使用ntp定期同步也是存在误差,有时相差一两秒是很常见的,并且https也是基础时间做的加密,如果时间误差太大是无法通讯的。

    1
  • 林晓威
    2022-11-07 来自北京
    老师好,请问光使用base64加密是不是不太安全?这样别人不是很容易知道你用什么加密算法了

    作者回复: 你好,林晓威,很高兴收到你的提问,这个算法重点并不是这个payload区,payload这里只是附带的数据,只是为了方便业务使用,事实上这个核心在于签名和过期时间,由于密钥是只有服务端有,所以签名是不能伪造的,如果到子业务这里验证签名是正确的密钥加密的,那么代表token的payload的内容肯定是服务器发放的,传输的用户无法更改,如果更改了就会和签名核对不上,通过这个方式就已经能够保证数据的安全了。至于base64内放的数据普遍是可以公开的信息,如果有不能公开的信息可以再做一层加密后再放入payload

    1
  • 吴晨辉
    2022-10-31 来自北京
    很高兴第二次回答问题 传统sessoion会导致用户中心缓存大,耦合度高,但实时性强 jwt加密策略耦合度低,但是实时性不高 那么可以结合两个方式,优先读取token 加密字段,然后利用用户id关联session cache覆盖 考虑到session缓存成本,可以只缓存实时性强的字段,或者用vip制度,用户充钱越多,缓存的东西越多 核心思想就是成本增效
    展开

    作者回复: 最后一句很棒,支持,笑

    2
  • DZ
    2023-01-16 来自江苏
    老师您好,请教下,如果只是用户中心出现故障,导致客户端更换 access_token失败,APP没有离线,但是refresh_token没有过期。这个时候会怎么处理?客户端不会提示用户重新登陆,依旧拿着旧的 access_token请求其他业务接口,其他业务接口由于token过期返回登录超时?

    作者回复: 你好,DZ,这时候可以做客户端自行签名功能,比如在token后面追一个特殊的串,是由客户端结合本地refresh token制作的,然后再请求服务端的时候会特殊处理。另外,即使access token过期一些服务接受的情况下也可以允许过期一小时,这些都需要业务根据自己的场景定制,以前我们直播期间所有token是不判断过期的,只有进入直播前检测一下

  • zhou
    2023-01-12 来自浙江
    把用户信息放在服务器外做传递和维护,子系统通过签名算法对token进行验证,是否会存在子系统可以拿到签名的密钥,从而可以自行签发token的能力,会不安全。

    作者回复: 你好,zhou,token更换这个可以用户中心提供接口,但是触发更换生成token后,如何让客户端本地token同步更新是个问题,毕竟很多token并不是保存在cookie内,并且很多数据不是在同域名下,如果是多个网站联合sso刷新token会很麻烦

    共 3 条评论
  • 严程序
    2023-01-06 来自北京
    在修改后昵称直接颁发新token给客户端,或者让access_token过期用户重新用获取新token

    作者回复: 你好,等这个回答好久了,这里也补充一句,这个方式有个漏洞,如何预防入侵代码恶意刷用户的token

  • 👽
    2022-12-27 来自北京
    我的理解是,token中应该只存放和session生命周期同步的操作。比如:用户Id和权限。这两个东西,在用户session的生命周期内一般来说是不会变的。翻译一下,token代表着:你是谁,你能做什么。能做到这两个事情就够了。而不应该去单独关注用户的扩展信息。 至于昵称,我觉得应该单独放缓存中。通过用户ID获取。因为昵称当前token下修改还好说,如果跨token呢?比如web端修改了昵称web端端token可以立马换一个新的,移动端怎么办呢?所以我认为,昵称,头像,这种会修改的信息不应该放到token体里。
    展开

    作者回复: 你好,alien,确实如此,而很多业务为了方便,token有额外一段在结尾放附加消息

  • Layne
    2022-11-29 来自北京
    老师,这个双token机制中,re fresh_token的有效期是固定的,没办法刷新,那是不是意味着用户端只要超过30天,就必须重新都登陆一次了,因为没办法刷新access_token了。就算用户是活跃的

    作者回复: 你好,Layne,refresh_token可以刷新,只是频率很低,客户端本地定期检测即可,由于频率低所以被捕获的概率更低,再次补充,通过30天重新登陆也可以。

  • 连腾宇
    2022-11-11 来自北京
    有点没太理解, access token过期客户端有,那服务端怎么知道这个token是否还在有效期内呢?(是根据客户端传过来的时间为准,根据解密信息判断时间有没有被篡改吗?)

    作者回复: 你好,连腾宇,很高兴收到你的提问,看来你深入思考过了,才会碰到这个疑问,这个时间是在token内保存的,里面规定了这个token的有效时间,客户端只要揭开token的payload就能获取到

  • 特修斯之船
    2022-11-07 来自北京
    关于用户中心里,服务器端对access_token和refresh_token的管理,有没详细的设计? 一直不清楚,刷新这一块的细节逻辑

    作者回复: 你好,特修斯之船,由于这个篇幅过长不太好详细回复,详细的设计可以查一下oauth2.0协议的一些资料可能会对你有帮助!

  • 邱一二
    2022-11-06 来自北京
    老师好,请问一下token自动更新是客户端在过期前请求服务端重新获取新的token吗?还是客户端自己完成?那如果是客户端自己生成会不会有法外狂徒利用逆向工程破解app获得对应生成token的SecretString然后伪造token呢

    作者回复: 你好,邱一二,很高兴收到你的思考,目前token的签发都是服务端去做的,客户端自动更新 是指 客户端主动请求服务端拿最新的token,而不是请求服务端后发现过期在子业务系统进行更换。如果担心安全性可以使用非对称加密用证书加密

    共 2 条评论
  • peter
    2022-11-01 来自北京
    请问:Token的自动更新是系统自动完成的吗? 文中有“过期后客户端会自动更换 token。”,那么,token的更新需要开发人员写代码吗?(如果是框架自动完成,或者是TCP/UDP协议栈完成,就不需要用户写代码了)

    作者回复: 你好,peter,这里更换是客户端写代码来实现自动更换的,子业务系统无需关注token的更换。

  • frag007
    2022-11-01 来自北京
    更新昵称一般是用户自己发起的,更新昵称的同时,业务后台重新生成token就可以了。

    作者回复: 你好,frag007,很高兴收到你的留言,这是个最简单的实现方式~唯一缺点就是多客户端的情况下同步有些问题~

    共 2 条评论
  • 张申傲
    2022-10-30 来自北京
    老师对于 JWT 的原理和最佳实践讲得很清楚👍🏻

    作者回复: 你好,张申傲,感谢你的留言~

  • Sky
    2022-10-29 来自北京
    token的方式是怎么处理多终端登录以及“踢下线”类似的功能的呢?

    作者回复: 你好,sky,很高兴收到你的留言,踢下线可以用网关黑名单方式,每个业务网关会解开token,发现redis中有这个uid以及低于指定版本,就会要求更换

    共 6 条评论
  • Geek_00乐
    2022-10-29 来自北京
    用户如果被拉黑,客户端最快也要在 token 过期后才能退出登陆,这让我们的管理存在一定的延迟。

    作者回复: 你好,Geek_00乐,很高兴你的心得分享,同时补充一句~子系统不会每次都问询用户中心~导致了这个问题