长短令牌三验证”的JWT令牌续签策略

发布时间 2023-03-29 17:14:01作者: edda_huang

“长短令牌三验证”的JWT令牌续签策略(兼顾安全、性能的综合性方案)

令牌使用策略概述

顾名思义,本机制下所使用的令牌分为长短两种:长令牌即过期时间较长的refresh_token,专门用于token的续签,同时刷新两种token;短令牌即过期时间较短的access_token,进行常规业务请求时使用。

流程概述:

  1. 登陆
  2. 用户登录成功后,同时获得长短两种令牌,之后长令牌存在客户端暂不使用,仅使用短令牌用于常规业务请求。
  3. 服务端在接收到每个携带短令牌的业务请求时,按照JWT的规则进行用户的身份验证,并从payload中获取用户身份信息,此即“三验证”中的第一种验证。
  4. 上述操作中,客户端如果发现自己的请求因短令牌过期被拒,则使用长令牌(refresh_token)向专用于续签令牌的接口发起请求,申请一对拥有全新过期时间的长短令牌。
  5. 服务端在接收到续签请求时,会进行两步验证:一是根据JWT的算法规则验证长令牌的合法性,此即“三验证”中的第二种验证;如果通过,则将该token按照某一规则进行转变,转变为一个有状态的token,用于比对服务端所存储的token数据,也即“三验证”中的第三种验证。(该步骤是本策略的核心关键,后面会展开详说。)
  6. 以上的两步验证均通过时,才会将此次请求正常返回。后续处理和登陆成功时一致,即生成并返回一对拥有全新过期时间的长短令牌,客户端也同样,拿短令牌访问常规业务接口,用长令牌续签。
  7. 上述操作中的第二、三种验证如果出现任何原因的续签失败,则统一回到1步骤要求用户重新登陆。(视情况也可增加盗号风险提醒,后详)

补充:

  1. 短令牌的过期时间为“自动续签”的最短时间,小于该时间则令牌有效,大于该时间则客户端自动使用长令牌进行令牌续签,以上操作对用户无感;
  2. 长令牌的过期时间为“用户重新登陆”的最短时间,在该时间内只要有任何访问服务的操作,则客户端均会在需要时进行自动续签,用户无感。一旦超过该时间才会需要用户重新登陆。
  3. 常规的业务请求使用短token,验证时只用秘钥和jwt算法验证合法性;刷新token的请求使用长token,进行两步验证:一是jwt算法验证,二是转换成有状态的token后进行字符串匹配验证

长令牌续签时的两道验证机制

使用短令牌进行身份验证的过程属于JWT基础,这里略过,只说续签:
续签服务的第一道JWT验证机制好理解,和业务请求时的JWT身份验证基本一致,即按照JWT的算法规则验证该token是否合法。

秘钥可以和短令牌一致也可以不一致,个人建议设计成不一致,多少能提升一些安全性。不过如果你的续签接口是和其他业务接口混用的,设计成一致的也无伤大雅,以免同一个服务还要搞两套不同秘钥的JWT验证策,没必要增加这种复杂度。

重点是接下来要说的第三种验证,即“有状态”的token验证

如何“转变”为有状态token

首先要介绍的是在第三种验证中提及的“转变”为有状态token。本质上是一个你自定义的规则,即要求你自定义一个方式,将格式为JWT的refresh_token转变为一个仅作为匹配一致性使用的、无意义的字符串,该转变只需要保证结果唯一,无需可逆。你可以对整个refresh_token进行一次md5计算(长度32),也可以直接把jwt token的签名部分截取使用(长度43)。开发阶段可以先使用后者,方便一眼看出对应关系。本文中把这个由长令牌转换来的字符串称为 “有状态token”,以区别于运用jwt规则验证的无状态token。

第三种验证(有状态token验证)机制流程

核心机制:

首先在用户登陆阶段(流程概述的步骤1)做出改动,在生成了长短两个jwt token之外,还需生成一个有状态token,由长令牌按前面所说的规则“转变”而来。之后将长短令牌返回客户端,将有状态token作为键名存在redis中。留待后续的续签服务中使用。
之后在第三种验证时(流程概述的步骤5),只需将请求所携带的长令牌按照你设定的规则转变为为有状态token,再以此为键去redis中查询是否存在对应键即可。如果存在,则表示验证通过,其后的操作则与登录时一致,即生成长短令牌与有状态token,前俩令牌返回客户端,有状态令牌存redis,并在redis中删去此次请求中已使用过的 有状态令牌。

设计思路:

