03-Spring-Security-JWT认证

Spring Security 6 + JWT 实现无状态认证:从原理到实战

一、前言

1.1 Session vs JWT 对比

在Web应用认证方案的选择上,Session和JWT是两种主流方案,它们各有优劣:

对比维度 Session 方案 JWT 方案
存储位置 服务端存储(Redis/内存) 客户端存储(LocalStorage/Cookie)
服务端状态 有状态(Stateful) 无状态(Stateless)
扩展性 需要共享Session,扩展复杂 天然支持分布式,扩展简单
性能 需要查询Redis/数据库 本地验证签名,性能更高
安全性 可以主动销毁Session Token一旦签发无法撤销(除非黑名单)
跨域支持 需要额外配置 天然支持跨域
适用场景 传统Web应用 分布式系统、微服务、移动端

AI面试助手选择JWT的原因

  1. 前后端分离架构,需要支持跨域
  2. 未来可能扩展为微服务架构
  3. 需要支持多端登录(Web、移动端)
  4. 无状态设计便于水平扩展

1.2 JWT 核心概念

复制代码
JWT (JSON Web Token) 结构:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Header (头部).Payload (载荷).Signature (签名)

示例:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMSIsImlhdCI6MTcwNDA2MDgwMCwiZXhwIjoxNzA0MDY0NDAwfQ.xxxxxx

1. Header - 声明类型和签名算法
{
  "alg": "HS256",
  "typ": "JWT"
}

2. Payload - 携带的用户信息(Claims)
{
  "sub": "user1",           // 主题(用户ID)
  "iat": 1704060800,        // 签发时间
  "exp": 1704064400,        // 过期时间
  "role": "PREMIUM",        // 用户角色
  "plan": "MONTHLY"         // 套餐类型
}

3. Signature - 签名,防止篡改
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

二、Spring Security 6 配置详解

2.1 SecurityFilterChain 配置

Spring Security 6 采用链式配置,相比旧版本更加简洁直观。

java 复制代码
/**
 * Spring Security 6 核心配置类
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)  // 启用方法级权限注解
public class SecurityConfig {
    
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    
    @Autowired
    private JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
    /**
     * 安全过滤器链配置
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 禁用CSRF(前后端分离不需要)
            .csrf(csrf -> csrf.disable())
            
            // 配置CORS
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            
            // 配置会话管理 - 无状态
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            
            // 配置异常处理
            .exceptionHandling(exception -> 
                exception
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)  // 认证失败处理
                    .accessDeniedHandler(jwtAccessDeniedHandler)            // 权限不足处理
            )
            
            // 配置授权规则
            .authorizeHttpRequests(auth -> auth
                // 公开接口 - 允许匿名访问
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                
                // Swagger/OpenAPI 文档
                .requestMatchers("/swagger-ui/**").permitAll()
                .requestMatchers("/v3/api-docs/**").permitAll()
                
                // 静态资源
                .requestMatchers("/static/**").permitAll()
                
                // 付费功能 - 需要PREMIUM或ENTERPRISE角色
                .requestMatchers("/api/interview/voice/**").hasAnyRole("PREMIUM", "ENTERPRISE")
                .requestMatchers("/api/resume/optimize/**").hasAnyRole("PREMIUM", "ENTERPRISE")
                
                // 企业功能 - 仅ENTERPRISE角色
                .requestMatchers("/api/enterprise/**").hasRole("ENTERPRISE")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                
                // 其他接口需要认证
                .anyRequest().authenticated()
            )
            
            // 添加JWT过滤器
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
    
    /**
     * CORS配置源
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(
            "http://localhost:5173",      // 开发环境
            "http://localhost:3000",      // 备选端口
            "https://ai-interview.example.com"  // 生产环境
        ));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList(
            "Authorization",
            "Content-Type",
            "X-Requested-With",
            "Accept",
            "Origin"
        ));
        configuration.setExposedHeaders(Arrays.asList("Authorization", "X-Request-Id"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    
    /**
     * 密码编码器 - BCrypt
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    /**
     * 认证管理器
     */
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

2.2 认证流程架构图

复制代码
Spring Security 6 + JWT 认证流程:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. 登录请求
   POST /api/auth/login
   ↓
2. AuthController.login()
   ↓
3. AuthenticationManager.authenticate()
   ↓
