前言
在支持用户注册以后,service 一炮打响,地球人都知道。
还没来得及为滔天的流量高兴,service 的副作用就出现了。
谁动了我的奶酪?
有了 access_token,用户们不需要每次刷新网页都输入一遍密码了,好。
但是有人发现这个 access_token 没有有效期限,坏。
然后就有人发现自己的帐号被不知道谁盗用了,坏坏。
不知道怎么清除 cookie 的用户发现退出重进了以后还是会被别人盗号,坏坏坏。
我家走上了信息土石路!
因为每次验证 access_token 都要涉及数据库查询,登录速度慢得天怒人怨。
小白的好诡秘全都找她吐槽来了,她恨不得自己从来没有开放过注册功能。
Solution?
在一阵搜索以后,小白惊奇地发现两个问题居然可以一次解决,这个方案就是
JWT!
JWT 的全名叫 Json Web Token,是一种无状态的令牌。
状…状态?
传统的会话是有状态的 (stateful)。在 01-03 的系统设计中,基于 access_token 的会话也是有状态的。
这个「状态」,指的是服务器会保存与会话有关的信息。这个方法的好处和坏处都很明显:
- 好处:会话发出的请求可以只包含本次请求需要的简短内容。
- 坏处:服务器需要对于每个会话保存相应的信息,极大地减小鲁棒性和处理效率。
例如,要完成对某项机密资源的访问,有状态的请求可能会长这样:
|
|
而无状态的 (stateless) 的请求可能会长这样:
|
|
(注意:这个请求是被解码的,并且一定不是安全的最佳实践。一般的服务会使用 JWT 进行无状态的管理,这将是接下来要讨论的内容。)
那我问你,无状态就这么好吗?
使用像 JWT 这样的无状态令牌的好处和坏处也都很明显。
- 好处:服务器资源对 JWT 是八十万对六十万,优势在我(这是真的),可以极大地减少服务器的运算需求。
- 坏处:如果用户在 JWT 预定失效时间前注销,撤销 JWT 的有效性是一个问题。
总体而言,JWT 的好处大于坏处,所以多了不说少了不唠,开干!
JWT 的组成呢那我问你
JWT 在解码后由三部分组成:header, payload 和 signature。
header 一般包含 JWT 的加密算法和类别。一般来说类别就是 JWT。
payload 一般包含 JWT 的主要数据。
signature 包含认证服务器对 JWT 的签名,不然 JWT 该什么阿猫阿狗都能生成了。
那我问你,你的 Redis 怎么亮亮的?
解决 JWT 令牌的有效性实时更改问题的一个可行方法还是拿数据库,而且是「快的」数据库。
Redis 是 in-memory 的,再快也没它快了,美塌咧。
小白决定让 JWT 的 payload 包含一个 ID (jti),比如 aaaaaaaa-bbcc-ddee-ff00-123456789abc。
按照 RFC 7519 的 §4.1,小白决定再给 JWT 加一个 exp 字段,用来规定 JWT 的到期时间。
这样,JWT Payload 解码后大概会长这样:
|
|
假如用户在 time = 1758113514, 也就是 Wed Sep 17 2025 20:51:54 GMT+0800 (China Standard Time) 时注销了,那么就把这个 ID 加到 Redis 数据库中,标记它是无效的,并且将它在 Redis 数据库的 TTL 设置得比 exp 时间戳长一小点,例如 1758114524。
接下来,在每次验证的时候向 Redis 数据库查询 id 是否存在,如果存在就阻止用户的登录就行了。
小白觉得这个想法特别好,但是上线了一段时间以后发现盗号现象还是时有发生:exp 还是太长了,只要攻击者能在这段时间内拿到 JWT,那么还是可以伪装普通用户的身份。于是小白决定使用
短期 Access Token + 长期 Refresh Token!
既然问题出在 exp 太长,那把 exp 调短不就行了嘛。
但是这样带来的问题是一般通过的、携带 Access Token 的用户也会需要频繁登录,这样小白说不定会被喷死,坏。
这个时候她的救星来了:Refresh Token!
首先,小白在用户每次登录的时候都创建一个 Refresh Token,并且让其具有较长的有效期(例如 7 天)。这个 Refresh Token 会以 HttpOnly; Secure; SameSite=strict 的 Cookie 方式存储在本地。
此后,对于每个受保护的页面 /protected/page,使用 use client,这样就可以使用 React State 来管理 Access Token 了,这样:
- 攻击者一般拿不到 Refresh Token: JS 拿不到存有 Refresh Token 的 Cookie.
- 攻击者就算拿到了 Access Token,
exp也很短;更不用说拿到 Access Token 也是很难的。 - 如果攻击者非常不幸地两个都拿到了,那就该直接
sudo rm -rf /*跑路了(
Refresh Token 会在页面对后端请求失败的时候出手:如果失败的原因是 Access Token 过期,而 Refresh Token 仍然有效,那就直接用 Refresh Token 向 /api/auth 发出请求,更新 Access Token 就行。
这下,小白感觉这个 service 的认证管理应该够安全和强力 (robust) 了,她应该考虑点别的了。