Spring Security 实现自定义刷新令牌机制

1. 自定义令牌实体类
// 令牌响应DTO
@Data
@AllArgsConstructor
public class TokenResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";
    private Long expiresIn; // 秒
    private Long refreshExpiresIn;
}

// 刷新请求DTO
@Data
public class RefreshRequest {
    @NotBlank
    private String refreshToken;
}

// 数据库刷新令牌实体
@Entity
@Table(name = "refresh_tokens")
@Data
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 512)
    private String tokenHash; // 存储哈希值
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
    
    @Column(nullable = false)
    private String clientInfo; // IP + User-Agent 的哈希
    
    @Column(nullable = false)
    private Instant expiresAt;
    
    @Column(nullable = false)
    private Instant createdAt;
    
    @Column(nullable = false)
    private boolean revoked = false;
    
    @Column
    private Instant revokedAt;
}
2. 自定义令牌服务
@Service
@Transactional
@Slf4j
public class JwtTokenService {
    
    @Value("${app.jwt.secret:defaultSecretKey}")
    private String jwtSecret;
    
    @Value("${app.jwt.access-token-expiration:900}") // 15分钟
    private Long accessTokenExpiration;
    
    @Value("${app.jwt.refresh-token-expiration:2592000}") // 30天
    private Long refreshTokenExpiration;
    
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;
    
    public JwtTokenService(RefreshTokenRepository refreshTokenRepository, 
                          UserRepository userRepository) {
        this.refreshTokenRepository = refreshTokenRepository;
        this.userRepository = userRepository;
    }
    
