双Token机制(Access Token + Refresh Token)的详细实现步骤:
1. 令牌设计与生成
1.1 令牌定义
-
Access Token
- 有效期:30分钟(短效)
- 存储方式:客户端内存或非持久化存储(如JavaScript变量)
- 内容:用户ID、权限范围、设备指纹哈希、签发时间
- 格式:JWT(含
exp
声明)
-
Refresh Token
- 有效期:7天(长效)
- 存储方式:HttpOnly + Secure Cookie(防XSS)
- 内容:全局唯一标识符(UUID)、用户ID、设备指纹哈希
- 格式:不透明字符串(存储于Redis)
1.2 登录接口实现
// AuthController.java
@PostMapping("/login")
public R<LoginResult> login(@RequestBody LoginRequest request) {// 1. 验证用户密码LoginUser user = remoteUserService.authenticate(request);// 2. 生成双TokenString accessToken = JwtUtils.generateAccessToken(user);String refreshToken = UUID.randomUUID().toString();// 3. 存储Refresh Token到Redis(绑定设备和用户)String deviceFingerprint = buildDeviceFingerprint(request);String redisKey = buildRefreshTokenKey(user.getUserId(), deviceFingerprint);redisService.setEx(redisKey, refreshToken, 7, TimeUnit.DAYS);// 4. 设置Refresh Token到CookieResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken).httpOnly(true).secure(true).path("/").maxAge(7 * 24 * 3600).sameSite("Strict").build();return R.ok(new LoginResult(accessToken)).addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
2. 令牌刷新接口
2.1 刷新端点实现
// AuthController.java
@PostMapping("/auth/refresh")
public R<LoginResult> refreshToken(@CookieValue(name = "refresh_token", required = false) String refreshToken,HttpServletRequest request) {// 1. 验证Refresh Token存在性if (StringUtils.isEmpty(refreshToken)) {return R.fail(HttpStatus.UNAUTHORIZED, "缺少刷新令牌");}// 2. 提取设备指纹String deviceFingerprint = buildDeviceFingerprint(request);// 3. 查询Redis验证有效性String redisKey = buildRefreshTokenKeyFromRequest(request); // 根据请求生成KeyString storedToken = redisService.get(redisKey);if (!refreshToken.equals(storedToken)) {return R.fail(HttpStatus.UNAUTHORIZED, "刷新令牌无效");}// 4. 生成新Access TokenLoginUser user = getCurrentUser(); // 从上下文获取用户String newAccessToken = JwtUtils.generateAccessToken(user);// 5. 可选:刷新Refresh Token有效期(滑动过期)redisService.expire(redisKey, 7, TimeUnit.DAYS);return R.ok(new LoginResult(newAccessToken));
}
2.2 设备指纹生成逻辑
private String buildDeviceFingerprint(HttpServletRequest request) {String ip = ServletUtils.getClientIP(request);String userAgent = request.getHeader("User-Agent");return Hashing.sha256().hashString(ip + userAgent, StandardCharsets.UTF_8).toString();
}
3. 网关过滤器改造
3.1 验证流程调整
// AuthFilter.java
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {ServerHttpRequest request = exchange.getRequest();// 1. 白名单直接放行if (isIgnorePath(request.getPath().toString())) {return chain.filter(exchange);}// 2. 尝试获取Access TokenString accessToken = getAccessToken(request);try {// 3. 验证Access Token有效性Claims claims = JwtUtils.parseToken(accessToken);if (claims != null && isTokenValid(claims)) {// 正常流程return chain.filter(addHeaders(exchange, claims));}} catch (ExpiredJwtException ex) {// 4. Access Token过期,尝试刷新return handleTokenRefresh(exchange, chain, ex.getClaims());}// 5. 无有效令牌return unauthorizedResponse(exchange, "请重新登录");
}private Mono<Void> handleTokenRefresh(ServerWebExchange exchange, GatewayFilterChain chain,Claims expiredClaims) {// 1. 获取Refresh TokenString refreshToken = getRefreshTokenFromCookie(exchange);// 2. 调用刷新接口(内部转发)return WebClient.create().post().uri("http://auth-service/auth/refresh").cookie("refresh_token", refreshToken).retrieve().bodyToMono(R.class).flatMap(result -> {if (result.getCode() == HttpStatus.SUCCESS) {// 3. 更新请求头中的Access TokenString newToken = result.getData().get("accessToken");ServerHttpRequest newRequest = exchange.getRequest().mutate().header("Authorization", "Bearer " + newToken).build();return chain.filter(exchange.mutate().request(newRequest).build());} else {return unauthorizedResponse(exchange, "会话已过期");}});
}
4. 安全增强措施
4.1 Token绑定设备
// JWT生成时加入设备指纹
public static String generateAccessToken(LoginUser user, HttpServletRequest request) {String fingerprint = buildDeviceFingerprint(request);return Jwts.builder().setSubject(user.getUsername()).claim("user_id", user.getUserId()).claim("fp", fingerprint).setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)).signWith(SECRET_KEY).compact();
}// 网关验证时检查设备
private boolean validateDeviceFingerprint(Claims claims, HttpServletRequest request) {String currentFp = buildDeviceFingerprint(request);String tokenFp = claims.get("fp", String.class);return currentFp.equals(tokenFp);
}
4.2 主动令牌撤销
// 注销接口
@PostMapping("/logout")
public R<Void> logout(HttpServletRequest request) {// 1. 获取当前设备指纹String fingerprint = buildDeviceFingerprint(request);// 2. 删除Redis中的Refresh TokenString redisKey = buildRefreshTokenKey(getCurrentUserId(), fingerprint);redisService.delete(redisKey);// 3. 将Access Token加入黑名单(剩余有效期内拒绝)String accessToken = getAccessToken(request);redisService.setEx("token_blacklist:" + accessToken, "1", JwtUtils.getRemainingTime(accessToken), TimeUnit.SECONDS);// 4. 清除客户端CookieResponseCookie cookie = ResponseCookie.from("refresh_token", "").maxAge(0).build();return R.ok().addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
5. 客户端实现示例
5.1 前端自动令牌管理
// axios拦截器
axios.interceptors.response.use(response => {return response;
}, error => {const originalRequest = error.config;if (error.response?.status === 401 && !originalRequest._retry) {originalRequest._retry = true;// 调用刷新接口return axios.post('/auth/refresh', {}, { withCredentials: true }).then(res => {const newToken = res.data.accessToken;localStorage.setItem('access_token', newToken);originalRequest.headers['Authorization'] = `Bearer ${newToken}`;return axios(originalRequest);});}return Promise.reject(error);
});
5.2 静默刷新机制
// 定时检查Token有效期
setInterval(() => {const token = localStorage.getItem('access_token');if (token && isTokenExpiringSoon(token)) { // 剩余<5分钟axios.post('/auth/refresh', {}, { withCredentials: true }).then(res => {localStorage.setItem('access_token', res.data.accessToken);});}
}, 300000); // 每5分钟检查
6. 监控与运维
6.1 关键监控指标
指标名称 | 监控方式 | 报警阈值 |
---|---|---|
刷新令牌失败率 | Prometheus计数器 | >5% (持续5分钟) |
并发刷新冲突次数 | Redis分布式锁统计 | >10次/秒 |
黑名单令牌数量 | Redis键空间统计 | 突增50%时告警 |
6.2 日志审计要点
# 成功刷新日志
[INFO] 用户[1001]通过设备[192.168.1.1|Chrome]刷新令牌,新有效期至2023-10-01 12:30# 异常事件日志
[WARN] 检测到异常刷新请求,用户[1001]的设备[192.168.1.2|Firefox]与记录不匹配
7. 部署与回滚
7.1 分阶段部署
-
Phase 1:
- 先部署新的Auth Service(含双Token接口)
- 保持旧网关兼容两种令牌模式
-
Phase 2:
- 部署新网关过滤器
- 前端逐步灰度发布新逻辑
-
Phase 3:
- 完全禁用旧令牌模式
- 清理遗留的单一Token数据
7.2 回滚方案
-
紧急开关:
@Value("${security.token.mode:SINGLE}") private String tokenMode;public Mono<Void> filter(...) {if ("SINGLE".equals(tokenMode)) {// 回退到旧逻辑} }
-
数据兼容:
- 保持旧Token验证逻辑1周
- 双写Refresh Token到新旧Redis结构
方案优势总结
-
安全性提升:
- Access Token短有效期降低泄露风险
- Refresh Token通过HttpOnly Cookie保护
- 设备指纹绑定防止跨设备滥用
-
用户体验优化:
- 无感知自动刷新机制
- 支持多设备独立会话管理
-
系统扩展性:
- 易于实现令牌吊销列表(黑名单)
- 支持细粒度权限变更实时生效
-
合规性保障:
- 符合OAuth 2.0规范
- 满足GDPR等数据保护要求