4. DaoAuthenticationProvider
   - UserDetailsService.loadUserByUsername()
   - PasswordEncoder.matches()
   ↓
5. 认证成功 → JwtUtils.generateToken()
   ↓
6. 返回 JWT Token

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. 业务请求(携带Token)
   GET /api/interview/start
   Header: Authorization: Bearer <token>
   ↓
2. JwtAuthenticationFilter
   - 从Header提取Token
   - JwtUtils.validateToken()
   - 解析用户信息
   ↓
3. 创建 Authentication 对象
   - UsernamePasswordAuthenticationToken
   - 设置用户详情和权限
   ↓
4. SecurityContextHolder
   - 存储认证信息
   ↓
5. 到达Controller
   - @AuthenticationPrincipal 获取当前用户
   ↓
6. 执行业务逻辑

2.3 权限注解使用

java 复制代码
/**
 * 面试控制器 - 方法级权限控制示例
 */
@RestController
@RequestMapping("/api/interview")
public class InterviewController {
    
    /**
     * 基础AI面试 - 所有认证用户可用
     */
    @PostMapping("/text")
    @PreAuthorize("isAuthenticated()")
    public Result<InterviewResponse> textInterview(
            @RequestBody InterviewRequest request,
            @AuthenticationPrincipal UserDetails userDetails) {
        // 执行文本面试...
        return Result.success(response);
    }
    
    /**
     * 语音面试 - 仅付费用户可用
     */
    @PostMapping("/voice")
    @PreAuthorize("hasAnyRole('PREMIUM', 'ENTERPRISE')")
    public Result<VoiceInterviewResponse> voiceInterview(
            @RequestBody VoiceInterviewRequest request) {
        // 执行语音面试...
        return Result.success(response);
    }
    
    /**
     * 批量面试 - 仅企业用户可用
     */
    @PostMapping("/batch")
    @PreAuthorize("hasRole('ENTERPRISE')")
    public Result<BatchInterviewResponse> batchInterview(
            @RequestBody BatchInterviewRequest request) {
        // 执行批量面试...
        return Result.success(response);
    }
    
    /**
     * 获取面试记录 - 只能查看自己的记录
     */
    @GetMapping("/history/{userId}")
    @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
    public Result<List<InterviewHistory>> getHistory(@PathVariable Long userId) {
        // 查询面试记录...
        return Result.success(history);
    }
    
    /**
     * 管理员查看所有面试 - 仅管理员可用
     */
    @GetMapping("/admin/all")
    @PreAuthorize("hasRole('ADMIN')")
    public Result<PageResult<Interview>> getAllInterviews(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        // 查询所有面试记录...
        return Result.success(result);
    }
}

三、JWT 工具类实现

3.1 JwtUtils 完整实现

java 复制代码
/**
 * JWT 工具类
 * 负责Token的生成、解析、验证
 */
