一、核心问题与解决方案
1.1 单体Token的困境
单体Token面临一个基本矛盾:安全要求短期有效,体验要求长期有效。
- 短期Token(如15分钟):安全但体验差,用户需要频繁登录
- 长期Token(如7天):体验好但风险高,泄露后危害时间长
1.2 双Token的解决思路
双Token架构通过职责分离来解决这个矛盾:
短期Access Token(15分钟)
├── 用途:访问API资源
├── 特点:频繁使用,有效期短
└── 安全:泄露后危害时间短
长期Refresh Token(7天)
├── 用途:获取新的Access Token
├── 特点:很少使用,安全存储
└── 安全:可服务端撤销
二、工作流程
sequenceDiagram
participant User as 用户
participant Client as 前端
participant Server as 后端
User->>Client: 输入用户名密码
Client->>Server: POST /api/login
Note over Server: 验证用户凭据
Server->>Server: 生成双Token
Note over Server: Access Token: 15分钟有效期
Refresh Token: 7天有效期 Server->>Client: 返回双Token Note over Client: 存储到localStorage loop 正常API访问 Client->>Server: 请求API (携带Access Token) Server->>Server: 验证Access Token alt Token有效 Server->>Client: 返回业务数据 else Token过期 Server->>Client: 返回401 Unauthorized end end Note over Client: 检测到401错误 Client->>Server: POST /api/refresh (携带Refresh Token) Server->>Server: 验证Refresh Token有效性 alt Refresh Token有效 Server->>Server: 生成新的Access Token Server->>Client: 返回新的Access Token Client->>Server: 用新Token重试原始请求 Server->>Client: 返回业务数据 else Refresh Token无效/过期 Server->>Client: 返回403 Forbidden Client->>User: 跳转到登录页面 end
Refresh Token: 7天有效期 Server->>Client: 返回双Token Note over Client: 存储到localStorage loop 正常API访问 Client->>Server: 请求API (携带Access Token) Server->>Server: 验证Access Token alt Token有效 Server->>Client: 返回业务数据 else Token过期 Server->>Client: 返回401 Unauthorized end end Note over Client: 检测到401错误 Client->>Server: POST /api/refresh (携带Refresh Token) Server->>Server: 验证Refresh Token有效性 alt Refresh Token有效 Server->>Server: 生成新的Access Token Server->>Client: 返回新的Access Token Client->>Server: 用新Token重试原始请求 Server->>Client: 返回业务数据 else Refresh Token无效/过期 Server->>Client: 返回403 Forbidden Client->>User: 跳转到登录页面 end
2.1 登录阶段
- 用户提交用户名密码
- 后端验证凭据,生成双Token:
- Access Token:短期有效(15分钟),用于API访问
- Refresh Token:长期有效(7天),用于刷新Access Token
- 前端存储Token到localStorage或httpOnly Cookie
2.2 正常访问阶段
- 前端在每个API请求的Authorization头中携带Access Token
- 后端验证Access Token签名和有效期
- 如果Token有效,返回业务数据;如果过期,返回401错误
2.3 刷新阶段
- 前端检测到401错误后,自动发起刷新请求
- 刷新请求携带Refresh Token到专用端点(如
/api/refresh) - 后端验证Refresh Token:
- 检查是否在有效期内
- 检查是否被撤销(从存储中查询)
- 如果有效,签发新的Access Token
- 如果无效,返回403错误,前端跳转到登录页
2.4 异常处理
- Refresh Token过期:用户需重新登录
- Refresh Token被撤销:密码修改或安全策略触发
- 并发刷新:需要机制防止多个请求同时刷新
三、核心优势
3.1 安全优势
缩短攻击窗口:即使Access Token泄露,最多只有15分钟的危害时间。
可撤销机制:Refresh Token存储在服务端,可以主动撤销:
java
// 密码修改时撤销用户的所有Refresh Token
@Service
public class TokenRevocationService {
@Transactional
public void revokeUserTokens(String userId) {
// 从Redis/数据库删除该用户的所有Refresh Token
refreshTokenRepository.deleteByUserId(userId);
// Access Token会自然过期(最多15分钟)
// 但无法再获取新的Access Token
}
}
3.2 体验优势
无感刷新:用户无需感知Token的刷新过程:
javascript
// 前端自动刷新逻辑
class TokenManager {
async requestWithAutoRefresh(url, options) {
try {
return await fetch(url, options);
} catch (error) {
if (error.status === 401) { // Token过期
// 自动刷新
const newToken = await this.refreshToken();
// 更新请求头
options.headers.Authorization = `Bearer ${newToken}`;
// 重试请求
return await fetch(url, options);
}
throw error;
}
}
}
四、关键技术实现
4.1 双Token生成
java
@Service
public class TokenService {
// 生成Token对
public TokenPair generateTokens(User user) {
// Access Token:15分钟有效期
String accessToken = JWT.create()
.withSubject(user.getId())
.withClaim("name", user.getName())
.withExpiresAt(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.sign(Algorithm.HMAC256(accessSecret));
// Refresh Token:7天有效期,存储到数据库
String refreshTokenId = UUID.randomUUID().toString();
RefreshToken refreshToken = new RefreshToken();
refreshToken.setId(refreshTokenId);
refreshToken.setUserId(user.getId());
refreshToken.setExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000));
refreshTokenRepository.save(refreshToken);
return new TokenPair(accessToken, refreshTokenId);
}
}
4.2 Refresh Token存储策略
推荐使用Redis存储,平衡性能和控制力:
java
@Component
public class RefreshTokenStorage {
// 存储Refresh Token
public void storeToken(String tokenId, String userId) {
// Key: refresh_token:tokenId
// Value: userId
// TTL: 7天(与Token有效期一致)
redisTemplate.opsForValue().set(
"refresh_token:" + tokenId,
userId,
Duration.ofDays(7)
);
}
// 验证Refresh Token
public boolean isValidToken(String tokenId) {
// 检查是否存在且未过期
return redisTemplate.hasKey("refresh_token:" + tokenId);
}
// 撤销Token
public void revokeToken(String tokenId) {
redisTemplate.delete("refresh_token:" + tokenId);
}
// 撤销用户的所有Token
public void revokeUserTokens(String userId) {
// 需要额外维护用户->Token的映射
Set<String> userTokens = redisTemplate.opsForSet()
.members("user_tokens:" + userId);
if (userTokens != null) {
userTokens.forEach(this::revokeToken);
redisTemplate.delete("user_tokens:" + userId);
}
}
}
4.3 刷新流程实现
java
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
// 1. 验证Refresh Token有效性
if (!tokenStorage.isValidToken(refreshToken)) {
return ResponseEntity.status(403).body("Refresh Token无效或已过期");
}
// 2. 获取用户信息
String userId = tokenStorage.getUserId(refreshToken);
User user = userService.findById(userId);
// 3. 生成新的Access Token
String newAccessToken = tokenService.generateAccessToken(user);
// 4. 可选:刷新Refresh Token(轮换策略)
String newRefreshToken = null;
if (enableTokenRotation) {
// 撤销旧的
tokenStorage.revokeToken(refreshToken);
// 生成新的
newRefreshToken = tokenService.generateRefreshToken(user);
}
return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
}
}
五、实际配置建议
5.1 时间配置
yaml
# 推荐的配置值
jwt:
access-token:
expiration: 15m # 15分钟,平衡安全与刷新频率
refresh-token:
expiration: 7d # 7天,合理的会话保持时间
rotation: true # 启用Token轮换,增加安全性
5.2 安全配置
java
@Configuration
public class SecurityConfig {
// 配置需要重新认证的敏感操作
public List<String> getSensitiveEndpoints() {
return Arrays.asList(
"/api/payment/**", // 支付相关
"/api/password/change", // 修改密码
"/api/account/delete" // 删除账户
);
}
// 对这些端点,即使有有效的Access Token,也需要重新输入密码
public boolean requireReauthentication(String endpoint) {
return getSensitiveEndpoints().stream()
.anyMatch(endpoint::startsWith);
}
}
六、常见问题处理
6.1 并发刷新问题
多个请求同时触发刷新时,需要防止重复刷新:
java
@Component
public class RefreshCoordinator {
private final Map<String, CompletableFuture<String>> refreshingTokens = new ConcurrentHashMap<>();
public CompletableFuture<String> refreshToken(String refreshToken) {
// 如果已经在刷新,返回同一个Future
return refreshingTokens.computeIfAbsent(refreshToken, key -> {
CompletableFuture<String> future = doRefresh(refreshToken);
// 刷新完成后清理
future.whenComplete((result, error) -> {
refreshingTokens.remove(refreshToken);
});
return future;
});
}
}
七、总结
双Token架构的核心价值在于:
- 平衡安全与体验:通过职责分离,既保证安全又提供良好体验
- 提供控制能力:服务端可以主动撤销Refresh Token
- 支持无感刷新:用户无需频繁登录
- 便于多设备管理:可以按设备管理会话
实现时的关键点:
- Access Token要短(15-30分钟)
- Refresh Token要可撤销(存储在服务端)
- 需要处理并发刷新
- 敏感操作需要额外验证
对于大多数需要用户登录的系统,双Token架构是目前的最佳实践。它解决了单体Token的核心矛盾,为系统提供了既安全又友好的认证体验。