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

你好,我是徐长龙,这节课我们来看看如何用token算法降低用户中心的身份鉴权流量压力。

很多网站初期通常会用Session方式实现登录用户的用户鉴权,也就是在用户登录成功后,将这个用户的具体信息写在服务端的Session缓存中,并分配一个session_id保存在用户的Cookie中。该用户的每次请求时候都会带上这个ID,通过ID可以获取到登录时写入服务端Session缓存中的记录。

流程图如下所示:

这种方式的好处在于信息都在服务端储存,对客户端不暴露任何用户敏感的数据信息,并且每个登录用户都有共享的缓存空间(Session Cache)。

但是随着流量的增长,这个设计也暴露出很大的问题——用户中心的身份鉴权在大流量下很不稳定。因为用户中心需要维护的Session Cache空间很大,并且被各个业务频繁访问,那么缓存一旦出现故障,就会导致所有的子系统无法确认用户身份,进而无法正常对外服务。

这主要是由于Session Cache和各个子系统的耦合极高,全站的请求都会对这个缓存至少访问一次,这就导致缓存的内容长度和响应速度,直接决定了全站的QPS上限,让整个系统的隔离性很差,各子系统间极易相互影响。

那么,如何降低用户中心与各个子系统间的耦合度,提高系统的性能呢?我们一起来看看。

JWT登陆和token校验

