TT Lab
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

服务器如何察觉被盗的刷新令牌

在 TT Lab 中继续学习

一句话总结

让 access 令牌短命,用 refresh 令牌来换新。作为交换,refresh 用一次就会更换(轮换),一旦已经用过的 refresh 再次进来,就切断由该次登录延续出来的全部令牌(复用检测)。这是一种即使服务器不知道谁是小偷,也能让盗用停下来的设计。

为什么需要它

让 access 令牌短命,即使泄露,受损窗口也很短。RFC 9700 第 4.14 节 写道,refresh 令牌正是让这一点成为可能的——因为不必让用户重新登录,就能持续签发寿命短、范围窄的 access。

问题是风险转移到了 refresh 一侧。同一节警告说,refresh 代表该客户端获得的全部权限,一旦被偷走并重放,攻击者就能随意制造 access。在服务器上持有密钥的机密客户端在使用 refresh 时要做客户端认证,所以仅凭令牌用不了(RFC 6749 10.4)。但浏览器 SPA 或移动应用这样的公共客户端,没有可以藏起来的密钥。

所以 RFC 9700——长期以草案 draft-ietf-oauth-security-topics 之名为人所知、于 2025 年 1 月作为 BCP 240 发布的文档——在第 2.2.2 节规定,公共客户端的 refresh 必须(MUST)是 sender-constrained 的,或者使用轮换。前一种是用 DPoP、mTLS 把令牌绑定到客户端密钥的方法,本实验亲手实现后一种。

工作原理

轮换是指每次用 refresh 获得新的 access 时,也同时给一个新的 refresh。RFC 9700 原文写道“The previous refresh token is invalidated, but information about the relationship is retained”。之所以不删除旧令牌而只留下“已使用”标记,原因就在这里。如果删掉,再次进来的旧令牌就无法区分是第一次见到的垃圾值还是已用过的令牌,复用检测也就失去了依据。

复用检测是这样成立的。令牌泄露后,攻击者和正常客户端都使用它,先用的一方会使其轮换,所以后用的一方一定会拿出已失效的令牌。服务器不知道谁是攻击者——可能是攻击者先用的。所以 RFC 9700 要求此时撤销处于活动状态的 refresh。正常用户也得重新登录,但攻击就此停止。由一次登录延续出来的令牌链称为令牌族(family),撤销以令牌族为单位。

POST /login         fam:F state=active   rt1(uses=0)  at1
POST /token rt1     rt1 uses=1 → rt2, at2               정상 회전
POST /token rt1     uses=2 → 재사용! fam:F state=revoked → 400 invalid_grant
POST /token rt2     계열이 죽었음 → 400 invalid_grant
GET  /api/me at2    계열이 죽었음 → 401 invalid_token

响应码也遵循标准。对无效、过期、已撤销的 refresh,令牌端点返回 400 invalid_grant(RFC 6749 5.2),受保护的 API 对无效的 access 返回 401 和 WWW-Authenticate: Bearer error="invalid_token"(RFC 6750 3.1)。退出登录做成 RFC 7009 撤销端点的样子。撤销 refresh 时,建议(SHOULD)把出自同一次授权的 access 也一并作废,而且即使来的是未知令牌也返回 200——因为让该令牌无法使用这个目的已经达到了。

最后,“已使用”标记必须是原子的。如果把读取、比较、写入分开做,对同一令牌同时到来的两个请求都会看到“还没用过”,于是两个都拿到新令牌。Redis 的 HINCRBY 把递增和返回结果放在一条命令里,所以看到 1 的请求永远只有一个。

在现场相遇的样子

开启轮换后,会收到“偶尔自己就退出登录了”的报告。两个标签页同时想刷新已过期的 access,把同一个 refresh 发了两次,第二次就会被看作复用,整个令牌族被一并清掉。这不是攻击,而是竞争状态。要改的地方不是检测,而是客户端——把刷新请求汇集到一处,只发一次。Keycloak 这样的产品把“开启轮换”和“允许复用的次数”分开设置,也是这个原因,但次数增加多少,检测就松懈多少。

另一个是长期无人管的 refresh。RFC 9700 建议(SHOULD)客户端一段时间不使用 refresh 就让它过期,并且允许(MAY)在退出登录或修改密码等安全事件时自动撤销。本实验中,由 Redis 键的 TTL 负责不活动过期。

下一项实验要做什么

搭建一个把令牌族存放在 Redis 里的小型签发方。通过登录拿到一对令牌,用 refresh 获得新的 access,并查看旧的 refresh 是否被拒绝。接着再次提交被偷走的旧 refresh,确认正常客户端的最新 refresh 和 access 是否也一起失效,再经过短 access 寿命和退出登录撤销,用一个脚本证明整个流程。