2018/10/8
续签部分补充了更多细节,签名原理部分,经 @龙卷风指出存在错误,已改正。
本篇文章不讨论 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 通过一次登录验证,得到一个鉴权字符串,然后以后带着这个鉴权字符串进行后续操作,这样就可以解决每次请求都要带账号密码的问题,而且也不需要反复使用账号和密码。
所以我们接下来主要探讨 token 相对于 Cookie + Session 的认证方式有什么优势呢?
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
以上,我仔细介绍了 API 常用的鉴权方式,以及 token 相对于 cookie + session 的优点。然后接下来仔细分析 JWT。
二、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)的,并不都是必需的。
iss (issuer):签发人
sub (subject):主题
aud (audience):受众
exp (expiration time):过期时间
nbf (Not Before):生效时间,在此之前是无效的
iat (Issued At):签发时间
jti (JWT ID):编号
公有的载荷
在使用 JWT 时可以额外定义的载荷。为了避免冲突,应该使用 IANA JSON Web Token Registry 中定义好的,或者给额外载荷加上类似命名空间的唯一标识。
私有载荷
在信息交互的双方之间约定好的,既不是预定义载荷也不是公有载荷的一类载荷。这一类载荷可能会发生冲突,所以应该谨慎使用。
将上面的 json
进行 Base64Url
编码得到载荷,,即 yyyyy
。
关于载荷的理解:
这里三种载荷的定义应该明确的一点是 —— 对于后两种载荷,它并非定义了载荷的种类,然后让你去选用哪种载荷,而是对你可能会定义出来的载荷做一个分类。
比如你定义了一个
admin
载荷,这个载荷按其分类应该是私有载荷,可能会和其他人定义的发生冲突。但如果你加了一个前缀(命名空间),如namespace-admin
,那么这应该就算一个公有载荷了。(但其实标准并没有定义怎么去声明命名空间,所以严格来说,还是可能会冲突)但是在现实中,团队都是约定好的了要使用的载荷,这样的话,好像根本不存在冲突的可能。那为什么文档要这么定义呢?我的理解是,RFC 是提出一种技术规范,出发点是一套通用的规范,考虑的范围是所有开发者,而不仅仅局限于一个开发者团队。就像用 token 做认证已经是很常见的技术了,但是 JWT 的提出就相当于提出了一套较为通用的技术规范。既然是为了通用,那么考虑在大环境下的冲突可能性也是必须的。
签名(Signature)
签名时需要用到前面编码过的两个字符串,如果以 HMACSHA256
加密,就如下:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
加密后再进行 base64url
编码最后得到的字符串就是 token
的第三部分 zzzzz
。
组合便可以得到 token:xxxxx.yyyyy.zzzzz
。
签名的作用:保证 JWT 没有被篡改过,原理如下:
HMAC 算法是不可逆算法,类似 MD5 和 hash ,但多一个密钥,密钥(即上面的 secret)由服务端持有,客户端把 token 发给服务端后,服务端可以把其中的头部和载荷再加上事先共享的 secret 再进行一次 HMAC 加密,得到的结果和 token 的第三段进行对比,如果一样则表明数据没有被篡改。
Hash-based Message Authentication Code
2. 使用
JWT 的使用有两种方式:
加到 url 中:
?token=你的token
加到 header 中,建议用这种,因为在 https 情况下更安全:
Authorization:Bearer 你的token
JWT 在客户端的存储有三种方式:
LocalStorage
SessionStorage
Cookie [不能设置 HTTPonly]
但是最推荐的还是第三种,因为第一二种存在跨域读取限制,而 Cookie 使用不同的跨域策略
因为没开 HTTPonly,所以要注意防范 XSS 漏洞。
Cookie 的跨域策略
子可以读父,但是父不可以读子,兄弟之间不能互相访问。
a.xxx.com 和 b.xxx.com 可以读 xxx.com,但是 a.xxx.com 和 b.xxx.com 不能互相读取,xxx.com 也不能读 a.xxx.com 和 b.xxx.com 的。
你可能会想:存 Cookie 那我不是又变得和 cookie + session 一样了吗?
其实不然,因为存 cookie 在这只是用到了其存储机制,而没有利用其去鉴权。也就是说我只是简单存一下,并没有期望浏览器带上去 token 去鉴权,将 token 加入请求这部分操作还是我手动进行的。
3. 相对于一般 token 的优点
既然 JWT 也是一种 token,那么它相对于普通的 token 有何优点呢?
无状态
因为 JWT 的有效期完全与其载荷中编码的过期时间,服务端不维护任何状态,因此 JWT 『一般』是『无状态』的(为什么是一般,后面会仔细说)。无状态最大的优势在于三点:
节省服务器的资源:因为服务端无需维护一个状态,因此能够节省服务端原先保存这些状态所花费的资源
适合分布式:因为服务端无需维护状态,因此如果服务端是多台服务器组成的分布式集群,那么无需像『有状态』一样互相同步各自的状态。
时间换空间:因为 token 的校验时通过签名校验来进行的,签名校验消耗的是 CPU 时间,而『有状态』是需要通过客户端提供的凭据对服务端现有的状态进行一次查询,消耗的是 I/O 和内存、磁盘空间。通常对于一个 Web 服务来说,其属于 I/O 密集型,因此通过时间换空间这一操作,可以提高整体的硬件使用率。
编码数据
因为 JWT 能够在载荷中编码了部分信息,所以如果把常用数据编码进去的话,能够大大减少数据库的查询次数,不过有两点需要额外注意的:
载荷信息是明文编码的,所以不能编码敏感信息在里面,如果要编码可以先加密再编码进去
token 在每次请求时都会进行传输,所以载荷中不能编码过多的信息,否则会降低传输效率
所以 JWT 就有四个优点了:
防 CSRF
适合移动应用
无状态
编码数据
前两个是 token 的优势,后两个是 JWT 独特的优势。
三、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 加入一个黑名单。
此策略是否会出现黑名单过大的问题?
答案是不会,因为黑名单只需维护本身没有过期但又要使其无效的 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
操作,刷新时间务必是过期时间的尽量小的倍数。
为什么客户端还需要保存
secret
?这样是不是不安全?比如 web 中,如果客户端保存了secret
,那么就可以手动生成 token 了?