@Component
@Slf4j
public class JwtUtils {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.expiration:86400000}")  // 默认24小时
    private long jwtExpiration;
    
    @Value("${jwt.refresh-expiration:604800000}")  // 默认7天
    private long refreshExpiration;
    
    private SecretKey key;
    
    @PostConstruct
    public void init() {
        // 使用HS512算法生成密钥
        this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
    }
    
    /**
     * 生成访问Token
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("type", "ACCESS");
        
        // 提取用户角色
        List<String> roles = userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .map(role -> role.replace("ROLE_", ""))
            .collect(Collectors.toList());
        claims.put("roles", roles);
        
        return buildToken(claims, userDetails.getUsername(), jwtExpiration);
    }
    
    /**
     * 生成刷新Token
     */
    public String generateRefreshToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("type", "REFRESH");
        return buildToken(claims, userDetails.getUsername(), refreshExpiration);
    }
    
    /**
     * 构建Token
     */
    private String buildToken(Map<String, Object> extraClaims, 
                             String subject, 
                             long expiration) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);
        
        return Jwts.builder()
            .claims(extraClaims)
            .subject(subject)
            .issuedAt(now)
            .expiration(expiryDate)
            .id(UUID.randomUUID().toString())  // JWT ID,用于Token黑名单
            .signWith(key, Jwts.SIG.HS512)
            .compact();
    }
    
    /**
     * 从Token中提取用户名
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
    
    /**
     * 从Token中提取过期时间
     */
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
    
    /**
     * 提取指定Claim
     */
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }
    
    /**
     * 提取所有Claims
     */
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
    
    /**
     * 验证Token是否有效
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            final String username = extractUsername(token);
            return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("Invalid JWT token: {}", e.getMessage());
            return false;
        }
    }
    
    /**
     * 检查Token是否过期
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
    
    /**
     * 获取Token剩余有效时间(毫秒)
     */
    public long getExpirationTime(String token) {
        Date expiration = extractExpiration(token);
        return expiration.getTime() - System.currentTimeMillis();
    }
    
    /**
     * 获取Token ID
     */
    public String getTokenId(String token) {
        return extractClaim(token, Claims::getId);
    }
    
    /**
     * 获取Token类型
     */
    public String getTokenType(String token) {
        return extractClaim(token, claims -> claims.get("type", String.class));
    }
    
    /**
     * 验证是否为刷新Token
     */
    public boolean isRefreshToken(String token) {
        return "REFRESH".equals(getTokenType(token));
    }
    
    /**
     * 刷新访问Token
     */
    public String refreshAccessToken(String refreshToken, UserDetails userDetails) {
        if (!isRefreshToken(refreshToken)) {
            throw new BusinessException(ResultCode.TOKEN_INVALID, "Invalid refresh token");
        }
        
        if (!isTokenValid(refreshToken, userDetails)) {
            throw new BusinessException(ResultCode.TOKEN_EXPIRED, "Refresh token expired");
        }
        
        return generateToken(userDetails);
    }
}

3.2 JWT 配置属性

yaml 复制代码
# application.yml
jwt:
  secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-at-least-32-characters-long}
  expiration: 86400000        # 访问Token有效期:24小时(毫秒)
  refresh-expiration: 604800000  # 刷新Token有效期:7天(毫秒)
  header: Authorization
  prefix: "Bearer "

四、认证流程实现

4.1 登录接口实现

java 复制代码
/**
 * 认证控制器
 */
@RestController
@RequestMapping("/api/auth")
@Validated
@Slf4j
public class AuthController {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private TokenBlacklistService tokenBlacklistService;
    
    /**
     * 用户登录
     */
    @PostMapping("/login")
    public Result<LoginResponse> login(@RequestBody @Valid LoginRequest request) {
        try {
            // 1. 创建认证Token
            UsernamePasswordAuthenticationToken authToken = 
                new UsernamePasswordAuthenticationToken(
                    request.getUsername(), 
                    request.getPassword()
                );
            
            // 2. 执行认证
            Authentication authentication = authenticationManager.authenticate(authToken);
            
            // 3. 获取认证成功的用户信息
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            
            // 4. 生成JWT Token
            String accessToken = jwtUtils.generateToken(userDetails);
            String refreshToken = jwtUtils.generateRefreshToken(userDetails);
            
            // 5. 更新用户最后登录时间
            userService.updateLastLoginTime(request.getUsername());
            
            // 6. 构建响应
            LoginResponse response = LoginResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .tokenType("Bearer")
                .expiresIn(jwtUtils.getExpirationTime(accessToken) / 1000)
                .user(UserResponse.from(userDetails))
                .build();
            
            log.info("User logged in successfully: {}", request.getUsername());
            return Result.success("登录成功", response);
            
        } catch (BadCredentialsException e) {
            log.warn("Failed login attempt for user: {}", request.getUsername());
            return Result.error(ResultCode.PASSWORD_ERROR);
        } catch (LockedException e) {
            return Result.error(ResultCode.ERROR.getCode(), "账户已被锁定");
        } catch (DisabledException e) {
            return Result.error(ResultCode.ERROR.getCode(), "账户已被禁用");
        }
    }
    
    /**
     * 用户注册
     */
    @PostMapping("/register")
    public Result<UserResponse> register(@RequestBody @Valid RegisterRequest request) {
        // 检查用户名是否已存在
        if (userService.existsByUsername(request.getUsername())) {
            return Result.error(ResultCode.USER_ALREADY_EXISTS);
        }
        
        // 检查邮箱是否已存在
        if (userService.existsByEmail(request.getEmail())) {
            return Result.error(ResultCode.ERROR.getCode(), "邮箱已被注册");
        }
        
        // 创建用户
        User user = userService.createUser(request);
        
        log.info("New user registered: {}", request.getUsername());
        return Result.success("注册成功", UserResponse.from(user));
    }
    
