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

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

刷新令牌轮换与重用检测

在 TT Lab 中继续学习

目标

搭建一个把令牌族存放在 Redis 里的小型签发方,并用真实的 HTTP 请求确认:每次使用 refresh 都会轮换、已用过的 refresh 再次出现时整个令牌族会被撤销、access 寿命很短、退出登录会切断令牌族。

为什么重要

让 access 短命,即使泄露受损窗口也很短,但代价是长寿的 refresh 成了新的目标。对于无法隐藏密钥的浏览器和移动应用,RFC 9700 要求把 refresh 绑定到客户端密钥,或使用轮换。轮换的力量来自复用检测——令牌泄露后,攻击者和正常客户端都使用它,两者中必有一方会拿出已用过的令牌,而服务器不知道谁是攻击者,所以切断整个令牌族。要让它生效,用过的 refresh 就不能删除,而要留作“已使用”,并且这个标记必须是原子的。本实验让你亲手写出这些规则并亲自证明。

步骤

  1. 在 127.0.0.1:8306 上启动 /root/st/refresh/app.py。GET /health 返回 200 和 {"ok":true}。存储使用 Redis 中的三种哈希:fam:<id>(user、state、current)、rt:<토큰>(user、fam、uses)和 at:<토큰>(user、fam)(占位符为令牌)。
  2. 把 POST /login(form user=alice&pw=wonderland)的响应正文保存到 /root/st/refresh/login.json。用该 access 调用 GET /api/me 返回 200,没有令牌、令牌是编造的、密码错误的登录都返回 401。
  3. 用新登录获得的 refresh 发送 POST /token,form 为 grant_type=refresh_token&refresh_token=<값>(占位符为值),把响应保存到 /root/st/refresh/refresh.json。
  4. 按“登录 → refresh(RT1)→ 用新的 RT2 再 refresh → 再次提交旧 RT1”的顺序测试,在 /root/st/refresh/rotate.txt 中写入 first_status= new_differs= new_status= old_status=。
  5. 用 bob/builder 登录并轮换一次后,再次提交旧 RT1,并尝试使用最新的 RT2 和 AT2,在 /root/st/refresh/reuse.txt 中写入 replay_status= active_after= access_after= family_state=。
  6. 把登录响应的 expires_in(1–300 秒)和 Redis 中 access、refresh 的 TTL,以 expires_in= access_ttl= refresh_ttl= 写入 /root/st/refresh/ttl.txt。已过期的 access 必须返回 401。
  7. 用 POST /revoke(form token=<refresh>)退出登录后,尝试使用同一个 refresh 和 access,在 /root/st/refresh/revoke.txt 中写入 revoke_status= refresh_after= access_after= family_state=。
  8. 用 /root/st/refresh/e2e.sh 测试轮换、复用检测和令牌族撤销,在 /root/st/refresh/e2e.out 中留下 rotate=ok、reuse_detected=yes、family_revoked=yes。

参考

启动令牌签发方

在 127.0.0.1:8306 上启动 /root/st/refresh/app.py。GET /health 返回 200 和 {"ok":true}。存储使用 Redis(127.0.0.1:6379)中的三种哈希:fam:<id>(user、state、current)、rt:<토큰>(user、fam、uses)和 at:<토큰>(user、fam)(占位符为令牌)。

Redis 已经在 6379 端口运行。把服务器放到后台运行,并轮询到 /health 有响应为止。没有 python-multipart,所以 form 正文要用 urllib.parse.parse_qs 自行解析。

通过登录获得一对令牌

把 POST /login(form user=alice&pw=wonderland)的响应正文原样保存到 /root/st/refresh/login.json。正文中必须有 access_token、refresh_token、token_type(Bearer)、expires_in,用该 access 调用 GET /api/me 返回 200 和 user=alice,没有令牌或令牌是编造的返回 401,密码错误的登录返回 401。

给 curl 明确指定 -X POST,并用 --data-urlencode 发送 form。受保护的 API 用 Authorization: Bearer 请求头调用。RFC 9700 禁止密码授权模式,所以第一个令牌由 /login 而不是 /token 给出。

用 refresh 获得新的 access

用新登录获得的 refresh 发送 POST /token,form 为 grant_type=refresh_token&refresh_token=<값>(占位符为值),把 200 响应正文保存到 /root/st/refresh/refresh.json。新的 access_token 必须与登录时不同,并且在 GET /api/me 中返回 200。

这是 RFC 6749 第 6 节的 refresh 授权。正文是 form-urlencoded。如果再次使用 login.json 里的 refresh,它可能已经是用过的令牌,所以请重新登录来获得。

轮换:新的 refresh 可用,旧的被拒绝

按“登录 → refresh(RT1)→ 用响应中的新 refresh(RT2)再次 refresh → 再提交一次旧 RT1”的顺序测试,在 /root/st/refresh/rotate.txt 中写入 first_status=200、new_differs=yes、new_status=200、old_status=<400 또는 401>(占位符为 400 或 401)。

每次使用 refresh 都给出新的 refresh,用过的 refresh 不要删除,只提高 uses 作为标记。如果返回相同的值,或者继续接受旧的,就不是轮换。

复用检测:撤销整个令牌族

用 bob/builder 登录,用 RT1 轮换一次(得到 RT2、AT2)后,再次提交旧 RT1,接着测试用 RT2 做 refresh、用 AT2 调用 GET /api/me,在 /root/st/refresh/reuse.txt 中写入 replay_status=<400|401>、active_after=<400|401>、access_after=401、family_state=revoked(family_state 取自 redis-cli HGET fam:<계열> state,占位符为令牌族 ID)。

服务器无法知道是谁提交了旧 RT1。所以要把该令牌族当前的 refresh 和 access 也全部杀掉。别让 /api/me 只看 access 键,也要看令牌族状态。

access 短命,refresh 长寿

把登录响应的 expires_in,以及 Redis 的 TTL at:<access>、TTL rt:<refresh>,以 expires_in= access_ttl= refresh_ttl= 写入 /root/st/refresh/ttl.txt。expires_in 为 1–300 秒,access_ttl 为正数且不大于 expires_in,refresh_ttl 必须大于 access_ttl。在 Redis 中已过期(键消失)的 access,在 GET /api/me 中必须返回 401。

access 寿命用 Redis 键的 TTL 来表示,过期就会自动发生。refresh 的 TTL 起到不活动过期(RFC 9700 第 4.14.2 节)的作用。

退出登录会撤销令牌族

登录后用 POST /revoke(form token=<refresh>)退出登录,再次使用同一个 refresh 和 access,在 /root/st/refresh/revoke.txt 中写入 revoke_status=200、refresh_after=<400|401>、access_after=401、family_state=revoked。用未知令牌调用 /revoke 也必须返回 200。

这是 RFC 7009 的样子。建议在撤销 refresh 时,把出自同一次授权的 access 也一并作废,对未知令牌也返回 200。只删除 refresh 的键,access 就会继续有效。

一次性证明轮换、复用和撤销

让 /root/st/refresh/e2e.sh 依次测试登录 → 轮换 → 再次提交旧 refresh → 复用最新的 refresh 和 access,输出 rotate=ok、reuse_detected=yes、family_revoked=yes 三行,并把该输出保存到 /root/st/refresh/e2e.out。评分器还会重新运行 e2e.sh,查看是否得到同样的结果。

把前面各步用一个脚本串起来。每次都重新登录,运行多少次结果才会一样。family_revoked 只有在最新 refresh 被拒绝且 access 返回 401 两者都成立时才是 yes。