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的原因:
- 前后端分离架构,需要支持跨域
- 未来可能扩展为微服务架构
- 需要支持多端登录(Web、移动端)
- 无状态设计便于水平扩展
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的完整实现方案:
- SecurityConfig:配置安全过滤器链、CORS、授权规则
- JwtUtils:Token生成、解析、验证工具类
- JwtAuthenticationFilter:请求级Token验证过滤器
- 认证流程:登录、刷新、登出完整实现
- 权限控制:角色、套餐、方法级权限注解
- Token黑名单:解决登出后Token失效问题
9.2 安全建议
- 使用强密钥(至少256位)
- 设置合理的Token过期时间
- 敏感操作使用HTTPS
- 定期轮换密钥
- 监控异常登录行为
9.3 下篇预告
下一篇文章:《Spring AI多模型架构:OpenAI/DeepSeek/MiniMax一键切换实战》
将重点讲解:
- Spring AI核心概念与API设计
- 多模型配置管理
- 动态模型切换机制
- Token计费与配额管理
敬请期待!
参考资料
- Spring Security 6 Reference
- JWT.io
- RFC 7519 - JSON Web Token
- Spring Security OAuth2
- OWASP Authentication Cheat Sheet
下篇预告:《Spring AI多模型架构:OpenAI/DeepSeek/MiniMax一键切换实战》
如果本文对你有帮助,欢迎点赞、收藏、评论交流!关注专栏获取更多Spring Security实战内容。