Refresh token 旋转后「旧令牌重放」检测:双活跃窗口该留多长、用什么数据结构判重放、误杀活跃会话的成本怎么控?
该问题的正文与回答。
背景
给一套 B 端 SaaS 做安全加固(Spring Boot 3.2 + jjwt 0.12.6,RS256 非对称,Redis 7 集群作会话/黑名单存储,K8s 1.28 自建 5 节点),涉及多端登录(Web + 移动端 + 桌面客户端,同一用户最多同时 3 个活跃会话)。
安全审计要求:refresh token(RT)必须做 rotation(每次刷新后旧 RT 立即失效),且要求"检测到重放时能区分是客户端并发 bug 还是攻击者偷了 token"——不能一刀切把该用户所有活跃会话全部踢掉(审计认为误杀影响面大于攻击面)。
现象 / 卡点
我们目前的做法是:
- 客户端拿旧 RT 调
/auth/refresh,服务端签发新 RT + 新 access token; - 旧 RT 的 jti 写入 Redis 黑名单(TTL = 该 RT 剩余有效期);
- 如果后续同一 jti 再次出现 → 判定重放 → 吊销该用户全部 RT(全踢)。
实际跑起来后踩了三个坑:
坑 1:多端并发刷新。 用户桌面端 + 移动端同时打开,两个客户端各自持有不同的 RT(因为之前每次 rotation 后各自拿到不同的新 RT),但如果其中一端因为网络抖动在 refresh 请求发出后超时重试,第二次请求带的是已经被第一次成功请求换掉的那个旧 RT——我们的逻辑会判定重放,把用户三个端全踢了。客服工单一周 12 条。
坑 2:双活跃窗口怎么留。 如果允许"旧 RT 和最新 RT 同时有效 N 分钟"(给并发重试留余量),那 N 的取值就是攻击窗口:攻击者在这个窗口内重放旧 RT 不会被立刻拦截。N=0 就是现在的误杀问题;N=5min 攻击窗口 5 分钟。有没有中间方案?
坑 3:重放判定的数据结构。 目前黑名单是 jti → 1 的 KV,无法区分"这个 jti 是正常 rotation 产生的"还是"这个 jti 被重放"。要区分,似乎需要记录 RT 的链式关系(哪个 jti 被哪个 jti 替代了),但 RT 链可能很长(7 天 TTL 内 rotation 可能几十次),Redis 里存全链太重。
已尝试
- 单飞(single-flight):同一 jti 的并发 refresh 请求合并为一个。解决了坑 1 的同端并发,但解决不了跨端误判(因为两端的 jti 本来就不一样,不是同一请求合并的问题)。
- 把黑名单 TTL 从"RT 剩余有效期"缩短到 5 分钟:降低了误杀窗口,但攻击者也有 5 分钟。
- 查了站内相关讨论(见引用),主要是吊销延迟预算和并发刷新治理,没有专门讨论"rotation 重放检测的链式判定"。
期望得到的答案形态
- 双活跃窗口的工程取值依据:不是拍脑袋给一个 N,而是给出"根据客户端最大并发数、网络 RTT P99、refresh 接口幂等性"推导出 N 的方法论,以及 N 对攻击面的量化影响;
- 重放判定的数据结构:如何在 Redis 里用轻量结构(不是全链)区分"正常 rotation"和"异常重放"?比如只保留最近 K 代 RT 的 jti + 签发时间戳 + 父 jti,K 取多少?
- 误杀 vs 漏杀的取舍框架:审计要求"不能全踢",那重放确认后的最小影响面操作是什么(只踢该端?只冻结新 RT 签发?告警 + 人工介入?)?
欢迎带具体代码 / 配置 / 数据结构来答。