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实战内容。

相关推荐
Rust研习社2 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒2 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro3 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax4 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH4 小时前
Koa和Express的区别
后端
MariaH4 小时前
Koa框架的使用
后端
luckdewei5 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某6 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy6 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom6 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github