    // 生成访问令牌 (JWT)
    public String generateAccessToken(User user) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("username", user.getUsername());
        claims.put("roles", user.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(user.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration * 1000))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
    }
    
    // 生成刷新令牌 (不透明)
    public String generateRefreshToken() {
        return UUID.randomUUID().toString().replace("-", "") + 
               UUID.randomUUID().toString().replace("-", "");
    }
    
    // 创建令牌对
    public TokenResponse generateTokenPair(User user, HttpServletRequest request) {
        String accessToken = generateAccessToken(user);
        String refreshToken = generateRefreshToken();
        
        // 保存刷新令牌到数据库
        saveRefreshToken(refreshToken, user, request);
        
        return new TokenResponse(
            accessToken,
            refreshToken,
            accessTokenExpiration,
            refreshTokenExpiration
        );
    }
    
    private void saveRefreshToken(String refreshToken, User user, HttpServletRequest request) {
        String tokenHash = hashToken(refreshToken);
        String clientFingerprint = createClientFingerprint(request);
        
        RefreshToken token = new RefreshToken();
        token.setTokenHash(tokenHash);
        token.setUser(user);
        token.setClientInfo(clientFingerprint);
        token.setCreatedAt(Instant.now());
        token.setExpiresAt(Instant.now().plusSeconds(refreshTokenExpiration));
        token.setRevoked(false);
        
        refreshTokenRepository.save(token);
        
        // 清理过期的令牌
        cleanupExpiredTokens(user.getId());
    }
    
    // 刷新令牌
    public TokenResponse refreshToken(String refreshTokenValue, HttpServletRequest request) {
        // 1. 查找令牌
        String tokenHash = hashToken(refreshTokenValue);
        RefreshToken refreshToken = refreshTokenRepository.findByTokenHash(tokenHash)
                .orElseThrow(() -> new SecurityException("无效的刷新令牌"));
        
        // 2. 验证令牌状态
        validateRefreshToken(refreshToken, request);
        
        // 3. 令牌轮换 - 撤销旧令牌
        refreshToken.setRevoked(true);
        refreshToken.setRevokedAt(Instant.now());
        refreshTokenRepository.save(refreshToken);
        
        // 4. 生成新令牌对
        return generateTokenPair(refreshToken.getUser(), request);
    }
    
    private void validateRefreshToken(RefreshToken refreshToken, HttpServletRequest request) {
        // 检查是否已撤销
        if (refreshToken.isRevoked()) {
            log.warn("尝试使用已撤销的刷新令牌,用户: {}", refreshToken.getUser().getUsername());
            throw new SecurityException("令牌已被撤销");
        }
        
        // 检查是否过期
        if (Instant.now().isAfter(refreshToken.getExpiresAt())) {
            // 自动清理过期令牌
            refreshTokenRepository.delete(refreshToken);
            throw new SecurityException("刷新令牌已过期");
        }
        
        // 客户端指纹验证 (可选,根据安全要求)
        String currentFingerprint = createClientFingerprint(request);
        if (!currentFingerprint.equals(refreshToken.getClientInfo())) {
            log.warn("客户端指纹不匹配,可能为令牌盗用,用户: {}", refreshToken.getUser().getUsername());
            // 根据安全策略决定:可以记录日志、撤销所有令牌或要求重新登录
            handleSuspiciousActivity(refreshToken.getUser());
            throw new SecurityException("安全验证失败");
        }
    }
    
    private void handleSuspiciousActivity(User user) {
        // 撤销用户的所有刷新令牌
        refreshTokenRepository.revokeAllUserTokens(user.getId());
        log.warn("检测到可疑活动,已撤销用户 {} 的所有令牌", user.getUsername());
        
        // 可以在这里发送安全通知邮件
    }
    
    private String hashToken(String token) {
        return DigestUtils.sha256Hex(token);
    }
    
    private String createClientFingerprint(HttpServletRequest request) {
        String ip = getClientIp(request);
        String userAgent = request.getHeader("User-Agent") != null ? 
                request.getHeader("User-Agent") : "";
        
        // 使用IP段而不是完整IP,避免同一网络下IP变化
        String ipSegment = ip.contains(".") ? 
                ip.substring(0, ip.lastIndexOf('.')) : ip;
        
        return DigestUtils.sha256Hex(ipSegment + userAgent);
    }
    
    private String getClientIp(HttpServletRequest request) {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader != null) {
            return xfHeader.split(",")[0];
        }
        return request.getRemoteAddr();
    }
    
    private void cleanupExpiredTokens(Long userId) {
        refreshTokenRepository.deleteExpiredTokens(Instant.now());
    }
    
    // 验证JWT访问令牌
    public boolean validateAccessToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    
    // 从JWT中提取用户名
    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();
        return claims.getSubject();
    }
    
    // 主动撤销令牌
    public void revokeRefreshToken(String refreshToken) {
        String tokenHash = hashToken(refreshToken);
        refreshTokenRepository.findByTokenHash(tokenHash)
                .ifPresent(token -> {
                    token.setRevoked(true);
                    token.setRevokedAt(Instant.now());
                    refreshTokenRepository.save(token);
                });
    }
    
    // 撤销用户的所有令牌
    public void revokeAllUserTokens(Long userId) {
        refreshTokenRepository.revokeAllUserTokens(userId);
    }
}
3. 自定义认证过滤器
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtTokenService tokenService;
    private final UserDetailsService userDetailsService;
    
    public JwtAuthenticationFilter(JwtTokenService tokenService, 
                                  UserDetailsService userDetailsService) {
        this.tokenService = tokenService;
        this.userDetailsService = userDetailsService;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                  HttpServletResponse response, 
                                  FilterChain filterChain) throws ServletException, IOException {
        
        try {
            String jwt = getJwtFromRequest(request);
            
            if (jwt != null && tokenService.validateAccessToken(jwt)) {
                String username = tokenService.getUsernameFromToken(jwt);
                
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("无法设置用户认证", e);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
4. 控制器端点
@RestController
@RequestMapping("/api/auth")
@Validated
public class AuthController {
    
    private final JwtTokenService tokenService;
    private final AuthenticationManager authenticationManager;
    
    public AuthController(JwtTokenService tokenService, 
                         AuthenticationManager authenticationManager) {
        this.tokenService = tokenService;
        this.authenticationManager = authenticationManager;
    }
    
    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest loginRequest, 
                                              HttpServletRequest request) {
        // 认证用户
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                loginRequest.getUsername(), 
                loginRequest.getPassword()
            )
        );
        
        SecurityContextHolder.getContext().setAuthentication(authentication);
        
        // 生成令牌对
        User user = (User) authentication.getPrincipal();
        TokenResponse tokenResponse = tokenService.generateTokenPair(user, request);
        
        return ResponseEntity.ok(tokenResponse);
    }
    
    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshToken(@Valid @RequestBody RefreshRequest refreshRequest,
                                                     HttpServletRequest request) {
        try {
            TokenResponse tokenResponse = tokenService.refreshToken(refreshRequest.getRefreshToken(), request);
            return ResponseEntity.ok(tokenResponse);
        } catch (SecurityException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }
    
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@Valid @RequestBody RefreshRequest refreshRequest) {
        tokenService.revokeRefreshToken(refreshRequest.getRefreshToken());
        return ResponseEntity.ok().build();
    }
    
    @PostMapping("/logout-all")
    public ResponseEntity<Void> logoutAll(Authentication authentication) {
        User user = (User) authentication.getPrincipal();
        tokenService.revokeAllUserTokens(user.getId());
        return ResponseEntity.ok().build();
    }
}
5. Spring Security 配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    private final JwtTokenService tokenService;
    private final UserDetailsService userDetailsService;
    
    public SecurityConfig(JwtTokenService tokenService, UserDetailsService userDetailsService) {
        this.tokenService = tokenService;
        this.userDetailsService = userDetailsService;
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/login", "/api/auth/refresh", "/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
            );
        
        return http.build();
    }
    
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(tokenService, userDetailsService);
    }
    
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
6. Repository 接口
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    
    Optional<RefreshToken> findByTokenHash(String tokenHash);
    
    List<RefreshToken> findByUserIdAndRevokedFalse(Long userId);
    
    @Modifying
    @Query("UPDATE RefreshToken rt SET rt.revoked = true, rt.revokedAt = :revokedAt WHERE rt.user.id = :userId")
    void revokeAllUserTokens(@Param("userId") Long userId);
    
    @Modifying
    @Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :now")
    void deleteExpiredTokens(@Param("now") Instant now);
    
    @Query("SELECT COUNT(rt) FROM RefreshToken rt WHERE rt.user.id = :userId AND rt.revoked = false AND rt.createdAt > :since")
    Long countActiveTokensSince(@Param("userId") Long userId, @Param("since") Instant since);
}

更多推荐