Spring Boot 项目别再让用户频繁重登:JWT 双 Token 无感刷新完整方案

发布时间:2026/10/1 7:49:25
Spring Boot 项目别再让用户频繁重登:JWT 双 Token 无感刷新完整方案
Access Token Refresh Token Redis Rotation 并发刷新控制文章导读这是一篇面向 Spring Boot 项目的工程化实现文章。重点不是“把 Token 过期时间调长”而是通过职责分离解决安全与体验之间的冲突。本文同时修正了常见示例中 Refresh Token 权限信息、jti 安全边界和主动失效等容易误解的问题。一、为什么单 Token 方案迟早会遇到问题很多系统最开始只有一个 JWT用户登录后签发 Access Token后续请求把它放进 Authorization 请求头服务端只做验签、解析和过期时间校验。这个方案简单、性能高也非常适合水平扩展。Authorization: Bearer access_token问题在于纯无状态 JWT 一旦签发服务端通常不会为每个 Token 保存会话状态。只要签名正确、尚未过期这个 Token 就可以继续使用。于是会出现三个典型问题。1. 过期时间长短两难Access Token 设成 1530 分钟安全窗口短但如果没有刷新机制用户会频繁重新登录。Access Token 设成 7 天体验好但一旦 Token 泄露攻击者可能在较长时间内持续使用。所以这不是“3600 秒还是 7 天”的配置问题而是一个职责设计问题同一个 Token 同时承担“访问业务接口”和“维持长期登录态”两项职责本身就容易产生冲突。2. 无法天然做到立即撤销更准确地说纯无状态 JWT 没有天然的逐 Token 撤销能力。用户改密码、管理员封号、用户主动退出后如果服务端完全不保存额外状态旧 Access Token 在自然过期前仍可能通过验签。说明JWT 不是绝对“无法撤销”。你可以引入黑名单、tokenVersion、Redis 会话状态等机制实现撤销但这样系统就不再是完全无状态。3. 把认证凭证和刷新凭证混在一起有些项目会在拦截器或过滤器里判断 Token 是否过期再尝试调用刷新逻辑。但如果刷新接口本身又依赖同一个已过期 Token就很容易形成逻辑闭环。根本原因是认证凭证和续期凭证没有分开。二、正确思路双 Token 职责分离核心原则只有一句Access Token 负责访问业务接口Refresh Token 负责延续登录状态。类型主要职责建议有效期是否直接访问业务接口Access Token业务鉴权1530 分钟是Refresh Token换取新的 Access Token730 天否这样设计后即使 Access Token 泄露攻击窗口也会被压缩到较短时间而用户又不需要每隔十几分钟重新输入账号密码因为 Refresh Token 可以在后台完成续期。三、本文采用的服务端可控 Refresh Token 方案本文采用一种“客户端持有随机刷新凭证标识服务端在 Redis 保存完整 Refresh Token”的实现。为了和 JWT 标准字段保持一致下面仍使用 jti 这个名字。安全边界jti 在这个方案里已经具有“刷新凭证”的作用。攻击者如果拿到可直接用于 /refresh 的 jti仍可能冒用刷新流程。因此不要把 jti 当成无敏感性的普通 ID。Web 场景下可进一步结合 HttpOnly、Secure、SameSite Cookie、设备绑定等措施。客户端 Access Token refreshTokenId(jti) Redis rt:{jti} - 完整 Refresh Token user_rt:{userId} - Setjti四、依赖与配置1. Maven 依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.6/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.6/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.6/version scoperuntime/scope /dependency2. application.ymljwt: access-token: secret: ${JWT_ACCESS_SECRET:REPLACE_WITH_AT_LEAST_32_BYTES_RANDOM_STRING} expiration: 900000 # 15 分钟 refresh-token: secret: ${JWT_REFRESH_SECRET:USE_A_DIFFERENT_32_BYTES_MINIMUM_KEY} expiration: 604800000 # 7 天Access Token 和 Refresh Token 建议使用不同密钥避免两个安全域耦合。HS256 对称密钥至少需要 256 bit也就是 32 字节。生产环境不要把密钥硬编码进 Git 仓库优先使用环境变量或密钥管理服务。五、JwtService生成与解析两类 TokenService public class JwtService { Value(${jwt.access-token.secret}) private String accessSecret; Value(${jwt.access-token.expiration}) private Long accessExpiration; Value(${jwt.refresh-token.secret}) private String refreshSecret; Value(${jwt.refresh-token.expiration}) private Long refreshExpiration; public String generateAccessToken(String userId, MapString, Object claims) { return Jwts.builder() .subject(userId) .claims(claims) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() accessExpiration)) .signWith(Keys.hmacShaKeyFor(accessSecret.getBytes(StandardCharsets.UTF_8))) .compact(); } public String generateRefreshToken(String userId, String jti) { return Jwts.builder() .subject(userId) .id(jti) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() refreshExpiration)) .signWith(Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8))) .compact(); } public Claims parseAccessToken(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(accessSecret.getBytes(StandardCharsets.UTF_8))) .build() .parseSignedClaims(token) .getPayload(); } public Claims parseRefreshToken(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8))) .build() .parseSignedClaims(token) .getPayload(); } public boolean isAboutToExpire(Claims claims, long thresholdSeconds) { long remaining claims.getExpiration().getTime() - System.currentTimeMillis(); return remaining 0 remaining thresholdSeconds * 1000; } public Long getRefreshExpiration() { return refreshExpiration; } }这里刻意没有把 roles 等权限数据塞进 Refresh Token。Refresh Token 的职责只是证明“这个会话仍具备续期资格”业务权限应该在刷新时重新获取避免把已经过期的角色信息不断复制下去。六、Redis 存储设计Component public class RefreshTokenStore { private static final String REFRESH_PREFIX rt:; private static final String USER_REFRESH_PREFIX user_rt:; Autowired private StringRedisTemplate redisTemplate; public void save(String jti, String userId, String refreshToken, Duration ttl) { redisTemplate.opsForValue().set(REFRESH_PREFIX jti, refreshToken, ttl); redisTemplate.opsForSet().add(USER_REFRESH_PREFIX userId, jti); } public String getAndDelete(String jti) { return redisTemplate.opsForValue().getAndDelete(REFRESH_PREFIX jti); } public void delete(String jti, String userId) { redisTemplate.delete(REFRESH_PREFIX jti); if (userId ! null) { redisTemplate.opsForSet().remove(USER_REFRESH_PREFIX userId, jti); } } public void deleteAllByUser(String userId) { SetString jtis redisTemplate.opsForSet().members(USER_REFRESH_PREFIX userId); if (jtis ! null !jtis.isEmpty()) { ListString keys jtis.stream() .map(jti - REFRESH_PREFIX jti) .toList(); redisTemplate.delete(keys); } redisTemplate.delete(USER_REFRESH_PREFIX userId); } }为什么还需要 user_rt:{userId}因为一个用户可能同时在手机、电脑、平板登录每个设备都有不同 jti。把这些 jti 放进 Set 后改密、封号或“退出全部设备”时就能一次找到并删除该用户的全部 Refresh Token。说明删除 Refresh Token 只能阻止后续续期不能自动让已经签发的 Access Token 立刻失效。Access Token 仍可能工作到自然过期。如果业务要求封禁立即生效需要额外引入 tokenVersion、黑名单或集中会话校验。七、刷新接口最关键的是 Rotation刷新接口不要简单地“验证旧 Refresh Token 后再发一个新的 Access Token”。更稳妥的做法是 Refresh Token Rotation每次刷新都消费旧凭证并签发新的刷新凭证。RestController RequestMapping(/api/auth) public class RefreshController { Autowired private JwtService jwtService; Autowired private RefreshTokenStore refreshTokenStore; Autowired private UserService userService; PostMapping(/refresh) public ResponseEntity? refresh(RequestBody RefreshRequest request) { String oldJti request.getRefreshTokenId(); if (oldJti null || oldJti.isBlank()) { return ResponseEntity.badRequest().body(refreshTokenId is required); } // 1. 原子读取并删除保证旧刷新凭证只能成功消费一次 String oldRefreshToken refreshTokenStore.getAndDelete(oldJti); if (oldRefreshToken null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of(code, 40102, message, Refresh token not found or already used)); } try { // 2. 校验旧 Refresh Token Claims claims jwtService.parseRefreshToken(oldRefreshToken); String userId claims.getSubject(); // 3. 重新查询当前用户与权限而不是从 Refresh Token 复制旧 roles User user userService.getById(userId); if (user null || !user.isEnabled()) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } MapString, Object accessClaims new HashMap(); accessClaims.put(roles, user.getRoles()); String newAccessToken jwtService.generateAccessToken(userId, accessClaims); // 4. Rotation签发新的 Refresh Token 新 jti String newJti UUID.randomUUID().toString(); String newRefreshToken jwtService.generateRefreshToken(userId, newJti); refreshTokenStore.save( newJti, userId, newRefreshToken, Duration.ofMillis(jwtService.getRefreshExpiration()) ); // 5. 返回新 Access Token 和新的刷新凭证标识 return ResponseEntity.ok(new TokenPair(newAccessToken, newJti)); } catch (JwtException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of(code, 40103, message, Invalid refresh token)); } } }关键修正上面的示例比很多网上版本多了一步刷新时根据 userId 重新查询当前角色和账号状态。这样管理员刚刚降权、封号后不会因为旧 Refresh Token 里缓存了历史 roles 而继续签发带旧权限的 Access Token。八、为什么一定要 GETDEL而不是 GET DELETE这是整套方案里最值得理解的并发点。假设一个页面同时发出多个接口请求它们几乎同一时间发现 Access Token 需要刷新。错误写法GET rt:oldJtiDELETE rt:oldJti线程 AGET - 拿到旧 Token线程 BGET - 也拿到旧 Token线程 CGET - 还是拿到旧 Token结果A/B/C 都有机会生成新的 Token 对。GET 和 DELETE 是两个独立操作中间存在并发窗口。Rotation 要求“一个旧刷新凭证只能被成功消费一次”因此读取和删除必须具备原子性。Redis 6.2 可以使用 GETDEL。GETDEL rt:oldJti线程 A - 得到旧 Token并同时删除 Key线程 B - null线程 C - null如果 Redis 版本不支持 GETDEL也可以用 Lua 脚本实现“读取 删除”的原子操作。九、登录接口PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { User user authenticate(request); String userId user.getId(); MapString, Object accessClaims new HashMap(); accessClaims.put(roles, user.getRoles()); String accessToken jwtService.generateAccessToken(userId, accessClaims); String jti UUID.randomUUID().toString(); String refreshToken jwtService.generateRefreshToken(userId, jti); refreshTokenStore.save( jti, userId, refreshToken, Duration.ofMillis(jwtService.getRefreshExpiration()) ); return ResponseEntity.ok(new LoginResponse(accessToken, jti)); }十、认证 Filter只负责校验和提示不负责刷新Filter 的职责应该尽量单一验证 Access Token、把用户信息传给后续业务、在 Token 即将过期时通过响应头给前端提示。不要在 Filter 里同步调用刷新接口。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtService jwtService; private static final Listlt;Stringgt; WHITE_LIST List.of( /api/auth/login, /api/auth/refresh, /actuator/health ); Override protected boolean shouldNotFilter(HttpServletRequest request) { String path request.getRequestURI(); return WHITE_LIST.stream().anyMatch(path::startsWith); } Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } String accessToken authHeader.substring(7); try { Claims claims jwtService.parseAccessToken(accessToken); if (jwtService.isAboutToExpire(claims, 300)) { response.setHeader(X-Token-Expiring, true); } request.setAttribute(userId, claims.getSubject()); request.setAttribute(userClaims, claims); chain.doFilter(request, response); } catch (ExpiredJwtException e) { response.setHeader(X-Token-Expired, true); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } } }十一、前端必须加“刷新锁”后端 GETDEL 解决的是安全和原子性问题但无法避免前端同时发起多次刷新请求。假设四个业务接口同时收到“即将过期”提示如果四个请求都调用 /refresh只有第一个能消费旧 jti其余都会失败。et refreshPromise null; function refreshTokenOnce() { if (!refreshPromise) { refreshPromise doRefresh() .finally(() { refreshPromise null; }); } return refreshPromise; }因此两层措施缺一不可前端单例 Promise 负责避免重复刷新、改善体验后端 GETDEL 负责最终的原子性与安全兜底。十二、完整流程1. 用户登录 用户名 密码 ↓ 后端认证成功 ↓ 返回 Access Token jti Redis 保存完整 Refresh Token 2. 正常请求 Authorization: Bearer AccessToken ↓ Filter 验签 ↓ Controller / Service 3. Access Token 即将过期 Filter - X-Token-Expiring: true ↓ 前端触发 /refresh 4. 刷新 old jti ↓ Redis GETDEL ↓ 验证旧 Refresh Token ↓ 查询最新账号状态与权限 ↓ 生成新 Access Token ↓ 生成新 Refresh Token new jti ↓ Redis 保存 ↓ 返回新 Access Token new jti 5. 旧 jti 立即失效新 jti 进入下一轮十三、退出、改密、封号怎么处理普通退出删除当前设备对应的 Refresh Token。退出全部设备通过 user_rt:{userId} 找到全部 jti 并删除。修改密码通常建议使全部 Refresh Token 失效要求其他设备重新认证。管理员封号删除全部 Refresh Token并在刷新时再次检查账号 enabled/status。说明如果必须做到“封号后当前 Access Token 立即不能使用”仅删除 Refresh Token 不够。可以额外引入 tokenVersion、Redis 在线会话校验或 Access Token 黑名单但会增加每次请求的状态读取成本。十四、几个最容易踩的坑1. Access Token 和 Refresh Token 共用同一密钥不建议。两类 Token 职责不同使用不同密钥可以减少安全域耦合。2. 把 Refresh Token 有效期直接设得很长有效期越长泄露后的可利用窗口越大。应结合 Rotation、设备管理和风险等级设计。3. 刷新时直接复制 Refresh Token 中的旧 roles容易让旧权限持续传播。更稳妥的是根据 userId 查询当前账号状态和权限。4. GET DELETE 实现 Rotation存在并发窗口。优先使用 GETDEL 或 Lua 保证原子消费。5. 只做后端原子控制不做前端刷新锁安全没问题但并发请求会制造大量 401用户体验差。6. 认为删除 Refresh Token 就能立即踢掉当前 Access Token不准确。它只能阻止续期当前 Access Token 仍可能使用到自然过期。7. 把 jti 当作完全不敏感的数据如果 /refresh 只凭 jti 就能换 Token那么 jti 本身已经是刷新凭证需要按敏感凭证保护。8. 已经接入 OAuth2/OIDC 还自己重复造刷新体系如果统一身份平台已经提供标准 Token 刷新、撤销、MFA 和审计能力应优先遵循 IdP 的机制而不是业务系统自行绕开。十五、方案取舍什么时候值得用这套设计的代价是系统复杂度会上升多一套 Redis 状态、多一个刷新接口、前端需要响应头处理、失败重试和刷新锁。换来的则是更短的 Access Token 生命周期、可控的长期登录状态、多设备管理以及更可靠的刷新并发控制。场景推荐方案原因普通内部系统安全要求一般短 Access Token Refresh Token DB/Redis实现成本适中互联网 Web 系统短 Access Token Rotation HttpOnly 等浏览器安全措施兼顾体验与凭证保护高安全系统双 Token Rotation 设备/会话状态 风险控制需要更强主动失效能力已接入 OAuth2/OIDC优先使用 IdP 标准刷新机制避免重复建设与绕过统一安全能力十六、总结一句话总结Access Token 短命负责访问Refresh Token 长期但必须可控每次刷新使用 Rotation 换新后端用原子操作防并发前端用刷新锁避免重复请求。真正应该理解的不是 JJWT 的几个 API而是背后的三个设计思想为什么“无状态”和“主动撤销”天然存在张力为什么 Refresh Token Rotation 必须保证一次性原子消费为什么前端并发控制和后端原子兜底解决的是两个不同层面的问题。理解这三点以后再学习 Session、OAuth2、OIDC、SSO、网关统一认证会顺很多。