一、方案三:自动续期方案(用户完全无感知)
基础方案需要前端主动调用刷新接口,自动续期则是后端偷偷处理,用户操作时完全没感觉,体验最好,适合 SAAS 应用、前后端分离系统。
1. 核心思路:拦截器 + 滑动窗口
在请求拦截器里判断 Token 剩余时间,快过期时自动刷新,用响应头返回新 Token,前端偷偷替换:
@Component
public class TokenRenewInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
// Token总有效期30分钟,剩余时间小于5分钟(或30%有效期)时续期
private static final long MIN_REMAIN_TIME = 5 * 60 * 1000;
private static final double REMAIN_RATIO = 0.3;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 提取Token(从请求头)
String token = request.getHeader("Authorization").replace("Bearer ", "");
if (token == null) {
throw new SecurityException("Token不能为空");
}
// 2. 判断是否需要续期
if (needRenew(token)) {
// 3. 自动刷新Token
String newToken = tokenService.autoRefreshToken(token);
// 4. 新Token放在响应头,前端偷偷替换
response.setHeader("X-New-Token", newToken);
}
return true;
}
// 判断是否需要续期:双阈值策略(绝对时间+相对比例)
private boolean needRenew(String token) {
// 解析Token过期时间和总有效期
Date expireTime = JwtUtil.getExpireTime(token);
long totalValidTime = JwtUtil.getTotalValidTime(token); // 30*60*1000
long remainTime = expireTime.getTime() - System.currentTimeMillis();
// 剩余时间小于5分钟 或 小于总有效期的30%,就需要续期
return remainTime <= Math.min(MIN_REMAIN_TIME, (long) (totalValidTime * REMAIN_RATIO));
}
}
前端拿到响应头的X-New-Token后,偷偷替换本地存储的 Token,用户完全没感知 —— 比如正在编辑文档,Token 续期了都不知道,体验拉满。
2. Redis 缓存续期:解决分布式一致性问题
分布式系统里,多个服务都要判断 Token 是否需要续期,用 Redis 缓存 Token 状态,避免每个服务都解析 JWT:
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// Token有效期30分钟,Redis缓存时间和Token一致
private static final long TOKEN_EXPIRE = 30 * 60;
// 自动刷新Token(结合Redis缓存)
public String autoRefreshToken(String oldToken) {
// 1. 先查Redis缓存,缓存不存在说明Token已失效
String cacheKey = "token:cache:" + oldToken;
String cachedToken = redisTemplate.opsForValue().get(cacheKey);
if (cachedToken == null) {
throw new SecurityException("Token已失效,请重新登录");
}
// 2. 解析用户名,生成新Token
String username = JwtUtil.parseUsername(oldToken);
String newToken = JwtUtil.createToken(username, TOKEN_EXPIRE * 1000);
// 3. 关键:旧Token的缓存Key不变,值换成新Token(保证分布式服务拿到最新Token)
redisTemplate.opsForValue().set(cacheKey, newToken, TOKEN_EXPIRE, TimeUnit.SECONDS);
// 4. 旧Token加入黑名单(提前失效)
String blacklistKey = "token:blacklist:" + oldToken;
redisTemplate.opsForValue().set(blacklistKey, "invalid", TOKEN_EXPIRE + 5 * 60, TimeUnit.SECONDS);
return newToken;
}
}
为什么这么做:微服务 A 和微服务 B 都能通过 Redis 拿到最新 Token,不会出现 “服务 A 续期了,服务 B 还在用旧 Token” 的情况。https://wxa.wxs.qq.com/tmpl/oc/base_tmpl.html
3. Gateway 全局过滤器:微服务统一处理
微服务架构下,在 Gateway 网关加全局过滤器,所有服务的 Token 续期逻辑集中管理,不用每个服务都加拦截器:
@Component
@Order(-100)// 优先级高于其他过滤器
public class TokenRenewGlobalFilter implements GlobalFilter {
@Autowired
private TokenService tokenService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 提取Token(从请求头)
ServerHttpRequest request = exchange.getRequest();
String token = request.getHeaders().getFirst("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return chain.filter(exchange); // 无Token,后续过滤器处理
}
token = token.replace("Bearer ", "");
// 2. 判断是否需要续期
if (needRenew(token)) {
try {
// 3. 自动刷新Token
String newToken = tokenService.autoRefreshToken(token);
// 4. 新Token放在响应头
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().add("X-New-Token", newToken);
} catch (Exception e) {
// 续期失败,不影响原请求(原Token可能还有效)
log.warn("Token续期失败:{}", e.getMessage());
}
}
return chain.filter(exchange);
}
// 判断是否需要续期(和拦截器逻辑一致)
private boolean needRenew(String token) {
Date expireTime = JwtUtil.getExpireTime(token);
long totalValidTime = JwtUtil.getTotalValidTime(token);
long remainTime = expireTime.getTime() - System.currentTimeMillis();
return remainTime <= Math.min(5 * 60 * 1000, (long) (totalValidTime * 0.3));
}
}
这样不管是订单服务、用户服务,还是商品服务,都不用管 Token 续期,全由 Gateway 统一处理,代码量减少 80%。
二、分布式环境的特殊挑战:多设备 + 跨服务
1. 多设备会话管理:避免 “注销一个设备,所有设备下线”
用户用手机和电脑同时登录,注销电脑端,手机端不能跟着下线,用 Redis Hash 存储每个用户的设备会话:
@Service
public class DeviceSessionService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 用户登录时,存储设备会话(key:user:{userId}:devices,field:设备类型,value:refresh_token的stateId)
public void saveDeviceSession(Long userId, String deviceType, String stateId) {
String hashKe y= "user:" + userId + ":devices";
// 存储设备对应的stateId(覆盖旧的,同一设备登录会顶掉之前的会话)
redisTemplate.opsForHash().put(hashKey, deviceType, stateId);
// 设置Hash过期时间(和refresh_token一致,7天)
redisTemplate.expire(hashKey, 7 * 24 * 60 * 60, TimeUnit.SECONDS);
}
// 注销指定设备的会话
public void logoutDevice(Long userId, String deviceType) {
String hashKey = "user:" + userId + ":devices";
// 1. 获取该设备对应的stateId
String stateId = (String) redisTemplate.opsForHash().get(hashKey, deviceType);
if (stateId == null) {
return;
}
// 2. 使该设备的refresh_token失效
String refreshKey = "token:refresh:" + stateId;
redisTemplate.delete(refreshKey);
// 3. 从Hash中删除该设备
redisTemplate.opsForHash().delete(hashKey, deviceType);
}
// 强制用户所有设备下线(如改密码后)
public void logoutAllDevices(Long userId) {
String hashKey = "user:" + userId + ":devices";
// 1. 获取所有设备的stateId
Map<Object, Object> deviceMap = redisTemplate.opsForHash().entries(hashKey);
// 2. 使所有refresh_token失效
for (Object stateId : deviceMap.values()) {
String refreshKey = "token:refresh:" + stateId;
redisTemplate.delete(refreshKey);
}
// 3. 删除所有设备会话
redisTemplate.delete(hashKey);
}
}
比如用户在电脑端注销,只会删除 “PC” 设备对应的会话,手机端的 “Mobile” 设备不受影响,体验更友好。
2. 跨服务令牌验证:本地缓存 + 认证中心
多服务间验证 Token,每次都查 Redis 或认证中心会影响性能,用本地缓存做一层快速验证:
@Service
public class CrossServiceTokenValidator {
@Autowired
private AuthCenterClient authCenterClient; // 认证中心Feign客户端
@Autowired
private RedisTemplate<String, String> redisTemplate;
// Caffeine本地缓存:存储最近10000个有效Token,5分钟过期
private final LoadingCache<String, Boolean> tokenLocalCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> {
// 缓存未命中时,查Redis或认证中心
String redisKey = "token:valid:" + key;
if (Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
return true;
}
// Redis没查到,查认证中心(最终数据源)
return authCenterClient.validateToken(key);
});
// 跨服务验证Token
public boolean validateTokenAcrossService(String token) {
try {
// 1. 先查本地缓存(快)
return tokenLocalCache.get(token);
} catch (Exception e) {
// 2. 缓存查不到,直接查认证中心(兜底)
return authCenterClient.validateToken(token);
}
}
}
本地缓存能挡住 80% 的验证请求,只有 20% 的请求会查 Redis 或认证中心,性能提升明显 —— 之前做 SAAS 系统,没加本地缓存时,Token 验证 QPS 只有 500,加了之后能到 5000。
三、5 种 Token 续期方案全对比(该怎么选?)
| 方案 | 安全性 | 用户体验 | 实现复杂度 | 适用场景 | 性能影响 |
|---|---|---|---|---|---|
| 单 Token 基础版 | ★☆☆☆☆ | ★★☆☆☆ | 低 | 内部测试系统 | 低 |
| 单 Token + 黑名单 | ★★☆☆☆ | ★★★☆☆ | 低 | 企业内网、短期活动 | 中 |
| 双 Token 基础版 | ★★★☆☆ | ★★★★☆ | 中 | 常规 Web/APP | 中 |
| 双 Token + 三验证 | ★★★★★ | ★★★☆☆ | 高 | 金融、支付系统 | 高 |
| 自动续期 + Gateway | ★★★★☆ | ★★★★★ | 高 | SAAS 应用、微服务、高体验要求 | 中高 |
选型口诀:
- 安全第一(金融 / 支付):双 Token + 三验证 + 设备绑定
- 体验第一(SAAS/APP):自动续期 + Gateway + 本地缓存
- 简单快速(内部系统):单 Token + 黑名单
- 分布式系统:必加 Gateway 全局处理 + 本地缓存
四、避坑指南:这 5 个坑千万别踩
- access_token 有效期太长
最长不要超过 30 分钟,越长风险越高 —— 之前见过有人设 2 小时,结果 Token 被劫持,攻击者用了 1 个多小时才被发现。 - refresh_token 可重复使用
每次刷新必须生成新的 refresh_token,旧的立即失效,否则被劫持后能无限刷新。 - 没处理设备变更
手机登录后,突然在电脑上用同一个 refresh_token 刷新,必须要求重新验证(如短信验证码),防止 Token 被盗用。 - 并发刷新没加锁
高峰期多个请求同时刷新,会生成多个有效 Token,用 Redisson 分布式锁控制,只能有一个请求成功。 - 敏感操作不二次验证
付款、改密码等敏感操作,不能只验证 Token,还要加短信 / 验证码二次验证:
// 敏感操作二次验证
public void processPayment(String token, String smsCode) {
// 1. 验证Token(基础)
if (!tokenService.validateToken(token)) {
throw new SecurityException("Token失效");
}
// 2. 验证短信验证码(二次验证)
Long userId = JwtUtil.parseUserId(token);
if (!smsService.verifyCode(userId, smsCode)) {
throw new SecurityException("短信验证码错误");
}
// 3. 执行付款操作
paymentService.pay(userId);
}
最后:好的 Token 续期像 “自主神经系统”
用户平时感受不到它的存在,但在 Token 快过期时,会自动续期;在设备变更时,会默默验证;在敏感操作时,会严格把关。
从单 Token 到双 Token,再到自动续期,核心不是技术多复杂,而是平衡安全、体验和性能 —— 不要为了安全牺牲体验,也不要为了体验忽略安全。
微信扫描下方的二维码阅读本文

Comments NOTHING