    /**
     * 刷新Token
     */
    @PostMapping("/refresh")
    public Result<TokenRefreshResponse> refreshToken(
            @RequestBody @Valid TokenRefreshRequest request) {
        String refreshToken = request.getRefreshToken();
        
        try {
            // 提取用户名
            String username = jwtUtils.extractUsername(refreshToken);
            
            // 加载用户信息
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            
            // 刷新访问Token
            String newAccessToken = jwtUtils.refreshAccessToken(refreshToken, userDetails);
            
            TokenRefreshResponse response = TokenRefreshResponse.builder()
                .accessToken(newAccessToken)
                .tokenType("Bearer")
                .expiresIn(jwtUtils.getExpirationTime(newAccessToken) / 1000)
                .build();
            
            return Result.success("Token刷新成功", response);
            
        } catch (BusinessException e) {
            return Result.error(e.getCode(), e.getMessage());
        }
    }
    
    /**
     * 用户登出
     */
    @PostMapping("/logout")
    @PreAuthorize("isAuthenticated()")
    public Result<Void> logout(
            @RequestHeader("Authorization") String authHeader,
            @AuthenticationPrincipal UserDetails userDetails) {
        
        // 提取Token
        String token = authHeader.replace("Bearer ", "");
        
        // 将Token加入黑名单
        long expirationTime = jwtUtils.getExpirationTime(token);
        tokenBlacklistService.addToBlacklist(
            jwtUtils.getTokenId(token), 
            expirationTime
        );
        
        log.info("User logged out: {}", userDetails.getUsername());
        return Result.success("登出成功", null);
    }
    
    /**
     * 获取当前用户信息
     */
    @GetMapping("/me")
    @PreAuthorize("isAuthenticated()")
    public Result<UserResponse> getCurrentUser(
            @AuthenticationPrincipal UserDetails userDetails) {
        User user = userService.findByUsername(userDetails.getUsername());
        return Result.success(UserResponse.from(user));
    }
}

4.2 用户详情服务实现

java 复制代码
/**
 * 用户详情服务实现
 */
@Service
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从数据库查询用户
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "User not found: " + username
            ));
        
        // 检查用户状态
        if (!user.isEnabled()) {
            throw new DisabledException("账户已被禁用");
        }
        if (user.isLocked()) {
            throw new LockedException("账户已被锁定");
        }
        
        // 构建权限列表
        List<GrantedAuthority> authorities = new ArrayList<>();
        
        // 添加角色权限
        authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
        
        // 添加套餐权限
        authorities.add(new SimpleGrantedAuthority("PLAN_" + user.getPlan().name()));
        
        // 添加额外权限
        user.getPermissions().forEach(permission -> 
            authorities.add(new SimpleGrantedAuthority(permission))
        );
        
        return org.springframework.security.core.userdetails.User.builder()
            .username(user.getUsername())
            .password(user.getPassword())
            .authorities(authorities)
            .accountExpired(false)
            .accountLocked(user.isLocked())
            .credentialsExpired(false)
            .disabled(!user.isEnabled())
            .build();
    }
}

4.3 Token 黑名单服务

java 复制代码
/**
 * Token 黑名单服务
 * 用于处理用户登出后的Token失效
 */
@Service
@Slf4j
public class TokenBlacklistService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
    
    /**
     * 将Token加入黑名单
     */
    public void addToBlacklist(String tokenId, long expirationTime) {
        String key = BLACKLIST_PREFIX + tokenId;
        long ttl = expirationTime / 1000;  // 转换为秒
        
        redisTemplate.opsForValue().set(key, "1", ttl, TimeUnit.SECONDS);
        log.debug("Token added to blacklist: {}, TTL: {}s", tokenId, ttl);
    }
    
    /**
     * 检查Token是否在黑名单中
     */
    public boolean isBlacklisted(String tokenId) {
        String key = BLACKLIST_PREFIX + tokenId;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
    
    /**
     * 批量清理过期黑名单(定时任务使用)
     */
    public void cleanupExpiredTokens() {
        // Redis会自动过期,此方法用于记录日志
        log.debug("Cleanup expired blacklist tokens");
    }
}

五、JWT 认证过滤器

