双Token鉴权
讲解双 Token 鉴权与续费机制:Access/Refresh Token 职责划分、Refresh Token Rotation、哈希与黑名单存储,以及并发刷新冲突的生产级解决方案。
双Token续费机制详解
为什么需要双Token?
单Token的问题:
graph TD
A["用户登录 → 获得Token (24小时有效期)"]
B["用户正在操作(如提交任务)"]
C["Token突然过期 → 401错误"]
D["用户被迫重新登录 → 体验差"]
A --> B --> C --> D
双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(更持久) |
| 传输 | 每次请求带 | 仅刷新时使用 |
| 失效方式 | 时间过期 | 时间过期 / 被禁用 / 换新后作废 |
完整流程图
sequenceDiagram
autonumber
participant F as 前端
participant S as 服务器
Note over F,S: 登录流程
F->>S: POST /auth/login (username, password)
S->>S: 验证密码
S->>S: 生成 access_token (15min)
S->>S: 生成 refresh_token (7天)
S->>S: 存储 refresh_token 哈希到DB
S-->>F: { access_token, refresh_token }
F->>F: 存入 localStorage (refresh_token 持久化)
F->>F: 存入内存 (access_token)
Note over F,S: 正常API请求流程
F->>S: GET /api/tasks (带 access)
S->>S: 验证 access_token ✓
S-->>F: 200 OK (任务列表)
Note over F,S: Access Token 过期时自动刷新
F->>S: GET /api/tasks (带 access)
S-->>F: 401 Unauthorized (access_token 已过期)
F->>F: 【前端拦截401,自动触发刷新】
F->>S: POST /auth/refresh { refresh_token }
S->>S: 验证 refresh_token 哈希
S->>S: 检查是否已使用/被禁用
S->>S: 生成新的 access_token
S->>S: 生成新的 refresh_token (可选)
S->>S: 更新DB中的 refresh_token 哈希
S-->>F: { new_access_token, new_refresh_token } (rotation)
F->>F: 存入 localStorage
F->>F: 【自动重试刚才失败的请求】
F->>S: GET /api/tasks (带 new access)
S-->>F: 200 OK
Note over F,S: Refresh Token Rotation
Note over F,S: 旧 refresh_token (abc123) → 作废 + 记录到黑名单<br/>新 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 | 攻击者拿到哈希,无法逆向 |
| 彩虹表攻击 | - | 加盐防彩虹表 |
---
2. 加盐哈希
// 每次登录生成唯一盐值
String salt = UUID.randomUUID().toString();
String tokenHash = sha256(token + salt);
// 存储
user.setRefreshTokenSalt(salt);
user.setRefreshTokenHash(tokenHash);
---
3. 单次使用(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
sequenceDiagram
participant U as 正常用户
participant T as 窃取者
U->>T: POST /refresh (token_A)
Note right of T: 服务器: 验证通过<br/>生成新 token_B 替换 token_A<br/>token_A 已作废!
T-->>U: 新 token_B
Note over U: 用户下次刷新时
U->>T: POST /refresh (token_A)
T-->>U: 401 Unauthorized (验证失败! token已rotation)
Note over U: ❌ 用户被踢了! 需要重新登录
---
### 解决方案
方案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分钟)
---
2. 基于数据库的持久化方案
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;
}
---
3. 混合方案(最佳实践)
高速场景用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天清理窗口
原因: 用户可能忘记密码,需要保留登出凭证一段时间
---
2. 清理策略
// 方式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);
}
---
3. 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 # 刷新时移除旧的
---
2. 数据库表结构
-- 核心黑名单表
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;
---
3. 家族追踪(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)
```java
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;
}
}
综合解决方案(生产环境推荐)
```mermaid
graph TD
CLIENT["客户端层<br/>• 刷新锁(单例Promise)<br/>• 标签页协调(BroadcastChannel)<br/>• 请求队列化"]
GW["网关/接口层<br/>• 幂等Key检查(tokenJti hash)<br/>• 请求去重(Sliding Window)<br/>• 限流(单用户刷新频率限制)"]
SRV["服务端业务层<br/>• 分布式锁(Redis SETNX)<br/>• Token版本校验(最新版本才能刷新)<br/>• 乐观锁(数据库version字段)"]
DB["数据持久层<br/>• 唯一约束(token_jti UNIQUE)<br/>• 家族追踪(family_id + version)<br/>• 黑名单(已失效token快速查询)"]
CLIENT --> GW --> SRV --> DB
---
### 完整代码示例
@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单例 + 标签页协调
推荐组合:幂等 + 分布式锁 + 版本校验 = 生产级安全