优秀的编程知识分享平台

网站首页 > 技术文章 正文

Spring Boot 3.x 中的 Token 续期方案实现

nanyue 2025-10-02 04:41:46 技术文章 1 ℃

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. 最佳实践建议

  1. Access Token 短期有效,Refresh Token 长期有效(短期:515分钟,长期:730天)
  2. Refresh Token 单次使用,刷新后立即作废旧的
  3. 引入黑名单机制,支持即时下线和注销
  4. 滑动过期阈值可配置,减少不必要的刷新
  5. 跨端校验 Refresh Token,绑定 client_id、device_id、ip
  6. 多节点部署共享 Redis,保持一致性

这样整合后,你的项目既有 标准 OAuth2 刷新令牌方案,又有 滑动过期静默续期 作为补充,并且具备 黑名单安全机制,可以直接落地到生产。

Tags:

最近发表
标签列表