5.1 JwtAuthenticationFilter 实现

java 复制代码
/**
 * JWT 认证过滤器
 * 每个请求都会经过此过滤器,验证JWT Token的有效性
 */
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Autowired
    private TokenBlacklistService tokenBlacklistService;
    
    /**
     * 不需要验证Token的路径
     */
    private static final List<String> SKIP_PATHS = Arrays.asList(
        "/api/auth/login",
        "/api/auth/register",
        "/api/auth/refresh",
        "/api/public/",
        "/actuator/health",
        "/swagger-ui/",
        "/v3/api-docs/"
    );
    
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        
        // 1. 检查是否需要跳过验证
        if (shouldSkip(request)) {
            filterChain.doFilter(request, response);
            return;
        }
        
        // 2. 从请求中提取Token
        final String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        
        final String jwt = authHeader.substring(7);
        
        try {
            // 3. 提取用户名
            final String username = jwtUtils.extractUsername(jwt);
            
            // 4. 检查当前是否已有认证信息
            if (username != null && 
                SecurityContextHolder.getContext().getAuthentication() == null) {
                
                // 5. 检查Token是否在黑名单中
                String tokenId = jwtUtils.getTokenId(jwt);
                if (tokenBlacklistService.isBlacklisted(tokenId)) {
                    log.warn("Token is blacklisted: {}", tokenId);
                    sendErrorResponse(response, HttpStatus.UNAUTHORIZED, 
                        "Token已失效,请重新登录");
                    return;
                }
                
                // 6. 加载用户详情
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                
                // 7. 验证Token有效性
                if (jwtUtils.isTokenValid(jwt, userDetails)) {
                    
                    // 8. 创建认证对象
                    UsernamePasswordAuthenticationToken authToken = 
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    
                    // 9. 设置请求详情
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                    );
                    
                    // 10. 将认证信息存入SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                    
                    log.debug("Authenticated user: {}, URI: {}", 
                        username, request.getRequestURI());
                }
            }
        } catch (ExpiredJwtException e) {
            log.warn("JWT token is expired: {}", e.getMessage());
            sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "Token已过期");
            return;
        } catch (JwtException e) {
            log.warn("JWT token is invalid: {}", e.getMessage());
            sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "Token无效");
            return;
        }
        
        filterChain.doFilter(request, response);
    }
    
    /**
     * 检查是否需要跳过验证
     */
    private boolean shouldSkip(HttpServletRequest request) {
        String path = request.getRequestURI();
        return SKIP_PATHS.stream().anyMatch(path::startsWith);
    }
    
    /**
     * 发送错误响应
     */
    private void sendErrorResponse(HttpServletResponse response, 
                                   HttpStatus status, 
                                   String message) throws IOException {
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        
        Result<Void> result = Result.error(status.value(), message);
        ObjectMapper mapper = new ObjectMapper();
        response.getWriter().write(mapper.writeValueAsString(result));
    }
}

5.2 认证异常处理器

java 复制代码
/**
 * JWT 认证入口点
 * 处理认证失败的情况
 */
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    @Override
    public void commence(HttpServletRequest request,
                        HttpServletResponse response,
                        AuthenticationException authException) throws IOException {
        
        log.warn("Unauthorized access attempt: {}", request.getRequestURI());
        
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        
        Result<Void> result = Result.error(
            ResultCode.UNAUTHORIZED.getCode(), 
            "未授权,请先登录"
        );
        
        ObjectMapper mapper = new ObjectMapper();
        response.getWriter().write(mapper.writeValueAsString(result));
    }
}

/**
 * JWT 访问拒绝处理器
 * 处理权限不足的情况
 */
@Component
@Slf4j
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    
    @Override
    public void handle(HttpServletRequest request,
                      HttpServletResponse response,
                      AccessDeniedException accessDeniedException) throws IOException {
        
        log.warn("Access denied: {}, User: {}", 
            request.getRequestURI(),
            SecurityContextHolder.getContext().getAuthentication() != null 
                ? SecurityContextHolder.getContext().getAuthentication().getName() 
                : "anonymous"
        );
        
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        
        Result<Void> result = Result.error(
            ResultCode.FORBIDDEN.getCode(), 
            "权限不足,无法访问该资源"
        );
        
        ObjectMapper mapper = new ObjectMapper();
        response.getWriter().write(mapper.writeValueAsString(result));
    }
}

