双Token续费机制详解
为什么需要双Token?
单Token的问题: 用户登录 → 获得Token (24小时有效期) ↓ 用户正在操作(如提交任务) ↓ Token突然过期 → 401错误 ↓ 用户被迫重新登录 → 体验差
双Token解决方案: Access Token (短):像临时身份证,进出需要验证(15-30分钟) Refresh Token (长):像长期签证,可以在过期前换新证(7-30天)
用户无感知:前端自动用refreshToken换新accessToken
双Token核心概念
┌───────────┬───────────────────────┬────────────────────────────────┐│ Token类型 │ Access Token │ Refresh Token │├───────────┼───────────────────────┼────────────────────────────────┤│ 用途 │ 访问API时身份证明 │ 换取新的Access Token │├───────────┼───────────────────────┼────────────────────────────────┤│ 有效期 │ 15-30分钟(短) │ 7-30天(长) │├───────────┼───────────────────────┼────────────────────────────────┤│ 存储 │ 内存 / sessionStorage │ localStorage(更持久) │├───────────┼───────────────────────┼────────────────────────────────┤│ 传输 │ 每次请求带 │ 仅刷新时使用 │├───────────┼───────────────────────┼────────────────────────────────┤│ 失效方式 │ 时间过期 │ 时间过期 / 被禁用 / 换新后作废 │└───────────┴───────────────────────┴────────────────────────────────┘完整流程图
┌─────────────────────────────────────────────────────────────────────┐ │ 登录流程 │ └─────────────────────────────────────────────────────────────────────┘
前端 服务器 │ │ │────── POST /auth/login ─────────▶│ │ username, password │ │ │ 验证密码 │ │ 生成 access_token (15min) │ │ 生成 refresh_token (7天) │ │ 存储 refresh_token 哈希到DB │◀───── { access_token, │ │ refresh_token } ─────────│ │ │ │ 存入 localStorage │ │ (refresh_token 持久化) │ │ 存入内存 (access_token) │
┌─────────────────────────────────────────────────────────────────────┐ │ 正常API请求流程 │ └─────────────────────────────────────────────────────────────────────┘
前端 服务器 │ │ │── GET /api/tasks (带 access) ───▶│ │ │ 验证 access_token ✓ │◀─── 200 OK (任务列表) ─────────│ │ │
┌─────────────────────────────────────────────────────────────────────┐ │ Access Token过期时自动刷新 │ └─────────────────────────────────────────────────────────────────────┘
前端 服务器 │ │ │── GET /api/tasks (带 access) ───▶│ │ │ access_token 已过期 → 401 │◀─── 401 Unauthorized ──────────│ │ │ │ 【前端拦截401,自动触发刷新】 │ │ │ │── POST /auth/refresh ───────────▶│ │ { refresh_token } │ 验证 refresh_token 哈希 │ │ 检查是否已使用/被禁用 │ │ 生成新的 access_token │ │ 生成新的 refresh_token (可选) │ │ 更新DB中的 refresh_token 哈希 │◀─── { new_access_token, │ (refresh_token rotation) │ new_refresh_token } ──────│ │ │ │ 存入 localStorage │ │ │ │ 【自动重试刚才失败的请求】 │ │── GET /api/tasks (带 new access)▶│ │◀─── 200 OK ────────────────────│
┌─────────────────────────────────────────────────────────────────────┐ │ Refresh Token Rotation │ └─────────────────────────────────────────────────────────────────────┘
每次用refreshToken换新token时: 旧 refresh_token (abc123) ──▶ 作废 + 记录到黑名单 新 refresh_token (xyz789) ──▶ 存入DB,取代旧的好处:即使旧token被盗用,刷新后自动失效,攻击窗口极短
关键安全问题:Refresh Token Rotation
-
单Token: ❌ 不安全:refresh_token 可以无限次使用 攻击者窃取 refresh_token → 可以一直换新access_token
-
双Token ✅ 安全:每次刷新后换新 refresh_token(Rotation) 攻击者窃取 refresh_token → 用户下次刷新 → 攻击者的token被作废
实现:数据库存储refresh_token的哈希值
登录时
String rawRefreshToken = UUID.randomUUID().toString();String refreshTokenHash = hash(rawRefreshToken);user.setRefreshTokenHash(refreshTokenHash);user.setRefreshTokenExpire(at: now + 7 days);db.save(user);刷新时
1. 验证 rawRefreshToken 的哈希是否与DB中匹配2. 验证是否已过期3. 验证是否已在黑名单(上次刷新时已替换)4. 通过 → 生成新token对,旧token哈希加入黑名单前端实现逻辑(伪代码)
// axios 拦截器axios.interceptors.response.use( response => response, async error => { if (error.response?.status === 401) { // 1. 尝试刷新token const newTokens = await refreshToken();
// 2. 更新本地存储 localStorage.setItem('access_token', newTokens.access_token); localStorage.setItem('refresh_token', newTokens.refresh_token);
// 3. 重试刚才失败的请求(用新token) error.config.headers.Authorization = `Bearer ${newTokens.access_token}`; return axios(error.config); } return Promise.reject(error); });
async function refreshToken() { const refresh = localStorage.getItem('refresh_token'); const res = await fetch('/auth/refresh', { method: 'POST', body: JSON.stringify({ refresh_token: refresh }) }); return res.json();}与单Token对比
┌───────────────┬─────────────────────┬─────────────────────────────┐│ 特性 │ 单Token │ 双Token │├───────────────┼─────────────────────┼─────────────────────────────┤│ 实现复杂度 │ 简单 │ 中等 │├───────────────┼─────────────────────┼─────────────────────────────┤│ 用户体验 │ 差(需频繁登录) │ 好(无感知刷新) │├───────────────┼─────────────────────┼─────────────────────────────┤│ 安全性 │ 一般 │ 高(refresh rotation) │├───────────────┼─────────────────────┼─────────────────────────────┤│ Token窃取风险 │ 高(token长期有效) │ 低(短期access + rotation) │├───────────────┼─────────────────────┼─────────────────────────────┤│ 服务端存储 │ 无需 │ 需要存储refresh_token哈希 │└───────────────┴─────────────────────┴─────────────────────────────┘RefreshToken 安全存储措施
- 哈希存储(服务端)
永远不在数据库存明文refresh_token
// ❌ 错误:存明文user.setRefreshToken(token); // token=abc123xyz
// ✅ 正确:存哈希user.setRefreshTokenHash(hashSHA256(token)); ┌────────────┬─────────────────────────┬──────────────────────────┐ │ 攻击场景 │ 明文存储 │ 哈希存储 │ ├────────────┼─────────────────────────┼──────────────────────────┤ │ 数据库泄露 │ 攻击者直接拿到可用token │ 攻击者拿到哈希,无法逆向 │ ├────────────┼─────────────────────────┼──────────────────────────┤ │ 彩虹表攻击 │ - │ 加盐防彩虹表 │ └────────────┴─────────────────────────┴──────────────────────────┘- 加盐哈希
// 每次登录生成唯一盐值String salt = UUID.randomUUID().toString();String tokenHash = sha256(token + salt);
// 存储user.setRefreshTokenSalt(salt);user.setRefreshTokenHash(tokenHash);- 单次使用(Rotation机制)
数据库: 存储当前 refresh_token 的哈希
刷新时: 1. 验证传入的 token 哈希 == 数据库存储的哈希 2. 验证通过 → 生成新的 token 对 3. 新 refresh_token 哈希覆盖数据库旧值 4. 旧 token 哈希加入”黑名单”(可选)
效果:token只能用一次,窃取者刷新后原token立即失效
过期时间控制
-- 表结构示例CREATE TABLE refresh_tokens ( id BIGINT PRIMARY KEY, user_id BIGINT, token_hash VARCHAR(64), -- 哈希值 salt VARCHAR(36), -- 盐 created_at TIMESTAMP, expires_at TIMESTAMP, -- 7天后过期 revoked BOOLEAN DEFAULT FALSE, replaced_by_token_id BIGINT -- 指向替换它的新token);
-- 查询时过滤WHERE revoked = FALSE AND expires_at > NOW()Token传输安全
┌──────────────────────┬─────────────────────────────┐ │ 措施 │ 说明 │ ├──────────────────────┼─────────────────────────────┤ │ HTTPS Only │ 禁止明文传输 │ ├──────────────────────┼─────────────────────────────┤ │ HttpOnly Cookie │ 前端JS无法读取refresh_token │ ├──────────────────────┼─────────────────────────────┤ │ SameSite=Strict │ 防止CSRF │ ├──────────────────────┼─────────────────────────────┤ │ 禁止本地存储明文密码 │ 安全意识 │ └──────────────────────┴─────────────────────────────┘// 后端设置HttpOnly Cookieresponse.addCookie(new Cookie("refresh_token", token) .setHttpOnly(true) .setSecure(true) // HTTPS only .setPath("/auth/refresh") // 仅刷新接口 .setMaxAge(7 * 24 * 3600));攻击检测与响应
异常检测: ├── IP突变 → 旧token立即加入黑名单 ├── 频率异常 → 临时封禁 + 通知用户 └── 多个token同时使用 → 疑似被盗
响应措施: ├── 立即撤销所有token ├── 强制用户重新登录 └── 记录审计日志完整安全矩阵
┌──────┬───────────────────────┬────────────────┐ │ 层级 │ 措施 │ 目的 │ ├──────┼───────────────────────┼────────────────┤ │ 传输 │ HTTPS + Secure Cookie │ 防止网络层窃取 │ ├──────┼───────────────────────┼────────────────┤ │ 存储 │ 加盐哈希 + 单次使用 │ 防止数据库泄露 │ ├──────┼───────────────────────┼────────────────┤ │ 时间 │ 短期过期(7-30天) │ 缩短攻击窗口 │ ├──────┼───────────────────────┼────────────────┤ │ 检测 │ 异常IP/频率监控 │ 发现即响应 │ ├──────┼───────────────────────┼────────────────┤ │ 撤销 │ 立即 revocation 机制 │ 最小化损失 │ └──────┴───────────────────────┴────────────────┘问题场景:Race Condition
正常用户 窃取者 | | |── POST /refresh (token_A) ───▶│ 服务器: 验证通过 | | 生成新 token_B 替换 token_A | | token_A 已作废! |◀─── 新token_B ──────────────| | | | 用户下次刷新时: | |── POST /refresh (token_A) ───▶│ 验证失败! token已rotation |◀─── 401 Unauthorized ────────|
❌ 用户被踢了! 需要重新登录解决方案
方案1:宽松的并发控制(推荐)
// 刷新时检查:允许"最近一个"token 刷新// 而不是强制"只能当前这个"token 刷新
boolean isCurrentToken = hash(incoming) == hash(databaseStored);boolean isRecentlyRotated = isRotatedRecently(incoming, databaseStored); // 5分钟内
if (isCurrentToken || isRecentlyRotated) { // 允许刷新}效果:窃取者刷新后,用户还有5分钟宽限期去刷新
方案2:家族追踪(Family Rotation)
每个refresh_token属于一个"家族"(family_id)同一个家族内,只允许最新那个token有效
家族ID: uuid-family-001 ├── token_v1 (已作废, parent=null) ├── token_v2 (已作废, parent=token_v1) └── token_v3 (当前有效, parent=token_v2)
刷新 token_v3 时: 生成 token_v4,家族ID不变 token_v3 加入黑名单用户被踢的唯一情况:窃取者刷新后,用户超过宽限期没刷新
方案3:检测+通知(最佳实践)
// 每次刷新记录审计日志logRefreshAttempt(userId, ip, userAgent, success, tokenId);
// 异常检测if (isSuspiciousRefresh(userId, ip, userAgent)) { // 窃取者IP/设备与用户正常使用时不同 // 触发安全告警:发送邮件/短信通知用户 sendSecurityAlert(user, "检测到异常token刷新,请确认是否本人操作");
// 可选:暂时不解禁,等用户确认 // 或者:自动解绑所有token,强制重新登录}完整安全策略
┌──────┬───────────────────────┬──────────────────┐ │ 层级 │ 措施 │ 应对 │ ├──────┼───────────────────────┼──────────────────┤ │ 检测 │ 记录每次刷新的IP/设备 │ 发现异常 │ ├──────┼───────────────────────┼──────────────────┤ │ 通知 │ 实时告警(邮件/短信) │ 用户知情 │ ├──────┼───────────────────────┼──────────────────┤ │ 宽限 │ 5分钟rotation宽限期 │ 减少用户体验影响 │ ├──────┼───────────────────────┼──────────────────┤ │ 撤销 │ 一键解绑所有设备登录 │ 用户自助控制 │ ├──────┼───────────────────────┼──────────────────┤ │ 审计 │ 完整刷新历史 │ 事后追溯 │ └──────┴───────────────────────┴──────────────────┘用户体验 vs 安全的权衡
高安全模式: ├── 严格单次使用(无宽限期) ├── 异常立即封号 └── 用户频繁需要重新登录
平衡模式(推荐): ├── 5分钟宽限期 ├── 异常告警但不封号 └── 用户可自助解绑
低安全模式: ├── 只验证是否当前token └── 不做异常检测实际生产环境的最佳实践
-
家族追踪 (Family Rotation)
- 每个设备登录生成独立家族
- 设备A刷新 → 设备A的token rotation
- 设备B不受影响
-
设备指纹 refresh_token 绑定设备指纹 窃取者换个设备用token → 立即告警
-
用户控制台 显示所有活跃设备/登录记录 用户可一键”退出所有设备”
总结
┌──────────────┬────────────────────┐ │ 问题 │ 解决方案 │ ├──────────────┼────────────────────┤ │ 窃取者先刷新 │ 5分钟宽限期 + 告警 │ ├──────────────┼────────────────────┤ │ 用户被踢 │ 宽限期内刷新即可 │ ├──────────────┼────────────────────┤ │ 窃取者跨设备 │ 设备指纹检测 │ ├──────────────┼────────────────────┤ │ 不知道被盗 │ 实时告警通知用户 │ └──────────────┴────────────────────┘关键:不能100%防止攻击,但能做到早期检测 + 用户知情 + 快速响应
RefreshToken 黑名单机制详解
黑名单的核心作用
黑名单用途: 1. 记录已失效的 refresh_token 2. 防止”双花”(double-spend)攻击:同一token被刷新两次 3. 实现强制单次使用的安全保证
黑名单实现机制
- 基于 Redis 的高速方案(推荐)
// 添加到黑名单public void blacklistToken(String tokenId, long ttlSeconds) { redis.opsForValue().set( "blacklist:refresh:" + hash(tokenId), // key "1", // value Duration.ofSeconds(ttlSeconds) // 自动过期 );}
// 检查是否在黑名单public boolean isBlacklisted(String tokenId) { return Boolean.TRUE.equals( redis.hasKey("blacklist:refresh:" + hash(tokenId)) );}
// 刷新时:旧token加入黑名单public void refresh(String rawToken) { TokenData data = parseAndValidate(rawToken);
// 检查是否已黑名单(防止重复刷新) if (isBlacklisted(data.getJti())) { throw new TokenReusedException("Token已被使用,请重新登录"); }
// ... 生成新token ...
// 旧token加入黑名单,过期时间 = 剩余有效期 + 缓冲时间 long remainingTTL = data.getExp() - Instant.now().getEpochSecond(); blacklistToken(data.getJti(), remainingTTL + 300); // 多加5分钟缓冲}
Redis 内存结构: blacklist:refresh:sha256(token_abc123) → "1" (TTL: 15分钟) blacklist:refresh:sha256(token_xyz789) → "1" (TTL: 5分钟)- 基于数据库的持久化方案
CREATE TABLE refresh_token_blacklist ( id BIGINT PRIMARY KEY AUTO_INCREMENT, token_jti VARCHAR(64) NOT NULL, -- token唯一标识(JTI) token_hash VARCHAR(64) NOT NULL, -- token的SHA256哈希 revoked_at TIMESTAMP NOT NULL, -- 撤销时间 expires_at TIMESTAMP NOT NULL, -- token本身过期时间 reason VARCHAR(128), -- 撤销原因: ROTATED / LOGOUT / COMPROMISED created_by VARCHAR(64), -- 操作者: USER / SYSTEM / ADMIN INDEX idx_token_hash (token_hash), INDEX idx_expires_at (expires_at) -- 定期清理用);
// 添加到黑名单public void blacklist(TokenData data, String reason) { // 计算token真正过期的绝对时间 long expiresAt = data.getExp(); // 秒级时间戳
refreshTokenBlacklistMapper.insert( RefreshTokenBlacklist.builder() .tokenJti(data.getJti()) .tokenHash(hash(data.getRawToken())) .revokedAt(Instant.now()) .expiresAt(Instant.ofEpochSecond(expiresAt)) .reason(reason) .createdBy("USER") .build() );}
// 检查黑名单public boolean isBlacklisted(String tokenJti) { return refreshTokenBlacklistMapper .selectCount(new QueryWrapper<RefreshTokenBlacklist>() .eq("token_jti", tokenJti)) > 0;}- 混合方案(最佳实践)
高速场景用Redis: ├── token验证(每次刷新)→ Redis O(1) 查询 └── 高频场景的性能保障
持久化场景用数据库: ├── 审计日志(合规要求) ├── 用户查看"登录设备历史" └── 管理员手动撤销@Servicepublic class TokenBlacklistService {
@Autowired private RedisTemplate<String, String> redis; @Autowired private RefreshTokenBlacklistMapper dbMapper;
// 双写:Redis快速查询 + DB持久化审计 public void blacklist(TokenData data, String reason) { // 1. Redis黑名单(快速生效) String key = "blacklist:refresh:" + data.getJti(); long ttl = data.getExp() - Instant.now().getEpochSecond() + 300; redis.opsForValue().set(key, reason, Duration.ofSeconds(ttl));
// 2. DB持久化(审计用) dbMapper.insert(RefreshTokenBlacklist.builder() .tokenJti(data.getJti()) .tokenHash(hash(data.getRawToken())) .revokedAt(Instant.now()) .expiresAt(Instant.ofEpochSecond(data.getExp())) .reason(reason) .createdBy("USER") .build()); }
// 查询:优先Redis,Redis没有查DB public boolean isBlacklisted(String jti) { String key = "blacklist:refresh:" + jti; if (Boolean.TRUE.equals(redis.hasKey(key))) { return true; } // Redis没有,查DB(兜底) return dbMapper.selectCount(new QueryWrapper<RefreshTokenBlacklist>() .eq("token_jti", jti)) > 0; }}有效期管理
- 黑名单条目的生命周期
┌─────────────────────────────────────────────────────────────┐ │ Token黑名单有效期设计 │ └─────────────────────────────────────────────────────────────┘Token有效期: 7天 (604800秒)
黑名单条目有效期 = Token剩余有效期 + 安全缓冲
-
场景1: Token刚创建就被刷新 剩余有效期: 7天 黑名单TTL: 7天 + 5分钟缓冲 = 604800 + 300 ≈ 7天
-
场景2: Token即将过期时才刷新 剩余有效期: 5分钟 黑名单TTL: 5分钟 + 5分钟缓冲 = 10分钟
-
场景3: 用户主动登出 剩余有效期: 任意 黑名单TTL: 剩余有效期 + 30天清理窗口 原因: 用户可能忘记密码,需要保留登出凭证一段时间
- 清理策略
// 方式1: Redis TTL自动清理redis.opsForValue().set(key, "1", Duration.ofSeconds(ttl));
// 方式2: 数据库定时任务清理@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点public void cleanupExpiredBlacklistEntries() { // 删除已过期的黑名单记录 // (因为过期的token本身就无法使用了) int deleted = refreshTokenBlacklistMapper.delete( new QueryWrapper<RefreshTokenBlacklist>() .lt("expires_at", Instant.now()) .eq("reason", "ROTATED") // 只清理rotation产生的 ); log.info("Cleaned up {} expired blacklist entries", deleted);}- Token有效期配置矩阵
┌────────────────┬────────────┬─────────────┬──────────────┐ │ Token类型 │ 建议有效期 │ 黑名单TTL │ 适用场景 │ ├────────────────┼────────────┼─────────────┼──────────────┤ │ Access Token │ 15-30分钟 │ 不需要 │ API访问 │ ├────────────────┼────────────┼─────────────┼──────────────┤ │ Refresh Token │ 7天 │ 7天 + 5分钟 │ 长期会话 │ ├────────────────┼────────────┼─────────────┼──────────────┤ │ 记住我 Token │ 30天 │ 30天 + 1天 │ 持久登录 │ ├────────────────┼────────────┼─────────────┼──────────────┤ │ 关键操作 Token │ 5分钟 │ 即时 │ 敏感操作确认 │ └────────────────┴────────────┴─────────────┴──────────────┘存储结构设计
- Redis 结构
# Hash结构(推荐,用于家族追踪)HSET refresh_family:user:12345 token_v1 "{\"jti\":\"xxx\",\"revoked\":true,\"revoked_at\":1700000000}" token_v2 "{\"jti\":\"yyy\",\"revoked\":true,\"revoked_at\":1700003600}" token_v3 "{\"jti\":\"zzz\",\"revoked\":false}"
# String结构(简单场景)SET blacklist:refresh:jti_abc123 "1" EX 604800SET blacklist:refresh:jti_xyz789 "1" EX 300
# Set结构(记录当前有效token)SADD active_tokens:user:12345 jti_abc123 jti_xyz789SREM active_tokens:user:12345 jti_abc123 # 刷新时移除旧的- 数据库表结构
-- 核心黑名单表CREATE TABLE refresh_token_blacklist ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, token_jti VARCHAR(64) NOT NULL UNIQUE, -- JWT ID,唯一标识 token_hash VARCHAR(64) NOT NULL, -- 哈希存储 family_id VARCHAR(64), -- 家族ID(用于追踪) token_version INT DEFAULT 1, -- 版本号(家族内序号) revoked_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL, -- token本身过期时间 reason ENUM('ROTATED','LOGOUT','EXPIRED','COMPROMISED','ADMIN') NOT NULL, created_by VARCHAR(64) NOT NULL, -- USER / SYSTEM / ADMIN ip_address VARCHAR(45), user_agent VARCHAR(256), INDEX idx_user_id (user_id), INDEX idx_token_hash (token_hash), INDEX idx_family_id (family_id), INDEX idx_expires_at (expires_at), INDEX idx_revoked_at (revoked_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 活跃Token追踪表(可选,用于快速查询用户当前有效token)CREATE TABLE refresh_token_active ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, token_jti VARCHAR(64) NOT NULL UNIQUE, token_hash VARCHAR(64) NOT NULL, family_id VARCHAR(64) NOT NULL, version INT NOT NULL DEFAULT 1, issued_at TIMESTAMP NOT NULL, expires_at TIMESTAMP NOT NULL, last_used_at TIMESTAMP, device_info VARCHAR(256), ip_address VARCHAR(45), is_active BOOLEAN DEFAULT TRUE, INDEX idx_user_id (user_id), INDEX idx_family_id (family_id), INDEX idx_expires_at (expires_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 审计日志表(可选,合规用)CREATE TABLE refresh_token_audit_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, token_jti VARCHAR(64) NOT NULL, action ENUM('ISSUED','REFRESHED','REVOKED','EXPIRED','REJECTED') NOT NULL, ip_address VARCHAR(45), user_agent VARCHAR(256), success BOOLEAN NOT NULL, failure_reason VARCHAR(128), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id), INDEX idx_created_at (created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;- 家族追踪(Family)完整示例
// 家族结构/*家族: family_abc123├── token_v1 (jti=aaa, version=1, revoked=true, revoked_by=bbb)├── token_v2 (jti=bbb, version=2, revoked=true, revoked_by=ccc)└── token_v3 (jti=ccc, version=3, revoked=false) ← 当前有效
每次刷新: 旧token作废,新token创建(同一家族,版本+1)*/
@Servicepublic class FamilyRotationService {
public RefreshTokenResult refresh(String rawToken) { TokenData data = parseToken(rawToken); String familyId = data.getFamilyId();
// 1. 检查token是否属于黑名单 if (isTokenBlacklisted(data.getJti())) { // 异常:同一token被使用两次! handleReuseAttack(data); throw new TokenReusedException(); }
// 2. 获取当前家族有效token TokenData currentActive = getCurrentActiveToken(familyId);
// 3. 确保传入的是最新那个(家族内只能最新token刷新) if (!data.getJti().equals(currentActive.getJti())) { // 攻击者用了旧token revokeEntireFamily(familyId); // 整个家族作废 throw new TokenReusedException("使用了过期token"); }
// 4. 生成新token,版本+1 String newJti = UUID.randomUUID().toString(); int newVersion = currentActive.getVersion() + 1;
// 5. 作废旧token blacklistToken(currentActive, "ROTATED");
// 6. 保存新token saveNewToken(data.getUserId(), newJti, familyId, newVersion);
return new RefreshTokenResult(newJti, newVersion); }}总结
┌────────────┬────────────────────────────────┐ │ 维度 │ 推荐方案 │ ├────────────┼────────────────────────────────┤ │ 高速查询 │ Redis String/Hash,TTL过期 │ ├────────────┼────────────────────────────────┤ │ 持久审计 │ MySQL/PostgreSQL │ ├────────────┼────────────────────────────────┤ │ 防复用攻击 │ 黑名单 + JTI唯一约束 │ ├────────────┼────────────────────────────────┤ │ 家族追踪 │ family_id + version字段 │ ├────────────┼────────────────────────────────┤ │ 清理策略 │ Redis TTL自动清理 + DB定时任务 │ ├────────────┼────────────────────────────────┤ │ 有效期 │ Token剩余有效期 + 5-30分钟缓冲 │ └────────────┴────────────────────────────────┘最佳实践:
Redis(高速) + DB(审计) 混合,黑名单TTL设为Token剩余有效期+缓冲时间。
双Token并发刷新冲突问题
问题场景
用户点击”刷新”按钮多次触发 /auth/refresh 或 多个标签页同时打开,都检测到token过期
时间线:
T1: 请求A 发送 refresh_token (v1)T2: 请求B 发送 refresh_token (v1) ← 并发!T3: 服务器处理请求A → 通过 → 生成 v2 → v1加入黑名单T4: 服务器处理请求B → 通过 → 生成 v3 → v2加入黑名单问题:请求A和B都成功了,但生成了两个新token,浪费且混乱
核心冲突类型
┌──────────────────┬────────────────────────────────┬────────────────────────────────┐ │ 冲突类型 │ 描述 │ 后果 │ ├──────────────────┼────────────────────────────────┼────────────────────────────────┤ │ 重复刷新 │ 同一token短时间内多次请求 │ 生成多个新token,token版本混乱 │ ├──────────────────┼────────────────────────────────┼────────────────────────────────┤ │ 新旧版本交错 │ v1还在处理时,v2来了 │ v2可能基于过期的旧状态生成 │ ├──────────────────┼────────────────────────────────┼────────────────────────────────┤ │ 客户端状态不一致 │ 标签页A拿到v2,标签页B还持有v1 │ 后续请求用v1被拒 │ └──────────────────┴────────────────────────────────┴────────────────────────────────┘解决方案
方案1:分布式锁(Redis)
public RefreshTokenResult refresh(String rawToken) { TokenData data = parseAndValidate(rawToken); String lockKey = "lock:refresh:user:" + data.getUserId();
// 获取分布式锁,100ms超时,10秒自动释放 Boolean acquired = redis.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMillis(100));
if (!acquired) { // 另一个刷新正在进行,等待重试 return retryRefreshAfterDelay(rawToken, 200); }
try { // 检查当前token是否已是最新 String currentJti = getCurrentActiveJti(data.getUserId()); if (!data.getJti().equals(currentJti)) { // 已有更新的token被生成,说明本token已过期 throw new TokenOutdatedException(); }
// 执行刷新逻辑 return doRefresh(data);
} finally { redis.delete(lockKey); // 释放锁 } }锁粒度:按 userId 加锁 刷新请求A ──▶ 获取锁 user:123 ──▶ 处理中... 刷新请求B ──▶ 获取锁 user:123 ──▶ 等待/拒绝方案2:数据库乐观锁
// 在active_token表加 version 乐观锁 @Entity public class RefreshToken { @Id private String jti;
private Long userId; private Integer version; // 版本号,每次刷新+1
@Version // JPA乐观锁注解 private Long optimisticLock; }
// 刷新时 public RefreshTokenResult refresh(String rawToken) { TokenData data = parseToken(rawToken); RefreshToken current = findActiveByFamily(data.getFamilyId());
// 乐观锁检查版本 if (!current.getJti().equals(data.getJti())) { throw new TokenOutdatedException("已有新token生成"); }
// 更新时自动版本+1,冲突则抛异常 current.setVersion(current.getVersion() + 1); current.setJti(newJti); refreshTokenRepository.save(current); // OptimisticLockException }并发场景:
请求A: UPDATE token SET jti='v2', version=2 WHERE jti='v1' AND version=1 → 成功 请求B: UPDATE token SET jti='v3', version=2 WHERE jti='v1' AND version=1 → 0行affected → 失败方案3:幂等性保障(Token版本号 + 请求ID)
public RefreshTokenResult refresh(RefreshRequest request) { String tokenJti = parseToken(request.getRefreshToken()).getJti(); String requestId = request.getIdempotencyKey(); // 客户端生成唯一ID
// 1. 检查请求是否已处理(幂等表) if (idempotencyService.alreadyProcessed(requestId)) { return idempotencyService.getCachedResult(requestId); }
// 2. 检查token是否已是最新版本 TokenMeta meta = getTokenMeta(tokenJti); if (meta.isNotLatest()) { throw new TokenOutdatedException(); }
// 3. 处理刷新 RefreshTokenResult result = doRefresh(meta);
// 4. 缓存结果(防重复) idempotencyService.cacheResult(requestId, result, Duration.ofMinutes(5));
return result; }幂等Key: sha256(tokenJti + ":" + clientNonce)
请求A: key=hash(v1 + "abc") ──▶ 处理中...请求B: key=hash(v1 + "def") ──▶ 两个请求都成功(因为nonce不同)
正确做法:同一个token只能有一个刷新请求成功请求A: key=hash(v1) ──▶ 处理中...请求B: key=hash(v1) ──▶ 幂等检查通过,复用请求A的结果方案4:客户端防抖(前端协作)
// 刷新锁let refreshPromise = null;
async function safeRefreshToken() { // 如果已有刷新在进行中,返回那个Promise if (refreshPromise) { return refreshPromise; }
refreshPromise = doRefresh() .finally(() => { refreshPromise = null; // 完成后释放 });
return refreshPromise;}
// 所有401拦截器调用同一个Promiseasync function handle401() { return safeRefreshToken(); // 不会并发}
// 标签页间协调(BroadcastChannel)const channel = new BroadcastChannel('token_refresh');
let isRefreshing = false;let refreshQueue = [];
channel.onmessage = (event) => { if (event.data === 'REFRESHING') { // 另一个标签页正在刷新,等待结果 queueRefresh(); } else if (event.data === 'REFRESHED') { // 刷新完成,通知等待的标签页 processQueue(event.data.newToken); }};
async function refreshToken() { if (isRefreshing) { return waitForRefresh(); // 等待 }
isRefreshing = true; channel.postMessage('REFRESHING');
try { const result = await doRefresh(); channel.postMessage({ type: 'REFRESHED', token: result }); return result; } finally { isRefreshing = false; }}综合解决方案(生产环境推荐)
┌─────────────────────────────────────────────────────────────┐ │ 客户端层 │ │ • 刷新锁(单例Promise) │ │ • 标签页协调(BroadcastChannel) │ │ • 请求队列化 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 网关/接口层 │ │ • 幂等Key检查(tokenJti hash) │ │ • 请求去重(Sliding Window) │ │ • 限流(单用户刷新频率限制) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 服务端业务层 │ │ • 分布式锁(Redis SETNX) │ │ • Token版本校验(最新版本才能刷新) │ │ • 乐观锁(数据库version字段) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 数据持久层 │ │ • 唯一约束(token_jti UNIQUE) │ │ • 家族追踪(family_id + version) │ │ • 黑名单(已失效token快速查询) │ └─────────────────────────────────────────────────────────────┘完整代码示例
@Servicepublic class ConcurrentRefreshProtection {
private final RedisTemplate<String, String> redis; private final RefreshTokenRepository tokenRepo; private final IdempotencyService idempotencyService;
public RefreshTokenResult refresh(RefreshRequest request) { String rawToken = request.getRefreshToken(); TokenData data = tokenParser.parse(rawToken);
// ========== 第一层:幂等检查 ========== String idempotencyKey = "idem:refresh:" + data.getJti(); if (idempotencyService.isProcessed(idempotencyKey)) { return idempotencyService.getResult(idempotencyKey); }
// ========== 第二层:分布式锁 ========== String lockKey = "lock:refresh:user:" + data.getUserId(); Boolean locked = redis.opsForValue() .setIfAbsent(lockKey, data.getJti(), Duration.ofSeconds(10));
if (!locked) { throw new RefreshInProgressException(); }
try { // ========== 第三层:版本校验 ========== RefreshToken activeToken = tokenRepo .findActiveByFamily(data.getFamilyId());
if (!activeToken.getJti().equals(data.getJti())) { throw new TokenOutdatedException( "Token已被更新,请使用最新token"); }
// ========== 第四层:数据库乐观锁 ========== int updated = tokenRepo.updateWithVersion( data.getFamilyId(), data.getJti(), data.getVersion() );
if (updated == 0) { throw new ConcurrentModificationException(); }
// ========== 执行刷新 ========== RefreshTokenResult result = generateNewTokenPair(data);
// ========== 缓存结果(幂等) ========== idempotencyService.cache(idempotencyKey, result, Duration.ofMinutes(5));
return result;
} finally { redis.delete(lockKey); } }}冲突处理策略对比
┌────────────┬────────┬──────────┬───────────┬────────────────┐ │ 策略 │ 复杂度 │ 并发保护 │ 用户体验 │ 适用场景 │ ├────────────┼────────┼──────────┼───────────┼────────────────┤ │ 分布式锁 │ 中 │ ✅ 严格 │ 等待/拒绝 │ 高并发生产环境 │ ├────────────┼────────┼──────────┼───────────┼────────────────┤ │ 乐观锁 │ 低 │ ✅ 严格 │ 失败重试 │ 单机/低并发 │ ├────────────┼────────┼──────────┼───────────┼────────────────┤ │ 幂等Key │ 中 │ ✅ 严格 │ 复用结果 │ 多节点 │ ├────────────┼────────┼──────────┼───────────┼────────────────┤ │ 客户端防抖 │ 低 │ ⚠️ 部分 │ 好 │ 配合服务端使用 │ ├────────────┼────────┼──────────┼───────────┼────────────────┤ │ 请求队列化 │ 中 │ ✅ 严格 │ 依次执行 │ 严格顺序场景 │ └────────────┴────────┴──────────┴───────────┴────────────────┘总结
并发冲突解决方案:
-
幂等性:同一token刷新多次,结果相同 → 缓存刷新结果,首次处理,后续复用
-
分布式锁:同一用户同时只能有一个刷新请求 → Redis SETNX + TTL,10秒自动释放
-
版本校验:只有最新版本的token才能刷新 → 传入token的jti必须等于数据库中当前活跃的jti
-
乐观锁:数据库层面防止并发更新 → version字段 + UPDATE WHERE条件
-
客户端防抖:前端避免重复请求 → Promise单例 + 标签页协调
推荐组合:幂等 + 分布式锁 + 版本校验 = 生产级安全