一套完整的 AccessToken + RefreshToken 解决方案,支持黑名单与自动续期
一、为什么需要双 Token 机制?
1.1 单 Token 的痛点
传统的单 JWT Token 方案存在两个核心问题:
| 问题 |
描述 |
| 无法主动失效 |
JWT 一旦签发,在有效期内无法主动使其失效(除非维护黑名单) |
| 安全与体验矛盾 |
有效期短 → 用户体验差(频繁登录) 有效期长 → 安全风险高(Token 泄露影响大) |
1.2 双 Token 方案的设计思想
复制代码
┌─────────────────────────────────────────────────────────────┐
│ 双 Token 分工协作 │
├─────────────────────────────────────────────────────────────┤
│ AccessToken │ 短期有效(15分钟)│ 高频使用 │ 泄露影响小 │
│ RefreshToken │ 长期有效(7天) │ 低频使用 │ 存储在安全位置 │
└─────────────────────────────────────────────────────────────┘
核心思路:用 RefreshToken 的长期有效性,换取 AccessToken 的短期安全性
二、整体架构设计
2.1 架构图
复制代码
┌─────────┐ ① 登录 ┌─────────┐
│ │ ─────────────► │ │
│ 客户端 │ │ 服务端 │
│ │ ◄───────────── │ │
└─────────┘ ② 返回 Token │ │
│ └─────────┘
│ │
│ ③ 携带 AccessToken 请求 API │
│ ──────────────────────────► │
│ │ ④ 验证 JWT + 黑名单
│ │
│ ⑤ AccessToken 过期(401) │
│ ◄────────────────────────── │
│ │
│ ⑥ 携带 RefreshToken 刷新 │
│ ──────────────────────────► │
│ │ ⑦ 验证 Redis 中的 RT
│ │
│ ⑧ 返回新 AccessToken │
│ ◄────────────────────────── │
│ │
│ ⑨ 用新 Token 重试原请求 │
│ ──────────────────────────► │
2.2 Token 生命周期
| Token 类型 |
存储位置 |
推荐有效期 |
作用 |
| AccessToken |
客户端内存 / localStorage |
15分钟 ~ 2小时 |
访问受保护资源 |
| RefreshToken |
httpOnly Cookie |
7天 ~ 30天 |
刷新 AccessToken |
三、核心代码实现
技术栈:Spring Boot + JWT + Redis
3.1 项目依赖
xml
复制代码
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3.2 Token 实体类
java
复制代码
@Data
@AllArgsConstructor
public class TokenPair {
private String accessToken;
private String refreshToken;
}
@Data
public class TokenResponse {
private String accessToken;
private String tokenType = "Bearer";
private Long expiresIn; // 剩余有效秒数
}
3.3 JWT 工具类
java
复制代码
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-ttl:900}") // 默认15分钟
private Long accessTokenTtl;
/**
* 生成 AccessToken
*/
public String generateAccessToken(String userId) {
return JWT.create()
.withSubject(userId)
.withIssuedAt(new Date())
.withExpiresAt(new Date(System.currentTimeMillis() + accessTokenTtl * 1000))
.withJWTId(UUID.randomUUID().toString())
.sign(Algorithm.HMAC256(secret));
}
/**
* 验证并解析 AccessToken
*/
public DecodedJWT verifyAccessToken(String token) throws JWTVerificationException {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
return verifier.verify(token);
}
/**
* 获取 Token 剩余有效期(毫秒)
*/
public long getRemainingTtl(String token) {
DecodedJWT decoded = JWT.decode(token);
return decoded.getExpiresAt().getTime() - System.currentTimeMillis();
}
}
3.4 RefreshToken 服务层
java
复制代码
@Service
@Slf4j
public class RefreshTokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Value("${jwt.refresh-token-ttl:604800}") // 默认7天
private Long refreshTokenTtl;
/**
* 创建 RefreshToken
*/
public String createRefreshToken(String userId) {
String refreshToken = UUID.randomUUID().toString();
String key = buildKey(refreshToken);
redisTemplate.opsForValue().set(key, userId, refreshTokenTtl, TimeUnit.SECONDS);
// 记录用户的所有 RefreshToken(用于多设备管理)
redisTemplate.opsForSet().add("user:tokens:" + userId, refreshToken);
return refreshToken;
}
/**
* 验证 RefreshToken
*/
public String validateAndGetUserId(String refreshToken) {
String key = buildKey(refreshToken);
return redisTemplate.opsForValue().get(key);
}
/**
* 刷新 RefreshToken(旋转机制)
*/
public String rotateRefreshToken(String oldRefreshToken, String userId) {
// 检查是否已被使用
String usedKey = "used:" + oldRefreshToken;
if (Boolean.TRUE.equals(redisTemplate.hasKey(usedKey))) {
// 可能被窃取,注销所有 Token
revokeAllUserTokens(userId);
throw new SecurityException("RefreshToken 可能已被窃取,已注销所有会话");
}
// 标记旧 Token 已使用
redisTemplate.opsForValue().set(usedKey, "1", 10, TimeUnit.MINUTES);
// 删除旧 Token
redisTemplate.delete(buildKey(oldRefreshToken));
redisTemplate.opsForSet().remove("user:tokens:" + userId, oldRefreshToken);
// 生成新 Token
return createRefreshToken(userId);
}
/**
* 注销 RefreshToken
*/
public void revokeRefreshToken(String refreshToken) {
String userId = redisTemplate.opsForValue().get(buildKey(refreshToken));
if (userId != null) {
redisTemplate.delete(buildKey(refreshToken));
redisTemplate.opsForSet().remove("user:tokens:" + userId, refreshToken);
}
}
/**
* 注销用户所有 Token
*/
public void revokeAllUserTokens(String userId) {
Set<String> tokens = redisTemplate.opsForSet().members("user:tokens:" + userId);
if (tokens != null) {
for (String token : tokens) {
redisTemplate.delete(buildKey(token));
}
}
redisTemplate.delete("user:tokens:" + userId);
}
private String buildKey(String refreshToken) {
return "refresh:" + refreshToken;
}
}
3.5 黑名单服务
java
复制代码
@Service
public class BlacklistService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 将 AccessToken 加入黑名单(登出时使用)
*/
public void blacklistAccessToken(String accessToken, long ttlMillis) {
if (ttlMillis > 0) {
redisTemplate.opsForValue().set(
"blacklist:" + accessToken,
"1",
ttlMillis,
TimeUnit.MILLISECONDS
);
}
}
/**
* 检查 Token 是否在黑名单中
*/
public boolean isBlacklisted(String accessToken) {
return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + accessToken));
}
}
3.6 JWT 过滤器(核心)
java
复制代码
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private BlacklistService blacklistService;
private static final List<String> WHITE_LIST = Arrays.asList(
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/swagger-ui/**",
"/v3/api-docs/**"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 白名单放行
String path = request.getRequestURI();
if (WHITE_LIST.stream().anyMatch(path::startsWith)) {
chain.doFilter(request, response);
return;
}
String accessToken = extractAccessToken(request);
if (StringUtils.isEmpty(accessToken)) {
sendUnauthorized(response, "缺少 AccessToken");
return;
}
try {
// 1. 验证黑名单
if (blacklistService.isBlacklisted(accessToken)) {
sendUnauthorized(response, "Token 已被注销");
return;
}
// 2. 验证 JWT
DecodedJWT decoded = jwtUtils.verifyAccessToken(accessToken);
String userId = decoded.getSubject();
// 3. 存入上下文
request.setAttribute("userId", userId);
chain.doFilter(request, response);
} catch (TokenExpiredException e) {
// AccessToken 过期,通知前端使用 RefreshToken 刷新
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("X-Token-Expired", "true");
response.setContentType("application/json");
response.getWriter().write("{\"code\":401,\"message\":\"AccessToken 已过期\"}");
} catch (JWTVerificationException e) {
log.warn("Token 验证失败: {}", e.getMessage());
sendUnauthorized(response, "Token 无效");
}
}
private String extractAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
}
}
3.7 认证控制器
java
复制代码
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private RefreshTokenService refreshTokenService;
@Autowired
private BlacklistService blacklistService;
/**
* 登录接口
*/
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request) {
// 验证用户名密码(省略)
String userId = authenticate(request);
// 生成 Token 对
String accessToken = jwtUtils.generateAccessToken(userId);
String refreshToken = refreshTokenService.createRefreshToken(userId);
// 设置 httpOnly Cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true) // HTTPS 环境
.sameSite("Strict")
.path("/api/auth")
.maxAge(Duration.ofDays(7))
.build();
TokenResponse response = new TokenResponse(
accessToken,
"Bearer",
jwtUtils.getRemainingTtl(accessToken) / 1000
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(response);
}
/**
* 刷新 Token(自动续期核心)
*/
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refreshToken(
@CookieValue(value = "refreshToken", required = false) String refreshToken,
HttpServletResponse response) {
if (StringUtils.isEmpty(refreshToken)) {
return ResponseEntity.status(401).build();
}
// 1. 验证 RefreshToken
String userId = refreshTokenService.validateAndGetUserId(refreshToken);
if (userId == null) {
return ResponseEntity.status(401).body(null);
}
// 2. 旋转 RefreshToken(安全增强)
String newRefreshToken;
try {
newRefreshToken = refreshTokenService.rotateRefreshToken(refreshToken, userId);
} catch (SecurityException e) {
return ResponseEntity.status(401).body(null);
}
// 3. 生成新 AccessToken
String newAccessToken = jwtUtils.generateAccessToken(userId);
// 4. 设置新的 RefreshToken Cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", newRefreshToken)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/api/auth")
.maxAge(Duration.ofDays(7))
.build();
TokenResponse tokenResponse = new TokenResponse(
newAccessToken,
"Bearer",
jwtUtils.getRemainingTtl(newAccessToken) / 1000
);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(tokenResponse);
}
/**
* 登出接口
*/
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authorization,
@CookieValue("refreshToken") String refreshToken) {
String accessToken = authorization.substring(7);
// 将 AccessToken 加入黑名单
long remainingTtl = jwtUtils.getRemainingTtl(accessToken);
blacklistService.blacklistAccessToken(accessToken, remainingTtl);
// 注销 RefreshToken
refreshTokenService.revokeRefreshToken(refreshToken);
// 清除 Cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.maxAge(0)
.path("/api/auth")
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
}
四、前端实现(自动续期核心)
4.1 Axios 拦截器实现
javascript
复制代码
// auth.js
const API_BASE_URL = 'https://api.example.com';
// 是否正在刷新中
let isRefreshing = false;
// 等待队列
let refreshSubscribers = [];
class AuthService {
constructor() {
this.setupInterceptors();
}
/**
* 设置 Axios 拦截器
*/
setupInterceptors() {
// 请求拦截器:添加 AccessToken
axios.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:处理 Token 过期
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 检查是否是 Token 过期
if (error.response?.status === 401 &&
error.response?.headers['x-token-expired'] === 'true' &&
!originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await this.refreshAccessToken();
if (newToken) {
// 重试原请求
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return axios(originalRequest);
}
} catch (refreshError) {
// 刷新失败,跳转登录
this.redirectToLogin();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
/**
* 刷新 AccessToken
*/
async refreshAccessToken() {
// 防止并发刷新
if (!isRefreshing) {
isRefreshing = true;
try {
// RefreshToken 会自动通过 Cookie 携带
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh`, {}, {
withCredentials: true // 允许携带 Cookie
});
const { accessToken, expiresIn } = response.data;
// 存储新 Token
localStorage.setItem('accessToken', accessToken);
// 设置定时器,提前刷新
this.scheduleAutoRefresh(expiresIn);
// 通知等待队列
this.onRefreshed(accessToken);
return accessToken;
} catch (error) {
this.onRefreshed(null);
throw error;
} finally {
isRefreshing = false;
refreshSubscribers = [];
}
}
// 正在刷新中,加入等待队列
return new Promise(resolve => {
refreshSubscribers.push(token => resolve(token));
});
}
/**
* 刷新完成后的回调
*/
onRefreshed(token) {
refreshSubscribers.forEach(callback => callback(token));
}
/**
* 定时自动刷新(可选)
* 在 Token 过期前 1 分钟主动刷新
*/
scheduleAutoRefresh(expiresInSeconds) {
const refreshTime = (expiresInSeconds - 60) * 1000; // 提前1分钟
if (refreshTime > 0) {
setTimeout(async () => {
try {
await this.refreshAccessToken();
console.log('自动刷新 Token 成功');
} catch (error) {
console.warn('自动刷新失败', error);
}
}, refreshTime);
}
}
/**
* 跳转登录页
*/
redirectToLogin() {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
/**
* 登录
*/
async login(username, password) {
const response = await axios.post(`${API_BASE_URL}/api/auth/login`, {
username,
password
}, { withCredentials: true });
const { accessToken, expiresIn } = response.data;
localStorage.setItem('accessToken', accessToken);
this.scheduleAutoRefresh(expiresIn);
return response.data;
}
/**
* 登出
*/
async logout() {
try {
await axios.post(`${API_BASE_URL}/api/auth/logout`, {}, {
withCredentials: true,
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
}
});
} finally {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
}
}
export default new AuthService();
五、自动续期的工作原理
5.1 核心流程图
复制代码
用户操作 前端 后端
│ │ │
│ 发起请求携带 AccessToken │
├────────►│ │
│ │ 验证 AccessToken │
│ ├─────────────────────►│
│ │ │
│ │ 过期,返回 401 │
│ │◄─────────────────────┤
│ │ │
│ │ 调用 /refresh │
│ ├─────────────────────►│
│ │ │
│ │ 验证 RefreshToken │
│ │ 生成新 AccessToken │
│ │◄─────────────────────┤
│ │ │
│ │ 用新 Token 重试请求 │
│ ├─────────────────────►│
│ │ │
│◄────────┤ 返回正常响应 │
│ │◄─────────────────────┤
│ │ │
│ 用户无感知完成续期 │
5.2 为什么能做到"自动"?
| 层面 |
实现方式 |
| 检测层 |
前端拦截器识别 401 + X-Token-Expired 头 |
| 刷新层 |
自动调用 /refresh 接口获取新 Token |
| 重试层 |
用新 Token 自动重试原始请求 |
| 用户感知 |
整个过程异步完成,用户无感知 |
5.3 并发请求处理
当多个请求同时发现 Token 过期时:
复制代码
请求A ──┐
请求B ──┼── 检测到过期 ──► 请求A 发起刷新 ──► 新Token
请求C ──┘ │
▼
请求B、C 等待 ──► 使用新Token重试
六、安全增强机制
6.1 安全措施汇总
复制代码
┌─────────────────────────────────────────────────────────────┐
│ 多层安全防护 │
├─────────────────────────────────────────────────────────────┤
│ ✓ RefreshToken 使用 httpOnly Cookie → 防 XSS 窃取 │
│ ✓ RefreshToken 旋转机制 → 防重放攻击 │
│ ✓ AccessToken 黑名单 → 主动失效能力 │
│ ✓ 设备指纹绑定(可选) → 防 Token 盗用 │
│ ✓ 异常检测 + 全家桶注销 → 检测到攻击响应 │
│ ✓ HTTPS 传输 → 防中间人攻击 │
└─────────────────────────────────────────────────────────────┘
6.2 RefreshToken 旋转详解
java
复制代码
// 旋转机制核心逻辑
public String rotateRefreshToken(String oldToken, String userId) {
// 1. 检查旧 Token 是否已被使用
if (redisTemplate.hasKey("used:" + oldToken)) {
// 攻击检测:同一 Token 被使用两次
revokeAllUserTokens(userId); // 注销用户所有会话
throw new SecurityException("Token replay attack detected");
}
// 2. 标记旧 Token 已使用
redisTemplate.opsForValue().set("used:" + oldToken, "1", 10, TimeUnit.MINUTES);
// 3. 删除旧 Token
redisTemplate.delete("refresh:" + oldToken);
// 4. 生成新 Token
return createRefreshToken(userId);
}
6.3 设备指纹绑定(进阶)
java
复制代码
// 生成 RefreshToken 时绑定设备指纹
public String createRefreshToken(String userId, String deviceFingerprint) {
String refreshToken = UUID.randomUUID().toString();
RefreshTokenEntity entity = new RefreshTokenEntity(userId, deviceFingerprint);
redisTemplate.opsForValue().set(
"refresh:" + refreshToken,
JSON.toJSONString(entity),
7, TimeUnit.DAYS
);
return refreshToken;
}
// 验证时检查设备指纹
public boolean validate(String refreshToken, String deviceFingerprint) {
String json = redisTemplate.opsForValue().get("refresh:" + refreshToken);
RefreshTokenEntity entity = JSON.parseObject(json, RefreshTokenEntity.class);
if (!entity.getDeviceFingerprint().equals(deviceFingerprint)) {
// 设备变更,可能存在风险
revokeAllUserTokens(entity.getUserId());
return false;
}
return true;
}
七、配置与调优
7.1 配置文件
yaml
复制代码
# application.yml
jwt:
secret: ${JWT_SECRET:your-256-bit-secret-key-here}
access-token-ttl: 900 # 15分钟(秒)
refresh-token-ttl: 604800 # 7天(秒)
spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
7.2 有效期推荐配置
| 场景 |
AccessToken |
RefreshToken |
| 高安全(银行类) |
5分钟 |
1小时 |
| 普通 Web 应用 |
15分钟 |
7天 |
| 移动 App |
1小时 |
30天 |
| 内部系统 |
2小时 |
7天 |
八、常见问题与解决方案
Q1:为什么不用 Redis 存储所有 Token?
| 方案 |
优点 |
缺点 |
| 纯 Redis |
可主动失效 |
每次请求查 Redis,性能差 |
| 纯 JWT |
无状态,性能好 |
无法主动失效 |
| JWT + Redis 黑名单 |
兼顾性能与可控 |
实现稍复杂 |
Q2:RefreshToken 被窃取了怎么办?
java
复制代码
// 方案1:设备指纹检测
// 方案2:异常行为检测(IP、地理位置突变)
// 方案3:提供"注销所有设备"功能
Q3:多设备登录如何处理?
java
复制代码
// 每个设备独立的 RefreshToken
// Redis 结构:user:tokens:{userId} -> Set<refreshToken>
// 查询用户所有设备
Set<String> tokens = redisTemplate.opsForSet().members("user:tokens:" + userId);
// 远程注销指定设备
public void revokeDevice(String userId, String deviceId) {
// 根据 deviceId 找到对应的 RefreshToken 并删除
}
九、总结
核心要点
- AccessToken 短时效 + JWT → 无状态高性能
- RefreshToken 长时效 + Redis → 可主动撤销
- 前端拦截器 + 401 检测 → 实现无感自动续期
- 黑名单机制 → 解决 JWT 无法主动失效问题
- Token 旋转 → 防止重放攻击
架构优势
| 维度 |
效果 |
| 安全性 |
⭐⭐⭐⭐⭐ 多层防护 |
| 用户体验 |
⭐⭐⭐⭐⭐ 无感续期 |
| 性能 |
⭐⭐⭐⭐ JWT 无状态验证 |
| 可维护性 |
⭐⭐⭐⭐ 清晰的分层设计 |