以上操作的核心意义在于给长令牌赋予了只能一次性使用的特性,可以大大提升系统的安全性,弥补JWT token无法提前失效的弊端。即假如自动续签token的请求被黑客抓包,将其中的长令牌重复使用,虽然能顺利通过第二种验证,但随即就会因为其所对应的“有状态token”已经在redis中被删而无法通过第三种验证。
同样由于一次性机制,即使长令牌泄露并被黑客使用,也会被合法客户端及时发现——因为下一次自动续签时,合法客户端的长令牌就会因为一次性机制失效了,继而要求用户重新登录,使得黑客手中的refresh token失效。也方便服务端根据登录、续签时的ip地址等记录判断该用户是否存在被盗号的风险,进一步进行账号冻结或提醒操作。
同时,纯JWT机制下,服务端系统原本无能为力的注销用户、拉黑名单等用户状态管理操作也得到了补全。注销时只需在redis中删去对应的有状态token,拉黑名单则在前者基础上进一步限制登陆即可,此时客户端发来的续签请求都会因为无法通过有状态token的验证而被拒。虽然客户端的短token还能在失效前继续可用,但已经利大于弊,采用JWT机制所失去的用户登录状态管理功能已经能够得到最大限度的挽回。
(后续补充:如果连客户端的短token依然短暂有效这点也不愿接受,也有对应策略:即增设一个仅针对claims中用户名字段(比如aud,userid之类)的临时黑名单,缓存过期时间设为用户手上短token的最长过期时间,每次普通业务请求的验证jwt token阶段也比对一下当前token的aud是否在黑名单中。因为这层验证极为频繁,所以比较建议的做法是将这个黑名单同步到每个微服务的本地缓存中以最大限度地节约通信开销。即便如此也是一笔不小的开销,各位同行可以自行取舍。)

进阶用法:

同一用户多设备的情形下登陆状态的管理

基于上述设计,再稍稍扩展一下,也能做好同一用户多设备的情形下登陆状态的管理:比如限制用户同时在线的设备数目、限制用户在同一类设备上仅能同时在线一个等等。

拿其中最为复杂的“限制用户在同一类设备上仅能同时在线一个”举例,在redis中以用户id为键,再维护一套hash(子map)对照表。表的filed为设备类型,用户登陆时由客户端传来,如果对应filed不存在则直接保存,以设备类型为filed,以有状态token的字符串为值;如果对应filed已存在,则先根据其中存的值,找到对应有状态token的键并删除,再将“设备类型–token”的field–value对存入。此时同类型旧设备上所存的refresh_token即会因为对应有状态token被删而失效,需要用户重新登录。
所以“限制用户同时在线的设备数目”也更简单了,把上面的设计的hash结构改成list结构,用lua脚本确保list元素上限,超过了则连同有状态token的key一并移除,以此让最旧的设备上的refresh token失效。
并且,如果客户端需要加个“管理我的设备”功能,也能一并实现了。用户可以自行移除自己其他设备上的自动登录状态。

可选策略:

系统设计的关键是合适,而非通用,所以根据不同情形和要求可以基于以上设计产生多种附加或删减的设计:

1. 结合通信加密(进一步加强长令牌的安全性)

登陆的请求本来就应当加密。同时为了避免登陆或续签令牌时的返回值被人抓包,这部分也建议和登陆请求一样加密。原本refresh_token就已经做成一次性了,在续签请求时被抓包也不怕,但是获取到新token并返回时还是有点风险,这部分加上加密策略就几乎完美的安全了。

这里简述一下通信加密策略的建议,即对称和不对称加密混用的通信加密策略
每次请求前客户端先生成一个临时秘钥,用该秘钥对称加密消息体(或其中的敏感数据),将该秘钥用不对称加密的公钥加密后,放在请求头中,和加密后的消息体一起发送到服务端。
服务端在获得请求后,从请求头中获取加密后的临时秘钥并用不对称加密的私钥解密得到临时秘钥,然后用临时秘钥对称解密消息体。
返回值如果需要加密,就也和客户端的做法一样,用临时秘钥加密消息体后返回到客户端,临时秘钥也不用带了,因为本来就是客户端发来的,它自己必然还存着一份。

后续补充:混合加密本身也是相当靠谱的安全策略了,考虑到和一次性长token的功能重叠,二者择一使用即可。当然二者都用也是种锦上添花的选择,并且由于令牌续签工作本身相对低频,二者并存对性能也没多大影响。大家可以自行取舍。

2. 结合ip地址绑定(进一步加强短令牌的安全性)

加强与设备的绑定肯定能让通信更为安全,但是设备唯一id,比如pc的网卡号和iphone的UDID,得由客户端自行获取并填入请求消息体中,可以造假,没有意义。剩下的只有IP地址了,但是如今的互联网环境是设备可能在多个网络间切换,比如wifi信号不好我就切蜂窝网络,出门换个地儿也就换了wifi,以此导致ip地址可能频繁变化。所以ip的绑定要做也只能绑短令牌access_token(将ip地址加入到payload中),一旦网络切换,短令牌失效而长令牌继续有效,就需要走令牌续签的接口获取新的ip地址下的新短令牌。同时由于我们上面有状态token的机制,refresh_token的验证已经非常安全了,所以短令牌加上ip绑定后也“全上加全”。
当然更安全的代价是服务端资源的更大开销,首先是每次常规的业务请求,在jwt的合法性验证之后,还要拿出客户端的ip地址对比一下和该token的payload中保存的ip地址是否一致;其次是每次切换网络,都要走一遍续签接口,重新生成长短令牌对并更新缓存中的有状态token。这开销和安全性之间的平衡,就由读者自行取舍了。
当然纯PC客户端或者内网办公系统要这么玩还是非常适合的。前者ip地址不会频繁变化,后者并发量也不会有多高。(不过这两种情形,是不是连采用JWT的必要性都有待商榷了?)

