5652 words
28 minutes
双Token鉴权

双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 安全存储措施#

  1. 哈希存储(服务端)

永远不在数据库存明文refresh_token

// ❌ 错误:存明文
user.setRefreshToken(token); // token=abc123xyz
// ✅ 正确:存哈希
user.setRefreshTokenHash(hashSHA256(token));
┌────────────┬─────────────────────────┬──────────────────────────┐
│ 攻击场景 │ 明文存储 │ 哈希存储 │
├────────────┼─────────────────────────┼──────────────────────────┤
│ 数据库泄露 │ 攻击者直接拿到可用token │ 攻击者拿到哈希,无法逆向 │
├────────────┼─────────────────────────┼──────────────────────────┤
│ 彩虹表攻击 │ - │ 加盐防彩虹表 │
└────────────┴─────────────────────────┴──────────────────────────┘

  1. 加盐哈希
// 每次登录生成唯一盐值
String salt = UUID.randomUUID().toString();
String tokenHash = sha256(token + salt);
// 存储
user.setRefreshTokenSalt(salt);
user.setRefreshTokenHash(tokenHash);

  1. 单次使用(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 Cookie
response.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
└── 不做异常检测

实际生产环境的最佳实践#

  1. 家族追踪 (Family Rotation)

    • 每个设备登录生成独立家族
    • 设备A刷新 → 设备A的token rotation
    • 设备B不受影响
  2. 设备指纹 refresh_token 绑定设备指纹 窃取者换个设备用token → 立即告警

  3. 用户控制台 显示所有活跃设备/登录记录 用户可一键”退出所有设备”


总结#

┌──────────────┬────────────────────┐
│ 问题 │ 解决方案 │
├──────────────┼────────────────────┤
│ 窃取者先刷新 │ 5分钟宽限期 + 告警 │
├──────────────┼────────────────────┤
│ 用户被踢 │ 宽限期内刷新即可 │
├──────────────┼────────────────────┤
│ 窃取者跨设备 │ 设备指纹检测 │
├──────────────┼────────────────────┤
│ 不知道被盗 │ 实时告警通知用户 │
└──────────────┴────────────────────┘

关键:不能100%防止攻击,但能做到早期检测 + 用户知情 + 快速响应

RefreshToken 黑名单机制详解

黑名单的核心作用#

黑名单用途: 1. 记录已失效的 refresh_token 2. 防止”双花”(double-spend)攻击:同一token被刷新两次 3. 实现强制单次使用的安全保证


黑名单实现机制#

  1. 基于 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分钟)

  1. 基于数据库的持久化方案
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;
}

  1. 混合方案(最佳实践)
高速场景用Redis:
├── token验证(每次刷新)→ Redis O(1) 查询
└── 高频场景的性能保障
持久化场景用数据库:
├── 审计日志(合规要求)
├── 用户查看"登录设备历史"
└── 管理员手动撤销
@Service
public 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;
}
}

有效期管理#

  1. 黑名单条目的生命周期
┌─────────────────────────────────────────────────────────────┐
│ 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. 清理策略
// 方式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);
}

  1. Token有效期配置矩阵
┌────────────────┬────────────┬─────────────┬──────────────┐
│ Token类型 │ 建议有效期 │ 黑名单TTL │ 适用场景 │
├────────────────┼────────────┼─────────────┼──────────────┤
│ Access Token │ 15-30分钟 │ 不需要 │ API访问 │
├────────────────┼────────────┼─────────────┼──────────────┤
│ Refresh Token │ 7天 │ 7天 + 5分钟 │ 长期会话 │
├────────────────┼────────────┼─────────────┼──────────────┤
│ 记住我 Token │ 30天 │ 30天 + 1天 │ 持久登录 │
├────────────────┼────────────┼─────────────┼──────────────┤
│ 关键操作 Token │ 5分钟 │ 即时 │ 敏感操作确认 │
└────────────────┴────────────┴─────────────┴──────────────┘

存储结构设计#

  1. 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 604800
SET blacklist:refresh:jti_xyz789 "1" EX 300
# Set结构(记录当前有效token)
SADD active_tokens:user:12345 jti_abc123 jti_xyz789
SREM active_tokens:user:12345 jti_abc123 # 刷新时移除旧的

  1. 数据库表结构
-- 核心黑名单表
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;

  1. 家族追踪(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)
*/
@Service
public 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拦截器调用同一个Promise
async 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快速查询) │
└─────────────────────────────────────────────────────────────┘

完整代码示例#

@Service
public 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 │ 中 │ ✅ 严格 │ 复用结果 │ 多节点 │
├────────────┼────────┼──────────┼───────────┼────────────────┤
│ 客户端防抖 │ 低 │ ⚠️ 部分 │ 好 │ 配合服务端使用 │
├────────────┼────────┼──────────┼───────────┼────────────────┤
│ 请求队列化 │ 中 │ ✅ 严格 │ 依次执行 │ 严格顺序场景 │
└────────────┴────────┴──────────┴───────────┴────────────────┘

总结#

并发冲突解决方案:

  1. 幂等性:同一token刷新多次,结果相同 → 缓存刷新结果,首次处理,后续复用

  2. 分布式锁:同一用户同时只能有一个刷新请求 → Redis SETNX + TTL,10秒自动释放

  3. 版本校验:只有最新版本的token才能刷新 → 传入token的jti必须等于数据库中当前活跃的jti

  4. 乐观锁:数据库层面防止并发更新 → version字段 + UPDATE WHERE条件

  5. 客户端防抖:前端避免重复请求 → Promise单例 + 标签页协调

推荐组合:幂等 + 分布式锁 + 版本校验 = 生产级安全

双Token鉴权
https://sgjki547.top/posts/jwt/
Author
SGJki
Published at
2026-04-06
License
CC BY-NC-SA 4.0