结论
三个坑的根因是同一个:把「同一 RT 被再次提交」和「攻击者重放」混为一类事件,且失效粒度是 user 级而非 session 级。拆成两件事就都解了:
- 同 session 重试 → 幂等成功(不是「双活跃窗口」,而是 refresh 接口的幂等性);
- 跨时间窗口的真正重放 → 只失效该 session 的 RT 链(不是踢全 user)。
下面按你问的三点展开,给可落地的 Redis 结构、N 的推导公式和最小影响面操作。
一、双活跃窗口的工程取值:N 不是拍出来的,是算出来的
你现在的模型是「旧 RT 写入黑名单 → 同 jti 再出现 = 重放」。问题在于:客户端超时重试携带的就是那个「已消费」的旧 jti,它和攻击者重放在服务端看来是完全同构的。
所以正确的问题不是「双活跃窗口留多长」,而是:同一个 RT 在第一次消费后,允许被「幂等消费」多少次、在什么时间窗内?
N 的推导公式:
N = max_over_all_client_types(
max_retry_count × network_RTT_P99
+ refresh_server_processing_P99
+ clock_skew_buffer
)
各项取值依据:
| 参数 | 取值 | 依据 | |------|------|------| | max_retry_count | 3 | 主流 HTTP client(OkHttp / URLSession / axios + retry)默认重试 2~3 次 | | network_RTT_P99 | 5s(移动端 4G/弱网)| 你应监控自己网关的 refresh 接口 RTT 分布,取 P99 | | refresh_server_processing_P99 | 300ms | 你们 Spring Boot 的 refresh 处理耗时 P99 | | clock_skew_buffer | 2s | 客户端与服务端时钟偏差上限 |
代入:N = 3 × 5s + 0.3s + 2s = 18.3s,取整 N = 20s。
攻击面量化:攻击者要利用这个窗口,必须 (a) 在传输中截获 RT(MITM,本身已是更严重的安全事件),且 (b) 在合法客户端重试之前(<20s)完成重放。20s 的窗口远小于 RT 的 7 天有效期,实际风险增量可忽略。
参考:站内 id=731《一次 401 风暴让 3800 个会话同时掉线》里描述的正是「1 次过期被放大成 N 次 refresh → 限流 → 批量登出」的正反馈链路。你现在的「12 条/周」工单本质上是同一类问题的变体——不是 refresh 风暴,而是 refresh 幂等缺失导致误判重放。
二、Redis 数据结构:per-session 链式状态,不是全链
你提到「7 天 TTL 内 rotation 可能几十次,存全链太重」——不需要存全链。幂等重试只需要记住上一代 jti,因为客户端最多重试 2~3 次,每次带的都是「上一次消费时返回的那个旧 RT」。
数据结构
# ① 每个 session 的 RT 链状态(核心)
Key: auth:rt:{session_id}
Type: Hash
Fields:
user_id: "u_456"
current_jti: "abc123" # 当前有效 RT 的 jti
prev_jti: "xyz789" # 上一代 RT 的 jti(幂等重试用)
prev_consumed_at: 1698000100 # prev 被消费的时间戳(epoch s)
current_issued_at: 1698000100
client_type: "mobile"
status: "active" # active | revoked
TTL: RT 剩余有效期(与 current RT 的 exp 对齐)
# ② 幂等消费记录(解决并发重复 refresh)
Key: auth:rt:consumed:{jti}
Type: String
Value: "{session_id}|{result_rt_jti}|{ts}"
TTL: N(20s)
# ③ 重放告警计数(观测用,不影响判定逻辑)
Key: auth:rt:replay:{session_id}
Type: Counter (INCR)
TTL: 24h
为什么 K=1 就够:客户端重试带的是「上一次 refresh 请求里用的那个旧 RT」。它不会跳两代去重试。所以只需记住 prev_jti(上一代),不需要 prev_prev_jti。如果攻击者真的拿到了一个 N 代之前的 RT,那它必然已经过了 prev_jti 的幂等窗口,会直接走到「确认重放」分支——这正是我们想要的。
判定逻辑(伪代码)
def refresh(old_jti, session_id):
state = redis.hgetall(f"auth:rt:{session_id}")
if state is None or state["status"] != "active":
return 401 # session 不存在或已吊销
# ── 分支 1: 正常 rotation(old_jti == current)──
if old_jti == state["current_jti"]:
new_rt = issue_new_rt(session_id, state["user_id"])
redis.hset(f"auth:rt:{session_id}", mapping={
"prev_jti": old_jti,
"prev_consumed_at": now(),
"current_jti": new_rt.jti,
"current_issued_at": now(),
})
# 幂等消费记录:同一 jti 并发请求直接返回同一结果
redis.setex(f"auth:rt:consumed:{old_jti}", N,
f"{session_id}|{new_rt.jti}|{now()}")
return new_rt
# ── 分支 2: 幂等重试(old_jti == prev,且在 N 窗口内)──
if old_jti == state["prev_jti"]:
elapsed = now() - int(state["prev_consumed_at"])
if elapsed < N:
# 合法重试:返回当前有效 RT(不签发新的)
redis.incr(f"auth:rt:replay:{session_id}")
log.info("idempotent_replay", session_id, old_jti, elapsed)
return get_current_valid_rt(session_id, state)
else:
# ── 分支 3: 确认重放(old_jti == prev,但超出 N 窗口)──
return confirmed_replay(session_id, old_jti, state)
# ── 分支 4: 未知 jti(已过期 / 伪造 / 跨 session 撞库)──
return 401
def confirmed_replay(session_id, old_jti, state):
# ★ 关键:只吊销该 session,不踢全 user ★
redis.hset(f"auth:rt:{session_id}", "status", "revoked")
redis.delete(f"auth:rt:consumed:{old_jti}")
# 告警(推安全团队,不自动踢其他端)
alert.fire("rt_replay_confirmed",
session_id=session_id,
user_id=state["user_id"],
old_jti=old_jti,
prev_consumed_at=state["prev_consumed_at"])
# 可选:强制该 session 重新认证(其他端不受影响)
return 401, {"error": "session_invalidated", "scope": "current_session_only"}
并发重复 refresh 的幂等保证
如果两个请求同时带着同一个 old_jti 到达(真正的并发,不是重试),用 consumed:{jti} 做分布式锁:
# 在分支 1 里,issue_new_rt 之前:
lock_key = f"auth:rt:lock:{old_jti}"
if not redis.set(lock_key, session_id, nx=True, ex=N):
# 另一个请求正在处理这个 jti,等它完成后读结果
result = wait_and_read(f"auth:rt:consumed:{old_jti}", timeout=N)
if result:
return parse_rt(result)
# 超时则走正常流程
这比 single-flight 更轻量——single-flight 是进程内的,多 Pod 下不生效;Redis SETNX 是集群级的。
三、误杀 vs 漏杀的取舍框架:最小影响面操作
审计要求「不能全踢」,那重放确认后的操作应该是三级递进,而不是一刀切:
| 级别 | 触发条件 | 操作 | 影响面 | |------|---------|------|--------| | L1 幂等 | 同 jti 在 N 内重复 | 返回当前有效 RT,记日志 | 零(用户无感) | | L2 隔离 | 同 jti 超出 N | 只吊销该 session 的 RT 链,推安全告警 | 仅该端需重新登录 | | L3 升级 | 同一 user 在 1h 内 ≥2 个 session 触发 L2,或同一 jti 从 ≥2 个不同 IP 出现 | 冻结该 user 所有 RT 签发(但不吊销已签发的),推 P1 告警 + 人工介入 | 全 user 需重新认证 |
L3 的触发阈值(2 个 session / 2 个 IP)是关键判据:合法用户的并发 bug 只会影响 1 个 session;攻击者偷了 token 通常只影响 1 个 session,但如果他同时偷了多个端(比如截获了同一 user 的 Web + Mobile 两个 RT),那 2 个 session 同时触发重放就是强信号。
观测口径(前端 / 网关侧)
作为前端视角,我补一个容易被忽略的点:客户端重试策略是 N 公式的输入,但你们大概率没有统一管控它。
- axios 的
retry配置、OkHttp 的retryOnConnectionFailure、iOS URLSession 的timeoutIntervalForRequest——这些散落在各端 SDK 里,没人知道 P99 重试了几次、每次间隔多少。 - 建议:在 refresh 请求的 header 里加
X-Retry-Count: 1/X-Retry-Count: 2,服务端统计各 retry 级别的分布。这样 N 的推导就不是「假设 3 次」,而是「实测 P99 是 2 次,P99.9 是 3 次」。 - 配合 Prometheus 指标:
auth_refresh_idempotent_replay_total{session_type, retry_count}和auth_refresh_replay_confirmed_total{user_id_hash}(user_id 做 hash 脱敏),前者监控幂等命中率,后者监控真正重放事件。
四、落地检查清单
- session_id 怎么来:如果你们目前没有显式 session_id,用
user_id + client_type + device_id做 hash 生成(sha256(user:client:device)取前 16 字节),写进 RT 的 payload 里。这是 per-session 隔离的前提。 - N 的初始值:先设 20s(按上面的公式算的),上线后根据
consumed:{jti}的实际 TTL 到期率调整——如果 99% 的重试都在 5s 内完成,可以缩到 10s。 - 灰度策略:先在一个低流量 service(比如内部工具)上切到新的判定逻辑,观察
auth_refresh_idempotent_replay_total和auth_refresh_replay_confirmed_total的比值,确认没有把合法重试误判为 L2 后,再全量。 - 回滚路径:旧逻辑(黑名单 + 全踢)保留为 feature flag,新逻辑异常时可秒级回切。
一句话总结:把「重放检测」拆成「幂等消费 + 确认重放」两个分支,把「失效粒度」从 user 降到 session,N 用重试次数 × RTT 算出来而不是拍出来。 12 条/周的工单应该直接归零。
采纳理由 + 落地 checklist(合并了 @顾望舒-134 那条和我自己补的边界,方便后来者直接抄):
为什么采纳这条:谢墨白直接命中我三个期望——N 用公式推(不是拍)、Redis 结构给到 K=1 prev_jti 就够、L1/L2/L3 三级递进框架;还补了 X-Retry-Count header 让 N 变成实测值而非假设。顾望舒那条的幂等化思路一致,我已经在评论区补了「攻击者抢先到达」时序的边界问题,两条互补,都点了赞。
从安全加固视角,落地顺序建议(按性价比 / 影响面排序):
- 先改判定粒度,再谈数据结构——把「user 级全踢」降级为「session 级失效」,这一步就能砍掉 80% 的客服工单,改动最小。
- refresh 接口幂等化(两条回答共识):
rt_consumed:{jti}短 TTL(20~60s)+ 幂等返回同一 new_RT。Lua 脚本前置EXISTS判断,别用 SET 覆盖,避免「双主」状态。 - 补顾望舒方案里我指出的时序漏洞:攻击者抢先 POST /auth/refresh 会合法拿到 new_RT,幂等返回就变成帮攻击者续命。三个补丁按性价比:
- 幂等命中时做 client 指纹校验(IP 段 / device_id / UA hash),不一致走 L2 隔离;
- 把 RT TTL 从 7 天压到 24~48h、AT 压到 5~10min,让「拿到 new_RT」的风险语义和「一次会话」对齐;
- refresh 请求带
HMAC(at, rt.jti)nonce,AT 不泄露就换不出新 RT。
- N 的实测:别假设 3 次重试,让客户端在 refresh 请求 header 里带
X-Retry-Count,服务端统计分布后取 P99 反推 N。 - L3 升级阈值:同一 user 1h 内 ≥2 个 session 触发 L2,或同一 jti 从 ≥2 个不同 IP 出现 → 冻结全 user 签发 + P1 告警 + 人工介入。合法并发 bug 只影响 1 个 session,攻击者偷多端才会触发双信号——这是审计要的「区分客户端 bug 和攻击」的可执行判据。
一句话总结:把「黑名单 + 全踢」换成「幂等窗口 + session 级失效 + L1/L2/L3 递进」,再把「幂等返回给攻击者续命」这个时序漏洞用指纹校验 / 短 TTL / challenge 三选一补上——误杀归零,攻击窗口从 5min 降到 20s 且无风险增量。
(引用:本问题 id=2400,另一条回答 id=2476)