在实际用JWT的时候,经常会因为一些问题在网上看到很多不一样的思路。甚至在部分培训机构的课程里,可能不同老师的讲解的做法能差异很多。
其实我自己也很好奇到底应该怎么做,今天针对这些问题仔细思考了一下。
先看看JWT是什么
JWT的大致思想与大致做法
现在流行的JWT技术实际上是一种令牌机制,它的思想大致是这样的:
下面的描述不是真实情况严谨,只是在尝试说明大致的意思
首先,我们可以把用户的一些信息(比如用户名、角色等)存为Json数据,比如就像这样:
{
"username": "baka",
"roles": [
"player",
"operator"
]
}
然后我们可以把这个json数据不可逆加密起来,变成一个校验字符串,拼接成这样:
abcdefg1234567::{"username": "baka","roles": ["player","operator"]}
其中abcdefg1234567
是这个Json数据加密后的校验字符串,这个校验字符串生成需要使用一个固定的密钥,这个密钥只有服务端才有,因此我们可以直接认为只有服务端才能有能力随意生成校验字符串,并且只有服务端才能有能力验证这个校验字符串到底是不是合法的。
完成这个操作以后,服务端就可以把这个拼接起来的串发给用户,用户拿着这个串代表自己具有登录状态,在每次发请求的时候,都要把这个串随请求发给服务端,跟服务端证明我真的登录了。
看起来这个意思是很简单的,不过,我们刚才提出的这个做法实在是太过于简陋了,这并不是JWT的实际做法。实际上的JWT是比这个要复杂一些的。在JWT里,数据分为三个部分,分别是头(Header)、载荷(Payload)和签名(Signature)。
Header是一个这样的Json数据,里面存储了这个JWT的算法和类型,就像这样:
{
"alg": "HS256",
"typ": "JWT"
}
其中alg
表示算法,typ
表示类型,这规定了这个JWT应该以怎样的姿势去使用。
然后是Payload,就可以像类比刚才说的那样,里面存实际数据,比如我们的载荷可以长这样:
{
"sub": "baka",
"exp": 2345678,
"iat": 1234567,
"roles": [
"player",
"operator"
]
}
有些字段是标准里规定的,比如一般用sub
表示主题字段,exp
代表过期时间,iat
表示签发时间,这里的roles
是我们定义的表示角色的字段。这里说他们是字段不够严谨,实际上应该叫它们为声明(Claim)。
最后是签名,它按照Header里定义的算法,把Header和Payload加密成一个校验用的签名,用来防伪防篡改。
这三个部分都准备好以后,把三个部分全部用稍有改变的Base64算法加密,就可以按照 Header.Payload.Signature
的格式,用一个点间隔前后连接起来。
生成完毕以后就可以发给用户的客户端了。客户端每次请求的时候都要带上这个JWT。比如HTTP请求里,可以把这个JWT放在Authorization
这个HTTP Header里,一并传给服务端,服务端拿到请求以后先去找Authorization
,进行鉴权操作,然后根据情况处理请求。
Session?
Session是个老前辈技术了,他的思想是与上面的思想较为类似的。
还是以登录场景为例,用户登录完毕以后,用户的具体登录状态保存在了服务器里,然后会给客户端返回一个这个登录状态对应的key作为凭据,一般会把这个凭据存储在Cookie里。
用户发HTTP请求的时候会自动带上这个Cookie,服务端收到请求先校验这个Cookie找到对应的key,然后取出对应的登录状态,进行鉴权操作以后根据实际情况处理请求。
看起来这个流程都没啥区别,如果你从用户视角来看那确实如此,因为流程都是服务端给了客户端一个东西,客户端发请求的时候都要带上这个东西来证明我真登录了,服务端要验证这个东西做鉴权。
其实我们应该换个角度,从服务端的角度想这样一个问题,“登录状态究竟存在哪儿了”:
- 在JWT方案里,用户的登录状态是让用户自己拿着的,为了防止用户瞎改给自己加管理员权限啥的,多了签名的操作
- 在Session方案里,这就类似于去超市购物要把东西寄存在柜子里一样,是服务器存着的,存起来以后给你一个小牌子,这个小牌子对应某个柜子,你找服务员有事那就得把这个小牌子一并也给服务员,否则服务员找不到对应的柜子是啥
JWT方案的优势
实际上这里的说法我觉得是不严谨的。我觉得要区分这样两个概念:
- JWT本质上是一个字符串处理方法,没人非逼我只能用JWT这种字符串处理方法,我完全可以自己发明一种方法,不用他那套
Header.Payload.Signature
的拼接方法 - 这个使用JWT的场景,思路是让用户自己拿着自己的登录状态,这个思想又是一个单独的事情,我完全可以把这个思想单独拆出来,结合一个我自己发明的字符串处理方法,实现一个我自己整的机制
不过非要扣这个字眼,那就是钻牛角尖的语文艺术了。这里直接统称为JWT方案。
JWT方案的优势是比较明显的。比如Session方案的key(叫做Session ID)是存在了Cookies里的。但是大伙都知道,不是所有的APP都是浏览器,我自己整个原生的APP就没有Cookies机制咋办,微信小程序这种阴间浏览器不支持Cookies咋办,那就很难搞了。
反观JWT,没人规定服务端给你JWT以后你怎么存,你爱怎么存怎么存,他只是说你必须发请求的时候要一并把JWT发回去。对于浏览器网站而言,那你除了Cookies还能用localStorage存,APP那就更五花八门了,写到某个文件里也行。
所以有培训机构在说,JWT不仅能用在B/S架构里,还能用在C/S架构里,其实就是这样的道理。
问题一:JWT是不是要存在Redis里
JWT存Redis里这件事的前因后果
为什么要考虑把JWT存在Redis里
前面我们说到,JWT里面包含了用户的登录状态。
我们要对用户进行鉴权,鉴权要看用户的角色之类的内容,这些信息存在了我们发给用户的JWT里,JWT是随每次请求发过来的。
因而站在你的每一个需要鉴权的接口的视角上来看,当你的接口想鉴权的时候,用户信息自己就随着请求送过来了,不用再去找对应的Session去读信息了。
但是这就有了一个关键问题,这样做安全性真的能有保障吗?
其实你得看什么安全性,如果你说数据防篡改这种基本的安全性,我的评价是只要你生成签名用的密钥不泄露,那就大概率安全。
但是试想一个这样的场景:用户不小心泄露了自己的JWT出去,现在这个用户修改了密码,从正常的道理上来讲,我希望用户修改完密码以后之前的登录全部作废,但是......
但是我们发现,JWT像是泼出去的水,这时候你想收回那就不可能,根本不可能。这样的设计导致JWT先天不具有,并且肯定没办法具有撤销机制!
所以有人提出,我们应该考虑把JWT存在Redis里。用户登录的时候给他生成JWT,每次鉴权的时候去Redis里找一下这个JWT有没有存在于Redis里,没有的话代表这个JWT无效。
JWT存Redis里算不算开Session的倒车
实际上,如果你稍微细品一下,就不难发现把JWT存在Redis里这件事其实是很抽象的。试想一个这样的问题,JWT带有Payload,本质就是用户的登录状态,你把JWT存在服务器的Redis里,这是什么行为?这不就是把用户的登录状态又存在服务器里了吗?
那这不就是自己借助JWT发明了一套Session机制吗???那为什么不直接用Session机制?
这个时候我们或许还有点狡辩的空间:Session的Session ID要存在客户端的Cookies里,客户端如果没有Cookies这个东西......
其实说大实话,这并不是一个很站得住脚的问题,你没有Cookies机制完全可以自己造一套,反正本质是服务端给你传了Session ID,你得想法子存起来不就得了。
试想一个这样的问题,在APP刚兴起,JWT还没流行,大家还在用Session机制的时候,那个时候的APP开发者是咋实现的登录状态保存呢?他们肯定有人实践过Session方案的APP场景下的应用了,这显然就不是个问题。
并且,本来Session只需要把状态存在服务器里一份就行了,现在好了,用户一份服务器一份,这何必呢?你为啥不只在服务器里存一份。合理怀疑是后端开发者看服务器太累了,想让用户的客户端也别闲着,感同身受一下服务器的辛苦(bushi
把JWT放在Redis里是针对加解密操作的优化吗?
我在一些培训机构的课程里看到一种很有意思的想法,这个老师显然是支持把JWT放在Redis里面的,他给了一个很有意思的理由,说这样能优化加解密的性能。
具体是:我们的接口在鉴权的时候,需要对JWT进行解密操作,并且JWT的生成还涉及到了加密操作,而JWT的加解密操作是一种消耗CPU的行为。现在我们把JWT放在了Redis里,那么加解密操作被减少了,只有存、取和匹配三种操作了,减少了CPU开销,从而优化了性能。
其实我觉得这个说法是非常站不住脚的,有点幽默的成分在里面。
我们先扔掉智商来想一想他的这个思路:
- 当我需要给用户签令牌时,我可以从Redis里读之前签好的令牌给用户返回,减少了加密开销,没有的话我再存一个新令牌到Redis里并返回给用户
- 当用户调用我的接口并且需要鉴权时,我可以直接把令牌去Redis里找,找到了就ok,没找到就不ok,变成了匹配操作
现在我们智商再次上线,来想想看这个观点真的对吗?
- 加解密根本就不是关键瓶颈。首先这个字符串并不长,他的加解密性能理论上根本就不是大头,我们采用的加密算法本质上就是一个单向不可逆的加密算法,从设计的原理上以及JWT这么多年的实际应用上来看,这里就不可能采用一个耗时的加密算法。我们为什么要在这里考虑加解密的性能瓶颈?
- 用Redis缓解性能问题,是用IO开销换计算开销,是负优化行为。本来加解密操作在CPU里计算的好好的,你非要换成Redis的操作,后端与Redis的交互是IO操作,这不是性能更差了吗?我到现在是完全没理解这哪儿做优化了。
并且,根据他的这个思路,正如我前文启发的那样,我为什么这个场景非要用JWT呢?你尝试思考一下,如果我随便找一个根据用户名生成随机字符串的方法(比如根据用户名+时间戳生成UUID),取代掉JWT,他的思路也能行,没有任何毛病。可见这些人作为JWT的支持者,干的事情看起来并不是那么支持JWT。
这种论调其实在一些培训机构和面试八股文文档里有时能看到,我到现在也想不通到底是咋想的。
也许可行的解决方案
我们不得不承认,JWT确实天生在需要撤销的场景上有不足,如果不依靠其它的手段去做限制,这个需求是根本没法做的。
把整个JWT存进Redis里这件事根据刚才的分析未免有些太抽象了。不过我们不能完全否定这个做法的所有思想。能不能提出来一些更有意思的解决方法呢?我觉得其实是不难的。
方案1:密码版本号机制
我自己想出来了一种机制。在这个机制里,不妨引入一个叫做“密码版本号”的概念,有:
- 用户注册的时候,需要创建用户信息,这个时候的用户密码为初版密码,版本号是1
- 之后每次修改一次密码,都要把版本号递增1
- JWT字段中要包含一个自定义Claim:
- 自定义Claim的值是根据密码版本号生成的。
- 这个生成方法必须能有办法判断这个自定义Claim的值是不是由某个密码版本号生成的。
- 某个Claim的值只能由一个密码版本号生成,即密码版本号集合对应该Claim值集合的映射是一对多的,反过来必须是一对一的
- 在鉴权的时候,首先要鉴别用户当前的密码版本号能不能生成JWT中自定义Claim的值,不能代表失效JWT,否则才能进行其它处理
我们不难想到,引入了这样的密码版本号机制能解决很多问题:
- Redis里只需要存储
<String, Integer>
键值对即可,我们没必要把完整的用户JWT存起来了 - 判断逻辑很简单
- Redis的角色回归了数据库的缓存角色。正常情况下密码版本号是存在数据库里的,对于高频访问的用户,我们可以把它拉近Redis里,这个拉取和存储到Redis的代价很低,因为只有一个数字罢了
- 这个方案其实用不到很大的数字,一个正常用户哪怕天天改密码,也估计很难用完一个
int
类型,否则这个用户多少是有点毛病
方案2:临时黑名单机制
这是我在别的网友那里看到的方案,他设计了一种这样的机制:
- 用户修改密码,就在Redis里放进一个这样的键值对,其中键是
USER_XXX:[UserId]
,值是改密码的时候的时间戳 - 鉴权时,判断JWT的签发时间(JWT的Payload里有
iat
这个Claim),如果在Redis里有数据,并且那个数据对应的时间戳晚于这个JWT的iat
,就不ok,否则做别的处理 - Redis里的这个键值对的有效时间不能少于最大的JWT有效期限
可以发现这种方案下,Redis的这个记录相当于一个临时过滤器,会把这个用户改密码前的所有的JWT给拦截掉,这也是一种解决方案。
问题二:AccessToken和RefreshToken存在哪儿?
AccessToken与RefreshToken
正常情况下,我们登录用户以后,服务端返回一个JWT,这个JWT是我们这个登录的有效凭证,叫做Token。此时我们只有一个Token一把梭。
有很多缘由使得有些人提出了双Token方案,这两个Token分别叫做AccessToken与RefreshToken,这两个Token会在登录成功后一起返回给客户端。对于它们:
- AccessToken是用户主用的Token,具有实际操作能力,但是有效期非常非常短
- RefreshToken是专门用来给生成AccessToken接口校验用的Token,不具有实际操作能力,专门用来生成AccessToken,有效期限长一点
可以发现这样做也是有优势的。
- 如果我们的AccessToken泄露了,那么我们还能安慰安慰自己,反正AccessToken的有效期是很短的,损失可能不会非常大。
- 如果我们现在撤销了用户的某个角色,如果Token有效期很长,那服务端就很难去更新JWT的内容了,而AccessToken很快就过期了,客户端在下次更新的时候会同步下来JWT的更新情况
AccessToken过期后,客户端可以做一些无感刷新处理,比如识别到AccessToken过期就自动用RefreshToken生成一个新的AccessToken续上。
如果AccessToken的有效时间过长就失去了这个系统的意义,过短太影响用户体验。
localStorge和Cookies
针对这个问题,对于网站而言,有一种解决方案是把AccessToken存在localStorge里,把RefreshToken存在Cookies里,理由有:
- localStorge用JS代码是可以轻易拿取到数据的,现代浏览器很难通过JS代码获取Cookies数据
- Cookie能设置同源站点策略,只允许特定的路径才能使用,所以我们可以固定存储RefreshToken的Cookie只能在刷新Token相关的接口上才能用
- Cookies是浏览器自动传输的,并且服务端通过
Set-Cookie
这一Header可以自动设置RefreshToken,全过程无JS代码参与,相对而言安全性更高一些 - AccessToken如果也存Cookie,通常在做鉴权校验的时候都是需要做在
Authorization
这一Header里的,由于JS代码没办法读取Cookie,因此发送请求时无法从Cookie里获得AccessToken,使用不方便