Spring Boot 3.x 中的 Token 续期方案实现
在 Spring Boot 3.x 中结合 Spring Security 6.x 和 OAuth2,可以实现多种 Token 续期策略,常见有:
- 刷新令牌(Refresh Token)
- 滑动过期(Sliding Expiration)
- 静默续期(Silent Renewal)
下面将完整实现这三种方案,并加上生产级安全增强。
1. 项目依赖
<dependencies>
<!-- Spring Security + OAuth2 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Redis 存储 Token 黑名单和元数据 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT 支持 -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
</dependencies>
2. JWT 配置(公钥 / 私钥)
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
@Bean
public JwtEncoder jwtEncoder() {
JWK jwk = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwks);
}
3. 安全配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private SlidingExpirationFilter slidingExpirationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/token", "/auth/refresh").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.addFilterBefore(slidingExpirationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
4. Token 服务(生成 & 解析 & 黑名单)
@Service
public class TokenService {
@Value("${jwt.expiration}")
private long jwtExpiration;
@Value("${jwt.refresh-expiration}")
private long refreshExpiration;
@Autowired
private JwtEncoder jwtEncoder;
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String TOKEN_BLACKLIST = "token:blacklist:";
public String generateAccessToken(UserDetails userDetails) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("your-issuer")
.issuedAt(now)
.expiresAt(now.plusMillis(jwtExpiration))
.subject(userDetails.getUsername())
.claim("scope", "ROLE_USER")
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public String generateRefreshToken(UserDetails userDetails) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("your-issuer")
.issuedAt(now)
.expiresAt(now.plusMillis(refreshExpiration))
.subject(userDetails.getUsername())
.claim("token_type", "refresh")
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public void blacklistToken(String token, long expiresInSeconds) {
redisTemplate.opsForValue().set(TOKEN_BLACKLIST + token, "1", expiresInSeconds, TimeUnit.SECONDS);
}
public boolean isTokenBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey(TOKEN_BLACKLIST + token));
}
}
5. 刷新令牌端点
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private TokenService tokenService;
@Autowired
private JwtDecoder jwtDecoder;
@Autowired
private UserDetailsService userDetailsService;
@PostMapping("/refresh")
public ResponseEntity<JwtResponse> refreshToken(@RequestBody RefreshRequest request) {
try {
Jwt jwt = jwtDecoder.decode(request.getRefreshToken());
if (!"refresh".equals(jwt.getClaims().get("token_type"))) {
throw new JwtException("Invalid token type");
}
if (tokenService.isTokenBlacklisted(request.getRefreshToken())) {
throw new JwtException("Token already used");
}
UserDetails userDetails = userDetailsService.loadUserByUsername(jwt.getSubject());
String newAccessToken = tokenService.generateAccessToken(userDetails);
String newRefreshToken = tokenService.generateRefreshToken(userDetails);
// 旧 refresh token 加入黑名单
long ttl = jwt.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond();
tokenService.blacklistToken(request.getRefreshToken(), ttl);
return ResponseEntity.ok(new JwtResponse(newAccessToken, newRefreshToken, "Bearer",
jwt.getExpiresAt().getEpochSecond()));
} catch (JwtException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}
6. 滑动过期 Filter
@Component
public class SlidingExpirationFilter extends OncePerRequestFilter {
@Value("${jwt.sliding-threshold-seconds:300}")
private long slidingThresholdSeconds;
@Autowired
private JwtDecoder jwtDecoder;
@Autowired
private TokenService tokenService;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
if (tokenService.isTokenBlacklisted(token)) {
throw new JwtException("Blacklisted token");
}
Jwt jwt = jwtDecoder.decode(token);
long expiresIn = jwt.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond();
if (expiresIn < slidingThresholdSeconds) {
UserDetails userDetails = userDetailsService.loadUserByUsername(jwt.getSubject());
String newToken = tokenService.generateAccessToken(userDetails);
response.setHeader("X-Renewed-Token", newToken);
}
} catch (JwtException ignored) {}
}
filterChain.doFilter(request, response);
}
}
7. 静默续期(前端)
function checkTokenExpiration() {
const token = getTokenFromStorage();
if (token && isTokenExpiringSoon(token)) {
renewTokenSilently();
}
}
function renewTokenSilently() {
const refreshToken = getRefreshTokenFromStorage();
fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
headers: { 'Content-Type': 'application/json' }
})
.then(res => res.json())
.then(data => storeNewTokens(data.accessToken, data.refreshToken))
.catch(() => redirectToLogin());
}
setInterval(checkTokenExpiration, 60000);
8. 配置文件示例
# Token 过期时间(毫秒)
jwt.expiration=900000 # 15分钟
jwt.refresh-expiration=2592000000 # 30天
jwt.sliding-threshold-seconds=300 # 滑动续期阈值(秒)
spring.redis.host=127.0.0.1
spring.redis.port=6379
9. 最佳实践建议
- Access Token 短期有效,Refresh Token 长期有效(短期:5
15分钟,长期:730天) - Refresh Token 单次使用,刷新后立即作废旧的
- 引入黑名单机制,支持即时下线和注销
- 滑动过期阈值可配置,减少不必要的刷新
- 跨端校验 Refresh Token,绑定 client_id、device_id、ip
- 多节点部署共享 Redis,保持一致性
这样整合后,你的项目既有 标准 OAuth2 刷新令牌方案,又有 滑动过期 和 静默续期 作为补充,并且具备 黑名单安全机制,可以直接落地到生产。