六、权限控制实战

6.1 角色与套餐设计

java 复制代码
/**
 * 用户角色枚举
 */
@Getter
@AllArgsConstructor
public enum UserRole {
    
    FREE("免费用户", "基础功能"),
    PREMIUM("付费用户", "高级功能"),
    ENTERPRISE("企业用户", "企业功能"),
    ADMIN("管理员", "系统管理");
    
    private final String displayName;
    private final String description;
}

/**
 * 用户套餐枚举
 */
@Getter
@AllArgsConstructor
public enum UserPlan {
    
    FREE(0, 10, 0),           // 免费:10次/月,0并发
    MONTHLY(100, 500, 2),     // 月付:500次/月,2并发
    YEARLY(800, 1000, 5),     // 年付:1000次/月,5并发
    ENTERPRISE(0, 10000, 20); // 企业:10000次/月,20并发
    
    private final int price;      // 价格(元)
    private final int quota;      // 月度额度
    private final int concurrent; // 并发限制
}

6.2 用户实体设计

java 复制代码
/**
 * 用户实体类
 */
@Entity
@Table(name = "users")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User extends BaseEntity {
    
    @Column(unique = true, nullable = false)
    private String username;
    
    @Column(nullable = false)
    private String password;
    
    @Column(unique = true, nullable = false)
    private String email;
    
    @Column(name = "phone_number")
    private String phoneNumber;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    @Builder.Default
    private UserRole role = UserRole.FREE;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    @Builder.Default
    private UserPlan plan = UserPlan.FREE;
    
    @Column(name = "last_login_time")
    private LocalDateTime lastLoginTime;
    
    @Column(name = "login_ip")
    private String loginIp;
    
    @Column(name = "is_enabled")
    @Builder.Default
    private boolean enabled = true;
    
    @Column(name = "is_locked")
    @Builder.Default
    private boolean locked = false;
    
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "permission")
    @Builder.Default
    private Set<String> permissions = new HashSet<>();
    
    /**
     * 获取剩余额度
     */
    public int getRemainingQuota() {
        // 从Redis或数据库查询本月已使用额度
        return plan.getQuota() - getUsedQuota();
    }
    
    /**
     * 检查是否有权限访问功能
     */
    public boolean hasPermission(String permission) {
        return permissions.contains(permission) || 
               role == UserRole.ADMIN ||
               (role == UserRole.ENTERPRISE && permission.startsWith("ENTERPRISE_")) ||
               (role == UserRole.PREMIUM && permission.startsWith("PREMIUM_"));
    }
}

6.3 权限检查切面

java 复制代码
/**
 * 权限检查切面
 * 用于检查用户套餐额度
 */
@Aspect
@Component
@Slf4j
public class PermissionAspect {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 检查AI调用额度
     */
    @Before("@annotation(checkQuota)")
    public void checkQuota(CheckQuota checkQuota) {
        // 获取当前用户
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new BusinessException(ResultCode.UNAUTHORIZED);
        }
        
        String username = authentication.getName();
        User user = userService.findByUsername(username);
        
        // 检查额度
        String quotaKey = "user:quota:" + user.getId() + ":" + LocalDate.now().getMonthValue();
        String usedStr = redisTemplate.opsForValue().get(quotaKey);
        int used = usedStr != null ? Integer.parseInt(usedStr) : 0;
        
        if (used >= user.getPlan().getQuota()) {
            throw new BusinessException(ResultCode.INSUFFICIENT_BALANCE, 
                "本月AI调用额度已用完,请升级套餐");
        }
        
        // 增加使用计数
        redisTemplate.opsForValue().increment(quotaKey);
        redisTemplate.expire(quotaKey, 31, TimeUnit.DAYS);
    }
    
    /**
     * 检查并发限制
     */
    @Before("@annotation(checkConcurrent)")
    public void checkConcurrent(CheckConcurrent checkConcurrent) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            return;
        }
        
        String username = authentication.getName();
        User user = userService.findByUsername(username);
        
        String concurrentKey = "user:concurrent:" + user.getId();
        Long current = redisTemplate.opsForValue().increment(concurrentKey);
        redisTemplate.expire(concurrentKey, 1, TimeUnit.HOURS);
        
        if (current != null && current > user.getPlan().getConcurrent()) {
            // 回滚计数
            redisTemplate.opsForValue().decrement(concurrentKey);
            throw new BusinessException(ResultCode.ERROR.getCode(), 
                "并发请求过多,请稍后再试");
        }
    }
}

