四、JWT 的其他问题
除了安全问题,JWT 还有许多其他需要考虑的问题。
1. 注销问题
因为 JWT 是无状态的,所以它的有效期完全由其本身决定,也就是说服务端无法让一个 token 失效。显然这是一个比较大的问题,对此也有诸多解决方案:
1.1 客户端主动注销
客户端直接删除存储 token 的 cookie
这种方案最为简单,操作的结果是无论客户端还是服务端都没有这个 token,可问题是,这个 token 并没有真正不可使用,而是处于一个游离态。
黑名单策略
客户端携带要注销的 token 访问一个注销接口,服务端把 token 加入一个黑名单。
此策略是否会出现黑名单过大的问题?
答案是不会,因为黑名单只需维护本身没有过期但又要使其无效的 token,过期的 token 就可以不用存在黑名单了。
1.2 服务端主动注销 \ 用户修改密码
把 token 和 uuid 用 key-value 对存储在 redis
这种方案看上去没问题,但是实际上,相当于自己实现了一次 cookie + session,JWT 就失去了『无状态』这一特性,从也会失去『无状态』特性带来的一系列的优点。
让每个用户都有一个 secret
前面讲到签发 token 的时候用到了 secret ,这种策略的思想就是让每个用户都有一个 secret,注销一个用户的时候修改其 secret,即可使其前面签发的 token 无法通过校验而失效。
这种策略上听上去不需要维护一个状态,但是实际上存在更大的问题。试想一下,第一种方案是通过 uuid 在已登录用户的 token 表中找到要注销的 token 注销。cookie + session 是通过 session_id 在已登录的用户的 session 表中找到其对应的 session 并删除来注销。而此方案是通过 uuid 在所有用户(而非已登录用户)中找到对于的 secret 修改来注销。这样看来会发现效率更低,因为查找范围更大了。
预黑名单
把要注销的用户的 uuid 和当前时间(TIME) 组成 key-value 对加入预黑名单,下次请求来时,若其 uuid 和黑名单中的对应,并且签发时间在 TIME 之前,则将其注销。这样查找范围就是未过期但又要注销的用户。并且在实现逻辑上这个预黑名单可以和签名的黑名单做到一起。
关于黑名单策略的补充:
有人可能会觉得黑名单也是一种状态,用这种策略实现的 JWT 并不能算纯正的无状态。这种说法没错,但是考虑每次要检索的数据范围可以得到下面一个关系:
未过期但要提前注销的用户或 token 数 < 所有已登录用户数 < 所有用户数
此处的『 < 』基本可以看成『远远小于』,所以黑名单策略虽然也算有状态,但是其维护的状态数也是特别小的。
可见 『黑名单』策略能够有效解决 JWT 的注销问题。
2. 续签问题
session 可以自动续签,那 token 如何实现自动续签呢?我们先仔细分析一下在 web 和 app 环境中,token 分别如何续签。先具体分析 web 续签和 app 续签分别是什么样的具体需求。
web
超过一段时间没有请求,需要重新登录,这个时间一般设置为 1-2 小时
app
超过一段较长的时间没有请求,需要重新登录,这个时间一般为 15-30 天
那这个需求可以如何实现呢?
2.1 方式一
服务端接管刷新
token 设置一个『过期时间』
token 过期后但是仍在『刷新时间』内时仍然可刷新
token 过期后超过『刷新时间』就不能再刷新,需重新登录
web
假设一个 token 的签发时间为 12:00,需求为 2h 未进行请求就要重新登录。则过期时间为 1h,刷新时间为 3h。
那么在 12:00 - 13:00 其都是可以正常使用的,如果在 13:00 - 15:00 进行请求,服务端自动换一个新 token 给客户端,达成续签。
如果 13:00 -15:00 之间没有进行请求,而是在 15:00 之后进行的请求,那么判断过期,需重新登录。
这样的话,最终的实现效果是:token 过期 2h 后需要重新登录 ,而不是 token 2h 未使用需要重新登录,导致的结果是,用户是 2 - 3h 未进行请求,需要重新登录。比设定的需求要多一个小时的不确定时间,但这也是没办法的办法了,至于会不会对业务造成影响,看具体需求吧,大多数的情况还是不会的。
app
和 web 端类似,设置成更长的时间周期即可。
对使用 Laravel 开发并使用 tymon/jwt-auth 这个插件的开发者,有个必须要注意的地方。
此处进行 token 的刷新并不是通过 refresh 这个操作获得新 token,因为这样 token 在不断的刷新过程中会达到一个刷新时间的上限。而上面的逻辑是每次都新签发一个 token,只要不断签就能够一直使用下去。 然后这里的旧 token 放入黑名单,黑名单有效期设置为『刷新时间』—— 3h。
当然如果开发者觉得这样不断签就能够一直使用不太好,那就可以设置更长的刷新时间,用 refresh 操作来获取新 token,刷新时间保证每次登陆得到 token 后,即使每次及时续签,最终也不会超过刷新时间。
然后这里又会出现一个新坑:
如果刷新时间设置为 14 天,过期时间设置为 2h。
token A 在 『 <= 14 天 』时刷新得到 token B,此时若再拿 token A 去请求刷新,肯定是不允许,否则 token 会出现『 1 变 N 』的问题,所以显然必须设置一个黑名单去放这些已过期但是又已经刷新过的 token。而这个黑名单的有效期范围应当为 token 的刷新期,即 14 天。然后你会发现对于每个用户每次登陆,需要维护的黑名单 token 数目最大可达 14 * 24 / 2 = 168 个,黑名单变得很大。
所以,如果要使用 refresh 操作,刷新时间务必是过期时间的尽量小的倍数。
2.2 方式二
每次请求 token 都进行一次刷新
token 设置一个过期时间
token 过期后无法再刷新
token 没必要设置刷新时间了
web
假设一个 token 的签发时间为 12:00,需求为 2h 未进行请求即过期。则设置有效期 2h,不需要设置刷新期。那么每次请求都会把一个 token 换成一个新 token。如果 2h 没有进行请求,那么上一次请求的到的 token 就会过期,需要重新登录。同样是不断签就能一直使用下去。
如果想要和上面一样,不希望永久续签,则设置一个刷新时间即可。这个刷新时间不会导致进一步膨胀。
app
和 web 端类似,设置更长时间即可。
然后又到了问题时间:
1.每次都刷新 token,带来的性能影响如何?
以前每次请求,需要进行一次 token 签名校验,而现在是要签发一个新 token,进行的都是一次签名运算,那么运算量即从 n 变成 2n。
其次,每次刷新都要把旧 token 加入黑名单,会导致黑名单特别大,远远比方式一的设置刷新期大。
2.每次都刷新 token,并发请求时会不会因为 token 刷新而导致只有一个请求成功?
答案是确实会导致这个问题,怎么解决呢?设置一个宽限时间,每次 token 刷新后,原来逻辑应该是立刻不可用,现在设置一个宽限时间,让其在 n 秒之内仍然可用即可。
总之,这种策略会导致花费的 CPU 运算翻倍,并导致巨大的黑名单,然后必须设置一个宽限时间以解决并发请求问题,至于宽限时间会不会带来安全问题,微乎其微吧。
2.3 黑名单膨胀的解决方案
上面讲到,对于方式一【限定不能一直续签】,会导致巨大的黑名单,对于方式二,总会导致一个更加巨大的黑名单。那有没有解决方案呢?当然是有的。
我们可以这么想,既然一个 token 进行了刷新,那么签发时间在这次刷新之前的即可认为无效。于是,和上面的『预黑名单』策略类似,我刷新时不是把一个 token 加入黑名单,而是把 uuid-refresh_time 组成 key-vakue 对加入黑名单,这样针对每个用户的每次登陆,要存储到黑名单中的条目数就从 N 个变成了一个。
但是这样还要考虑一个问题:就是一个用户开两个浏览器,在不同的时刻在同一个系统都登陆了(假设业务允许),那么一个浏览器的 token 刷新就可能会导致另一个浏览器登陆失效。所以存储在黑名单中的 key-value 应该再加一个 key 以代表每次登陆,并且这个 key 要在 JWT 的载荷中随着刷新一直传承。
基于以上的优化,黑名单的大小变成了:每个用户同时登陆的系统个数之和,就变的和 cookie + session 一样了。
比如,A 系统(假设 2h 过期时间,14 天刷新时间),你用一个浏览器登陆了你的账号,我用 Chrome 浏览器登陆了我的账号,然后我又用 QQ 浏览器再登陆我的账号,那么黑名单的大小就为 : 1 + 2 = 3
而对于方式一【限定不能一直续签】,黑名单的大小(最大):168 + 168 * 2
而对于方式二,黑名单的大小为:你在 2h 内请求的次数 x ,我在 Chrome 浏览器请求的次数 y,我在 QQ 浏览器请求的次数 z 之和,即:x + y + z
2.4 总结
如果要解决续签问题,方式一【可以一直续签】是个比较好的解决方案,虽然会带来一点小问题,但是并不会有太大的影响。方式二【限定不能一直续签】和 每次刷新会让黑名单的维护量和有状态差不多,但是有更高的安全性。
3. token 有没有必要每次刷新
我们先列举每次刷新 token 的优缺点:
优点:
能够实现续签
能够解决重放
更安全
缺点:
双倍的 CPU 消耗
几乎和有状态一样的空间消耗
必须设置宽限时间解决并发问题
上面讨论过,『续签』和『重放』都可以通过其他方式解决。只有『更安全』算半个痛点,为什么是半个痛点呢?因为如果采用 HTTPS 的话,那么盗取 token 的手段就只要以下几种办法:
破解 HTTPS
直接从你电脑上手抄过去
XSS【前面说到为了能够让 js 读取,不能设置 HTTPOnly】
只有第三种方法存在一点可能性。
所以,要不要每次刷新,还是根据各位的具体业务情况进行选择吧。
五、JWT 适合用来做什么
1. 无状态的 RESTful API
这个显然很适合。
2. SSO 单点登录
单点登录必须要实现的:
会话管理:通过黑名单和预黑名单解决
续签:通过签名的解决方案解决
可见,对 JWT 部署一些额外逻辑(黑名单,续签管理)即可让 JWT 在大部分场景代替 cookie + session。
六、JWT 与 Oauth2.0
Oauth 2.0 是干嘛的不再赘述,它与 JWT 其实并不是一个层面的东西。Oauth2.0 是一个方便的第三方授权规范,而 JWT 是一个 token 结构规范。只是 JWT 常用来登陆鉴权,而 Oauth2.0 在授权时也涉及到了登陆,所以就比较容易搞混。
但是在此,我要说的是,Oauth 2.0 其实可以和 JWT 结合使用。
以下是一个常见的 Oauth2.0 登陆返回:
{ "access_token":"kag2geh11a3eh56e23hj", "expires_in":7200, "refresh_token":"jgko97cq4c8wn69j", "scope":"SCOPE" }
在 Oauth2.0 中,access_token 用来进行数据请求,而 refresh_token 用来刷新 access_token。每次刷新,上一个 access_token 就会失效,而 access_token 和 refresh_token 显然都没有记录任何状态,所以必须为服务端进行状态的维护。
把 JWT 和 Oauth2.0 结合后,可以得到这样的返回:
{ "access_token":"xxx.yyy.zzz", "expires_in":7200, "refresh_token":"xxxxx.yyyyy.zzzzz", "scope":"SCOPE" }
进行结合后有如下优势:
Oauth2.0 的 token 也能够实现无状态(虽然也要用到黑名单)
Oauth2.0 的 token 也能够附带部分常用数据
前面讲到 JWT 续签,在需要限定不能一直续签的情形,可能会导致黑名单库膨胀,但是和 Oauth2.0 结合,通过 refresh_token 的机制,让黑名单库中 token 的有效期从 『刷新时间』又变回『过期时间』,从而解决了这个问题。
七、关于 token 十件必须知道的事
这是我从 Auth0 组织的这篇文章 10 Things You Should Know about Tokens 整理过来的:
- Token 获取到后需要保存起来以便下次使用,可以选择存储在 localstorage /sessionstorage/cookie
- Token 是包含有效期的,你必须部署一些逻辑来进行有效期的控制
- localstorage /sessionstorage 的跨域限制较 cookie 更为严格,推荐使用 cookie
- 在你进行异步请求时,浏览器一般都会发送预检请求(option),后端应对此部署相应的逻辑,为什么会有 OPTIONS 请求 - 云 + 社区 - 腾讯云
- 使用 cookie 可以轻松处理一个文件下载请求,但是 token 一般都是通过 XHR 方式进行请求的,所以你必须部署额外的逻辑。比如生成一个实时 ticket ,以 ticket 进行访问,然后校验,重定向,最后下载文件。
- 处理 XSS 比处理 CSRF 更容易(这一点我实在没看到他是什么个逻辑,大家可以去看看原文)
- token 在每次请求时都会被编码到请求中,所以请注意 token 的大小,不要编码过多数据
- 如果在 token 中编码敏感信息,请对 token 进行加密
- JSON Web Token 可以用于 Oauth2.0 的 Bearer Token 中,赋予 Oauth2.0 无状态的优势
- Token 不是银弹,请根据实际业务需要进行选择