转载JWT 超详细分析
本篇文章不讨论 Laravel 中 JWT 这个怎么使用,要这方面内容的可以看我另一篇文章 JWT 完整使用详解 。
在此我要从一个更深的层次来探讨 JWT 在实际运用中的使用以及其优缺点,以及 JWT 和 Oauth 2.0 这两者到底有什么差别和联系。
首先我们从 Token 入手,再联系到 JWT,然后分析 JWT 的优缺点和使用场景,最后再联系到 Oauth2.0。
一、Token
token 是一串字符串,通常因为作为鉴权凭据,最常用的使用场景是 API 鉴权。
1. API 鉴权
那么 API 鉴权一般有几种方式呢?我大概整理了如下:
cookie + session
和平常 web 登陆一样的鉴权方式,很常见,不再赘述。
HTTP Basic
将账号和密码拼接然后 base64 编码加到 header 头中。很显然,因为账号和密码几乎是『明文』传输的,而且每次请求都传,安全性可想而知。
HTTP Digest
将账号和密码加上其他一些信息拼接然后取摘要加到 header 头中。这个安全性比上面要好一点,因为如果是取摘要的话,即使信息段被截取,也无法轻易破解出来(当然也是有破解的可能)。
不过其实最大的问题还是:每次请求都要对账号、密码取一次摘要,也就是说每次请求都要有账号和密码,也就是说账号和密码要么缓存一下,要么就每次请求要去用户输一次密码,这样显然不合适。同样,上面的 Basic 也存在这样的问题。
Token
token 通过一次登录验证,得到一个鉴权字符串,然后以后带着这个鉴权字符串进行后续操作,这样就可以解决每次请求都要带账号密码的问题,而且也不需要反复使用账号和密码。
2. Token 的优势
token 相对于 Cookie + Session 的优点,主要有下面两个:
CSRF 攻击
这个原理不多做介绍,构成这个攻击的原因,就在于 Cookie + Session 的鉴权方式中,鉴权数据(cookie 中的 session_id)是由浏览器自动携带发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。而 token 是通过客户端本身逻辑作为动态参数加到请求中的,token 也不会轻易泄露出去,因此 token 在 CSRF 防御方面存在天然优势。
适合移动应用
移动端上不支持 cookie,而 token 只要客户端能够进行存储就能够使用,因此 token 在移动端上也具有优势。
3. Token 的种类
一般来说 token 主要三种:
- 自定义的 token:开发者根据业务逻辑自定义的 token
- JWT:JSON Web Token,定义在 RFC 7519 中的一种 token 规范
- Oauth2.0:定义在 RFC 6750 中的一种授权规范,但这其实并不是一种 token,只是其中也有用到 token
二、JWT 的组成和优势
JWT 全称 JSON Web Tokens ,是一种规范化的 token。可以理解为对 token 这一技术提出一套规范,是在 RFC 7519 中提出的。
1. 组成
一个 JWT token 是一个字符串,它由三部分组成,头部、载荷与签名,中间用 .
分隔,例如:xxxxx.yyyyy.zzzzz
头部(header)
头部通常由两部分组成:令牌的类型(即JWT)和正在使用的签名算法(如HMAC SHA256 或 RSA.)。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后用 Base64Url
编码得到头部,即 xxxxx
。
载荷(Payload)
载荷中放置了 token
的一些基本信息,以帮助接受它的服务器来理解这个 token
。同时还可以包含一些自定义的信息,用户信息交换。
载荷的属性也分三类:
- 预定义(Registered)
- 公有(public)
- 私有(private)
预定义的载荷
{
"sub": "1",
"iss": "http://localhost:8000/auth/login",
"iat": 1451888119,
"exp": 1454516119,
"nbf": 1451888119,
"jti": "37c107e4609ddbcc9c096ea5ee76c667",
"aud": "dev"
}
这里面的前 7 个字段都是由官方所定义的,也就是预定义(Registered claims)的,并不都是必需的。
公有的载荷
在使用 JWT 时可以额外定义的载荷。为了避免冲突,应该使用 IANA JSON Web Token Registry 中定义好的,或者给额外载荷加上类似命名空间的唯一标识。
私有载荷
在信息交互的双方之间约定好的,既不是预定义载荷也不是公有载荷的一类载荷。这一类载荷可能会发生冲突,所以应该谨慎使用。
将上面的 json
进行 Base64Url
编码得到载荷,,即 yyyyy
。
签名(Signature)
签名时需要用到前面编码过的两个字符串,如果以 HMACSHA256
加密,就如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
加密后再进行 base64url
编码最后得到的字符串就是 token
的第三部分 zzzzz
。
组合便可以得到 token:xxxxx.yyyyy.zzzzz
。
签名的作用:保证 JWT 没有被篡改过,原理如下:
2. 使用
JWT 的使用有两种方式:
- 加到 url 中:
?token=你的token
- 加到 header 中,建议用这种,因为在 https 情况下更安全:
Authorization:Bearer 你的token
JWT 在客户端的存储有三种方式:
- LocalStorage
- SessionStorage
- Cookie [不能设置 HTTPonly]
但是最推荐的还是第三种,因为第一二种存在跨域读取限制,而 Cookie 使用不同的跨域策略
Cookie 的跨域策略
子可以读父,但是父不可以读子,兄弟之间不能互相访问。
a.xxx.com 和 b.xxx.com 可以读 xxx.com,但是 a.xxx.com 和 b.xxx.com 不能互相读取,xxx.com 也不能读 a.xxx.com 和 b.xxx.com 的。
3. 相对于一般 token 的优点
既然 JWT 也是一种 token,那么它相对于普通的 token 有何优点呢?
无状态
因为 JWT 的有效期完全与其载荷中编码的过期时间,服务端不维护任何状态,因此 JWT 『一般』是『无状态』的(为什么是一般,后面会仔细说)。无状态最大的优势在于三点:
- 节省服务器的资源:因为服务端无需维护一个状态,因此能够节省服务端原先保存这些状态所花费的资源
- 适合分布式:因为服务端无需维护状态,因此如果服务端是多台服务器组成的分布式集群,那么无需像『有状态』一样互相同步各自的状态。
- 时间换空间:因为 token 的校验时通过签名校验来进行的,签名校验消耗的是 CPU 时间,而『有状态』是需要通过客户端提供的凭据对服务端现有的状态进行一次查询,消耗的是 I/O 和内存、磁盘空间。通常对于一个 Web 服务来说,其属于 I/O 密集型,因此通过时间换空间这一操作,可以提高整体的硬件使用率。
编码数据
因为 JWT 能够在载荷中编码了部分信息,所以如果把常用数据编码进去的话,能够大大减少数据库的查询次数,不过有两点需要额外注意的:
- 载荷信息是明文编码的,所以不能编码敏感信息在里面,如果要编码可以先加密再编码进去
- token 在每次请求时都会进行传输,所以载荷中不能编码过多的信息,否则会降低传输效率
三、JWT 的安全问题
既然主要使用场景是鉴权,那么安全问题就是不得不考虑的问题了。下面对 JWT 可能需要的安全问题都进行一次深入的探讨并寻求最佳的解决方案。
1. 重放攻击
重放攻击是通过把原先的包进行一次重放来进行攻击的手段。需要先明确是的 cookie + session 也是存在重放攻击的问题的。
常用的防范重放攻击的措施主要有以下几种:
timestamp
在请求中夹带一个时间戳,设置较短的有效期,如果一个新来的请求的请求时间超过了请求中的有效期,则认为无效。但是这种策略也存在问题,即如果一个黑客『眼疾手快』在有效期以内将你的包进行了重放, 那就来攻击成功。
这种策略对应到 JWT 中就是给 token 设置一个较短的有效期。
nonce
在请求中夹带一个随机字符串,这个字符串传送到客户端后即存入客户端的黑名单中,如果一个新来的请求其中存在的随机字符串已经在黑名单中则认为无效。但是显然,这个策略存在巨大的问题:服务端需要维护一个黑名单库,这个库的大小会随着业务运行的时间而变得无比巨大,从而严重影响效率。
这种策略对应到 JWT 中就是给 token 设置一个黑名单,但是不设置有效期。
timestamp + nonce
在请求中夹带一个随机字符串和一个时间戳,如果一个新来的请求,其随机字符串已经在黑名单中则认为无效,或者一个请求的的请求时间超过了其有效期,则也认为其无效。这样黑名单的范围只需设置为时间戳策略的有效期范围即可。
这种策略对应到 JWT 中就是给 token 既设置一个黑名单,又设置一个有效期。
挑战-应答
这个其实和 timestamp + nonce 策略一样,只是随机字符串是有服务端生成给客户端的,客户端携带服务端所给的随机串来请求。这样有什么好处呢?服务端可以通过一个加密算法来生成这个串,使其和时间戳相关,同时客户端又无法伪造。这样就不需要维护黑名单了。同样也是时间换空间的策略。但是显然每次或几次请求就要进行一次与预请求以得到随机串,并不是特别方便,造成的额外消耗也有待考量。
序列号
通过在请求中嵌入一个序列号,每次请求依次加一,如果一个请求的序列号早已用过,则认为无效。但是这个要用逻辑额外一个全局序列号,并不是特别方便。
HTTPS
终极解决方案了,HTTPS 在握手过程中会自动维护一个隐式序列号,解决了上面要自己维护序列号的问题。
2. token 被盗
因为 token 中包含了登陆状态,因此一旦 token 被盗,那么就会被人盗用身份。那么 token 针对被盗的防范措施整理如下:
- 使用 HTTPS 传输:从传输层的角度解决问题
- HTTPOnly:从存储层的角度解决问题,防止 XSS 攻击窃取 cookie,但是这种方案其实存在问题,因为这样 js 就无法读取 token 并把它加到 header 头中了。所以不开启 HTTPOnly 的话必须要额外注意防范 XSS 攻击。
- 在 token 中嵌入客户端指纹:通过客户端指纹,即使黑客盗取了你的 cookie,他也无法用你的 cookie 进行请求。
- 设置较短的 token 有效期:这样如果 token 被盗,只要超过一定时限就无法使用。
四、JWT 的其他问题
除了安全问题,JWT 还有许多其他需要考虑的问题。
1. 注销问题
因为 JWT 是无状态的,所以它的有效期完全由其本身决定,也就是说服务端无法让一个 token 失效。显然这是一个比较大的问题,对此也有诸多解决方案:
1.1 客户端主动注销
客户端直接删除存储 token 的 cookie
这种方案最为简单,操作的结果是无论客户端还是服务端都没有这个 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 之前,则将其注销。这样查找范围就是未过期但又要注销的用户。并且在实现逻辑上这个预黑名单可以和签名的黑名单做到一起。
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 之后进行的请求,那么判断过期,需重新登录。
app
和 web 端类似,设置成更长的时间周期即可。
此处进行 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 个,黑名单变得很大。
2.2 方式二
- 每次请求 token 都进行一次刷新
- token 设置一个过期时间
- token 过期后无法再刷新
- token 没必要设置刷新时间了
web
假设一个 token 的签发时间为 12:00,需求为 2h 未进行请求即过期。则设置有效期 2h,不需要设置刷新期。那么每次请求都会把一个 token 换成一个新 token。如果 2h 没有进行请求,那么上一次请求的到的 token 就会过期,需要重新登录。同样是不断签就能一直使用下去。
如果想要和上面一样,不希望永久续签,则设置一个刷新时间即可。这个刷新时间不会导致进一步膨胀。
app
和 web 端类似,设置更长时间即可。
2.3 黑名单膨胀的解决方案
上面讲到,对于方式一【限定不能一直续签】,会导致巨大的黑名单,对于方式二,总会导致一个更加巨大的黑名单。那有没有解决方案呢?当然是有的。
我们可以这么想,既然一个 token 进行了刷新,那么签发时间在这次刷新之前的即可认为无效。于是,和上面的『预黑名单』策略类似,我刷新时不是把一个 token 加入黑名单,而是把 uuid-refresh_time 组成 key-vakue 对加入黑名单,这样针对每个用户的每次登陆,要存储到黑名单中的条目数就从 N 个变成了一个。
但是这样还要考虑一个问题:就是一个用户开两个浏览器,在不同的时刻在同一个系统都登陆了(假设业务允许),那么一个浏览器的 token 刷新就可能会导致另一个浏览器登陆失效。所以存储在黑名单中的 key-value 应该再加一个 key 以代表每次登陆,并且这个 key 要在 JWT 的载荷中随着刷新一直传承。
2.4 总结
如果要解决续签问题,方式一【可以一直续签】是个比较好的解决方案,虽然会带来一点小问题,但是并不会有太大的影响。方式二【限定不能一直续签】和 每次刷新会让黑名单的维护量和有状态差不多,但是有更高的安全性。
3. token 有没有必要每次刷新
我们先列举每次刷新 token 的优缺点:
优点:
- 能够实现续签
- 能够解决重放
- 更安全
缺点:
- 双倍的 CPU 消耗
- 几乎和有状态一样的空间消耗
- 必须设置宽限时间解决并发问题
上面讨论过,『续签』和『重放』都可以通过其他方式解决。只有『更安全』算半个痛点,为什么是半个痛点呢?因为如果采用 HTTPS 的话,那么盗取 token 的手段就只要以下几种办法:
- 破解 HTTPS
- 直接从你电脑上手抄过去
- XSS【前面说到为了能够让 js 读取,不能设置 HTTPOnly】
只有第三种方法存在一点可能性。
五、JWT 适合用来做什么
1. 无状态的 RESTful API
这个显然很适合。
2. SSO 单点登录
单点登录必须要实现的:
- 会话管理:通过黑名单和预黑名单解决
- 续签:通过签名的解决方案解决
六、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),后端应对此部署相应的逻辑
使用 cookie 可以轻松处理一个文件下载请求,但是 token 一般都是通过 XHR 方式进行请求的,所以你必须部署额外的逻辑。比如生成一个实时 ticket ,以 ticket 进行访问,然后校验,重定向,最后下载文件。
处理 XSS 比处理 CSRF 更容易(这一点我实在没看到他是什么个逻辑,大家可以去看看原文)
token 在每次请求时都会被编码到请求中,所以请注意 token 的大小,不要编码过多数据
如果在 token 中编码敏感信息,请对 token 进行加密
JSON Web Token 可以用于 Oauth2.0 的 Bearer Token 中,赋予 Oauth2.0 无状态的优势
Token 不是银弹,请根据实际业务需要进行选择
八、结语
前阵子写的两篇文章,承蒙各位关照,指出了许多问题。最近公司分享,我恰好打算对以前提出的问题结合我新的理解写一篇文章,于是便有了本文,前面两篇文章,近期我会重新整理一下,补上一些细节,修复一些错误,各位若不嫌弃到时候可以再看看。