刷新令牌轮换与重用检测
目标
搭建一个把令牌族存放在 Redis 里的小型签发方,并用真实的 HTTP 请求确认:每次使用 refresh 都会轮换、已用过的 refresh 再次出现时整个令牌族会被撤销、access 寿命很短、退出登录会切断令牌族。
为什么重要
让 access 短命,即使泄露受损窗口也很短,但代价是长寿的 refresh 成了新的目标。对于无法隐藏密钥的浏览器和移动应用,RFC 9700 要求把 refresh 绑定到客户端密钥,或使用轮换。轮换的力量来自复用检测——令牌泄露后,攻击者和正常客户端都使用它,两者中必有一方会拿出已用过的令牌,而服务器不知道谁是攻击者,所以切断整个令牌族。要让它生效,用过的 refresh 就不能删除,而要留作“已使用”,并且这个标记必须是原子的。本实验让你亲手写出这些规则并亲自证明。
步骤
- 在 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)(占位符为令牌)。 - 把
POST /login(formuser=alice&pw=wonderland)的响应正文保存到/root/st/refresh/login.json。用该 access 调用GET /api/me返回 200,没有令牌、令牌是编造的、密码错误的登录都返回 401。 - 用新登录获得的 refresh 发送
POST /token,form 为grant_type=refresh_token&refresh_token=<값>(占位符为值),把响应保存到/root/st/refresh/refresh.json。 - 按“登录 → refresh(RT1)→ 用新的 RT2 再 refresh → 再次提交旧 RT1”的顺序测试,在
/root/st/refresh/rotate.txt中写入first_status= new_differs= new_status= old_status=。 - 用
bob/builder登录并轮换一次后,再次提交旧 RT1,并尝试使用最新的 RT2 和 AT2,在/root/st/refresh/reuse.txt中写入replay_status= active_after= access_after= family_state=。 - 把登录响应的
expires_in(1–300 秒)和 Redis 中 access、refresh 的 TTL,以expires_in= access_ttl= refresh_ttl=写入/root/st/refresh/ttl.txt。已过期的 access 必须返回 401。 - 用
POST /revoke(formtoken=<refresh>)退出登录后,尝试使用同一个 refresh 和 access,在/root/st/refresh/revoke.txt中写入revoke_status= refresh_after= access_after= family_state=。 - 用
/root/st/refresh/e2e.sh测试轮换、复用检测和令牌族撤销,在/root/st/refresh/e2e.out中留下rotate=ok、reuse_detected=yes、family_revoked=yes。
参考
- POST 要用
curl -X POST --data-urlencode ...明确指定。令牌值用python3 -c从 JSON 中取出。 - 对无效、过期、已撤销的 refresh,令牌端点返回 400
invalid_grant(RFC 6749 5.2),受保护的 API 返回 401invalid_token(RFC 6750 3.1)。 - “已使用”标记用
HINCRBY rt:<토큰> uses 1这一条命令完成(占位符为令牌)。如果读取之后再另行写入,同时到来的两个请求都会通过。 - 常见错误 1:用过的 refresh 立刻删除——再次进来时无法与第一次见到的值区分,无法检测复用。
- 常见错误 2:只拒绝被复用的令牌而让令牌族继续存活——如果攻击者先轮换了,攻击者手里的最新令牌仍然有效。
启动令牌签发方
在 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。