常见方式是采用签名加密的token,这是登录的一个行业标准,即JWT(JSON Web 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中保存的用户昵称呢?

欢迎你在留言区与我交流讨论,我们下节课见!

精选留言

  • 2022-10-28 11:58:10

    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方式直接获取用户头像,这样也不用考虑更新问题了

    2022-10-28 15:07:30

  • 极客

    2022-10-28 17:15:49

    客户端可以缓存修改后的昵称,直到更换了access token再清除缓存,类似弹幕本地先发送让用户自己认为发送成功了
    作者回复

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

    2022-10-28 18:49:28

  • 7S

    2022-10-28 10:48:55

    access_token由于安全问题设置过期的时间非常短,但是refresh_token有效时间非常长,如果refresh_token被泄漏掉,是不是能一直刷新access_token呢。。
    作者回复

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

    2022-10-28 11:24:54

  • 小林coding

    2022-11-15 23:20:06

    PAYLOAD 中定义的 token 发放时间 iat 字段的值是绝对时间戳,如果服务端的系统时间被往前修改了,这时在校验token是否过期的时候,是不是还需要增加一个处理:如果「当前时间戳 < token 发放时间戳 」,就认为 token 过期了。
    作者回复

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

    2022-11-16 08:12:07

  • 林晓威

    2022-11-07 16:07:46

    老师好,请问光使用base64加密是不是不太安全?这样别人不是很容易知道你用什么加密算法了
    作者回复

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

    2022-11-07 18:39:27

  • 吴晨辉

    2022-10-31 13:14:00

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

    最后一句很棒,支持,笑

    2022-11-01 09:38:23

  • 👽

    2022-12-27 03:37:53

    我的理解是,token中应该只存放和session生命周期同步的操作。比如:用户Id和权限。这两个东西,在用户session的生命周期内一般来说是不会变的。翻译一下,token代表着:你是谁,你能做什么。能做到这两个事情就够了。而不应该去单独关注用户的扩展信息。

    至于昵称,我觉得应该单独放缓存中。通过用户ID获取。因为昵称当前token下修改还好说,如果跨token呢?比如web端修改了昵称web端端token可以立马换一个新的,移动端怎么办呢?所以我认为,昵称,头像,这种会修改的信息不应该放到token体里。
    作者回复

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

    2022-12-27 09:50:53

  • xin

    2024-02-04 10:31:01

    徐老师,请问如何实现登出的时候,让token失效呢
    作者回复

    你好,可以在用户中心发个广播,让各个子系统订阅,子系统解开时对比下内部写的版本号

    2024-02-05 13:37:01

  • Eason Lau

    2024-04-07 17:45:37

    这个jwt token不鸡肋吗? 别的系统严重token有效性的时候还得知道秘钥啊,这不解耦😄
    作者回复

    你好,这个要看具体场景和目的,这个方式可以做到无状态服务,大量降低多个系统对主站公用的服务的流量冲击。秘钥没有想象中那么麻烦,实际用只是个配置把它写本地或者用证书管理存起来即可,并且这个验证可以放到网关去做,当然除了JWT类似脱耦合的解决方案还有很多,但是站内搞的太复杂也不太实用。同时,这里举JWT这个例子只是因为他很典型,很适合开拓系统拆分的思路

    2024-04-08 10:45:45

  • seker

    2023-08-16 14:37:40

    徐老师,请问,用于token加密头中的常用加密算法有哪些呢?
    作者回复

    你好,seker,可以参考这个列表 https://jwt.io/libraries

    2023-08-17 14:32:54

  • 无问西东

    2023-07-26 11:16:45

    你好,客户端保存token如何保证不被其他应用窃取呢
    作者回复

    你好,这个看对安全要求程度。太过复杂会有些难用,简单的方式token可以加密保存在本地存储或数据库内如localstorage,sqlite,每次请求再次加密传输,定期更新短期token等

    2023-07-26 14:36:29

  • 李坤鹏

    2023-07-08 17:33:30

    如果用户中心和周边服务不属于一个信任域,需要将 JWT 签发能力限定在用户中心,但是和周边服务的关系又没有疏远到需要使用 OAuth 授权的程度,那么是否可以使用 RS256算法替代 HS256 算法呢?这样子周边服务无法自己颁发 token,但又可以自己独立验签,相当于牺牲一部分性能换取安全性。这种做法在业界是可接受的方案吗?
    作者回复

    这里我了解不多,不过从你的描述来看我理解是可以的。
    具体有什么限制和问题需要一起坐下来看和尝试。

    2023-07-10 09:34:07

  • 阿昕

    2023-04-06 08:18:27

    思考题:由客户端发起刷新token操作
    作者回复

    这个可以的:),缺点是客户端比较麻烦,并且无法通知多个客户端一起刷新。

    2023-04-06 17:46:59

  • Spoon

    2023-03-27 17:03:44

    使用token这种方式,怎么统计在线用户呢?
    作者回复

    时间段内 uid 访问日志就可以

    2023-03-28 08:03:24

  • piboye

    2023-02-15 08:04:10

    有消息系统的话,发消息给用户所有终端切换token
    作者回复

    你好,piboye,这个是个方法,但是不能保证,所有客户端程序都在线,会有一定难度

    2023-02-15 11:03:47

  • zmlmagic

    2023-02-14 10:32:38

    更新用户昵称,一般客户端修改自己资料接口调度成功后,直接调用token刷新更新掉本地token。如果用户编辑其他用户信息,要不就是易变更信息不走接口,要不就是鉴权那缓存用户token直接服务端刷新,根据需求平衡吧。
    作者回复

    确实,取决于需要

    2023-02-16 09:44:05

  • DZ

    2023-01-16 11:27:33

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

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

    2023-01-17 09:56:05

  • zhou

    2023-01-12 08:59:41

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

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

    2023-01-12 14:21:33

  • 严程序

    2023-01-06 10:29:39

    在修改后昵称直接颁发新token给客户端,或者让access_token过期用户重新用获取新token
    作者回复

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

    2023-01-10 09:42:09

  • Layne

    2022-11-29 08:43:30

    老师,这个双token机制中,re fresh_token的有效期是固定的,没办法刷新,那是不是意味着用户端只要超过30天,就必须重新都登陆一次了,因为没办法刷新access_token了。就算用户是活跃的
    作者回复

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

    2022-11-29 09:16:30