/**
 * 额度检查注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckQuota {
}

/**
 * 并发检查注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckConcurrent {
}

七、完整代码展示

7.1 完整 SecurityConfig

java 复制代码
/**
 * Spring Security 6 完整配置
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    
    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                         JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                         JwtAccessDeniedHandler jwtAccessDeniedHandler) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(WHITE_LIST).permitAll()
                .requestMatchers("/api/interview/voice/**").hasAnyRole("PREMIUM", "ENTERPRISE")
                .requestMatchers("/api/enterprise/**").hasRole("ENTERPRISE")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
    
    // 白名单路径
    private static final String[] WHITE_LIST = {
        "/api/auth/**",
        "/api/public/**",
        "/actuator/health",
        "/swagger-ui/**",
        "/v3/api-docs/**",
        "/webjars/**"
    };
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(Arrays.asList(
            "http://localhost:5173",
            "https://ai-interview.example.com"
        ));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

7.2 完整 JwtUtils

java 复制代码
@Component
@Slf4j
public class JwtUtils {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.expiration:86400000}")
    private long jwtExpiration;
    
    private SecretKey key;
    
    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
    }
    
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        List<String> roles = userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .map(r -> r.replace("ROLE_", ""))
            .collect(Collectors.toList());
        claims.put("roles", roles);
        
        return Jwts.builder()
            .claims(claims)
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + jwtExpiration))
            .id(UUID.randomUUID().toString())
            .signWith(key, Jwts.SIG.HS512)
            .compact();
    }
    
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
    
    public <T> T extractClaim(String token, Function<Claims, T> resolver) {
        return resolver.apply(extractAllClaims(token));
    }
    
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
    
    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            String username = extractUsername(token);
            return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
        } catch (JwtException e) {
            return false;
        }
    }
    
    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }
    
    public String getTokenId(String token) {
        return extractClaim(token, Claims::getId);
    }
    
    public long getExpirationTime(String token) {
        return extractClaim(token, Claims::getExpiration).getTime() - System.currentTimeMillis();
    }
}

7.3 完整 JwtAuthenticationFilter

java 复制代码
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtUtils jwtUtils;
    private final UserDetailsService userDetailsService;
    private final TokenBlacklistService tokenBlacklistService;
    
    public JwtAuthenticationFilter(JwtUtils jwtUtils,
                                   UserDetailsService userDetailsService,
                                   TokenBlacklistService tokenBlacklistService) {
        this.jwtUtils = jwtUtils;
        this.userDetailsService = userDetailsService;
        this.tokenBlacklistService = tokenBlacklistService;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        
        final String authHeader = request.getHeader("Authorization");
        
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }
        
        final String jwt = authHeader.substring(7);
        
        try {
            final String username = jwtUtils.extractUsername(jwt);
            
            if (username != null && 
                SecurityContextHolder.getContext().getAuthentication() == null) {
                
                // 检查黑名单
                if (tokenBlacklistService.isBlacklisted(jwtUtils.getTokenId(jwt))) {
                    sendError(response, HttpStatus.UNAUTHORIZED, "Token已失效");
                    return;
                }
                
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                if (jwtUtils.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken auth = 
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            }
        } catch (ExpiredJwtException e) {
            sendError(response, HttpStatus.UNAUTHORIZED, "Token已过期");
            return;
        } catch (JwtException e) {
            sendError(response, HttpStatus.UNAUTHORIZED, "Token无效");
            return;
        }
        
        chain.doFilter(request, response);
    }
    
    private void sendError(HttpServletResponse response, HttpStatus status, 
                          String message) throws IOException {
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        new ObjectMapper().writeValue(response.getWriter(), 
            Result.error(status.value(), message));
    }
}

八、常见问题与解决方案

8.1 Token 刷新策略

java 复制代码
/**
 * Token刷新策略
 */
