☰
Spring Boot 项目别再让用户频繁重登:JWT 双 Token 无感刷新完整方案
2026/10/1 7:49:25 网站建设 项目流程

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 设成 15~30 分钟:安全窗口短,但如果没有刷新机制,用户会频繁重新登录。
  • 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

业务鉴权

15~30 分钟

是

Refresh Token

换取新的 Access Token

7~30 天

否

这样设计后,即使 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} -> Set<jti>

四、依赖与配置

1. Maven 依赖

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency>

2. application.yml

jwt: 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:生成与解析两类 Token

@Service 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, Map<String, 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) { Set<String> jtis = redisTemplate.opsForSet().members(USER_REFRESH_PREFIX + userId); if (jtis != null && !jtis.isEmpty()) { List<String> 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(); } Map<String, 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:oldJti
DELETE rt:oldJti

线程 A:GET -> 拿到旧 Token
线程 B:GET -> 也拿到旧 Token
线程 C:GET -> 还是拿到旧 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(); Map<String, 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 List&lt;String&gt; 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,而是背后的三个设计思想:

  1. 为什么“无状态”和“主动撤销”天然存在张力;
  2. 为什么 Refresh Token Rotation 必须保证一次性原子消费;
  3. 为什么前端并发控制和后端原子兜底解决的是两个不同层面的问题。

理解这三点以后,再学习 Session、OAuth2、OIDC、SSO、网关统一认证,会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询