3. 异地登录提醒。

redis中存转变后的第三种令牌用的是Key,此时value可以存用户登陆时的ip地址,这样当下次进行续签请求时可以比对一下ip地址的范围是不是还在同个城市,如有变动可以给用户发个异地登录提醒。
关于value的用法目前只想到这个,能用上总比空着好,要是还有啥个性化需要能把它利用起来的也欢迎在评论中补充或者进群交流。

Q&A,系统设计思路

  1. 为什么要做“转变为有状态token”这一步骤 ,是不是可以直接存长令牌的JWT token?
    答:可以是可以,功能也能走通。但是JWT token更长更占空间都是其次,更重要的是我们系统设计中应该遵循“文要对题”、“形意合一”的原则,保证一目了然的高可读性,以此降低后续维护以及组内沟通时的学习成本。放在当前情境里就是:作为一个有状态token,既然它被当做比对字符串来使用,那它在形式上就应该是个无意义的字符串,类似jsessionid,或者加盐加密后的用户密码。拿自带无状态属性的jwt token当有状态token用,属于文不对题,会给后续的理解与沟通带来不必要的成本和障碍。

  2. 是不是可以随机生成一个字符串作为有状态token,每次续签或登陆返回给客户端三个token?
    答:可以,功能上没问题,三个token的设计本来就是现在这套设计的雏形。因为考虑到refresh token(JWT)和有状态token永远绑定在一起使用,而且后者原本只是比对字符串使用,用啥机制生成都行,完全不妨碍它们合并成一个。首先越简洁的设计越不容易出错;其次在客户端层面完全隐藏了第三道验证的存在,也可一定程度上增加被破解的难度。

  3. 长短令牌的必要性,是不是能合成一个,并使用在服务端判断是否临近过期的机制来实现自动续签?
    答:长短令牌的策略就我搜到的资料来看其实已经挺主流了,这里老生常谈一下:以仅使用一个令牌为前提:常规业务请求时,令牌使用频繁且重复使用,泄露风险非常高,所以过期时间越短泄露后的危害就越小;同时令牌一旦过期就需要用户重登录,手动登录越频繁用户体验越差,从这个角度考虑又是过期时间越长用户体验越好。所以长短令牌结合使用的优越性就体现在这里,可以很好解决安全性和用户体验之间的矛盾。

  4. 既然最终结果由第三种验证(有状态token的比对)来决定,那么第二种验证策略,即在续签请求中首先以JWT规则验证长令牌是否合法的步骤,是不是可以去掉?
    答:不要去掉,虽然最终结果由第三种验证决定,但第二种验证是一个在性能优化方面非常具有性价比的环节。这道验证的存在类似布隆过滤器,先以低代价的本机运算方式将请求过滤一遍,排除掉那些连JWT token的合法性都没做到的请求,之后再进行远程访问,从而有效节约网络通信和远程缓存服务器的开销。

特征总结

  1. 短token业务用,长token续签用。普通业务时短token用jwt规则验一次,续签时长token不光用jwt规则验一次,还要转变成有状态token再比对服务端的token缓存再验一次。
  2. 有状态token使用一次后即刷新替换,以此保证其安全性。通过一次性机制有效防止非法客户端使用其截获的refresh_token在一定期间内“无限续杯”。同时也可以此获得在服务端管理用户在线、登出的功能,弥补纯无状态的jwt令牌只能“坐待过期”的不足。

后续改进:利用nbf属性批量颁发短令牌

JWT的规则自带一个nbf属性,该属性可以控制jwt开始生效的时间。那么基于前面的设计就可以做出一个更为高效的改动:
每次登录或续签时,在颁发一个长token之外,批量颁发短token,这批短token的有效时间头尾相接,即后一token在前一token临近失效时才生效,保证同一时间只有一个短令牌有效。这样在控制短token泄露危害的同时还可以大大延长续签周期。
不过本策略会导致客户端的逻辑会变得更为复杂,主要是体现在客户端和服务器的时间不同步时:一长一短的情形时无脑用短+被拒直接refresh即可,但多短的情形下,你可能需要加上“转换服务端时间差”的逻辑,以及“短token被拒时继续尝试下一个短token”的逻辑。

原文地址

“长短令牌三验证”的JWT令牌续签策略