@Service
public class TokenRefreshService {
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    /**
     * 双Token刷新策略
     * - Access Token:短期有效(如15分钟)
     * - Refresh Token:长期有效(如7天)
     */
    public TokenPair refreshTokens(String refreshToken) {
        // 验证Refresh Token
        String username = jwtUtils.extractUsername(refreshToken);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        
        if (!jwtUtils.isTokenValid(refreshToken, userDetails)) {
            throw new BusinessException(ResultCode.TOKEN_EXPIRED);
        }
        
        // 生成新的Token对
        String newAccessToken = jwtUtils.generateToken(userDetails);
        String newRefreshToken = jwtUtils.generateRefreshToken(userDetails);
        
        return new TokenPair(newAccessToken, newRefreshToken);
    }
    
    /**
     * 滑动窗口刷新策略
     * - 当Access Token即将过期时自动刷新
     */
    public String slidingRefresh(String accessToken) {
        long remainingTime = jwtUtils.getExpirationTime(accessToken);
        
        // 如果剩余时间小于5分钟,自动刷新
        if (remainingTime < 5 * 60 * 1000) {
            String username = jwtUtils.extractUsername(accessToken);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            return jwtUtils.generateToken(userDetails);
        }
        
        return accessToken;
    }
}

8.2 Token 安全问题

问题 解决方案
Token被盗 使用HTTPS、设置合理过期时间、敏感操作二次验证
Token无法撤销 使用Redis黑名单、缩短Token有效期
密钥泄露 定期轮换密钥、使用环境变量存储、密钥分割存储
CSRF攻击 前后端分离天然免疫、添加CSRF Token(可选)

8.3 性能优化建议

java 复制代码
/**
 * JWT 缓存优化
 * 避免重复解析Token
 */
@Component
public class JwtCacheService {
    
    private final Cache<String, Claims> tokenCache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    public Claims getClaims(String token) {
        return tokenCache.get(token, jwtUtils::extractAllClaims);
    }
}

九、总结

9.1 本文要点

本文详细介绍了Spring Security 6 + JWT的完整实现方案:

  1. SecurityConfig:配置安全过滤器链、CORS、授权规则
  2. JwtUtils:Token生成、解析、验证工具类
  3. JwtAuthenticationFilter:请求级Token验证过滤器
  4. 认证流程:登录、刷新、登出完整实现
  5. 权限控制:角色、套餐、方法级权限注解
  6. Token黑名单:解决登出后Token失效问题

9.2 安全建议

  • 使用强密钥(至少256位)
  • 设置合理的Token过期时间
  • 敏感操作使用HTTPS
  • 定期轮换密钥
  • 监控异常登录行为

9.3 下篇预告

下一篇文章:《Spring AI多模型架构:OpenAI/DeepSeek/MiniMax一键切换实战》

将重点讲解:

  • Spring AI核心概念与API设计
  • 多模型配置管理
  • 动态模型切换机制
  • Token计费与配额管理

敬请期待!


参考资料

  1. Spring Security 6 Reference
  2. JWT.io
  3. RFC 7519 - JSON Web Token
  4. Spring Security OAuth2
  5. OWASP Authentication Cheat Sheet

下篇预告:《Spring AI多模型架构:OpenAI/DeepSeek/MiniMax一键切换实战》

如果本文对你有帮助,欢迎点赞、收藏、评论交流!关注专栏获取更多Spring Security实战内容。

相关推荐
未秃头的程序猿9 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·后端·ai编程
程序员buddha9 小时前
传统 Spring 框架,XML 配置 Bean 的方式
xml·java·spring
希望永不加班9 小时前
SpringBoot 消费者并发控制:线程池配置
java·spring boot·后端·spring
苏三说技术9 小时前
别再用HTTP调用大模型了,大厂都在用Spring AI?
后端
code_Bo9 小时前
apple gpt 礼品卡订阅失败解决方案
前端·人工智能·后端
MateCloud微服务9 小时前
从 Karpathy 加入 Anthropic 到 Claude Agent 化:MateClaw 为什么要做企业级 Agent Runtime
java·java agent·mateclaw·mateclaw agent·mc runtime·mc harness·mateclaw open
努力攻坚操作系统9 小时前
重新理解 RESTful:从理论约束到工程实践
后端·restful
奔跑的Ma~9 小时前
企业级 Codex 部署与团队协作方案
后端·python·ai编程·codex·ai学习
Yolanda949 小时前
【编程学习】复盘经典 VB OOP 示例:推翻旧认知,重学面向对象
java·面向对象