Spring Security JWT认证:OncePerRequestFilter与SecurityContextHolder
Spring Security JWT认证:OncePerRequestFilter与SecurityContextHolder
适读人群:需要实现JWT无状态认证的Spring Boot开发者 | 阅读时长:约20分钟
开篇故事
刚开始写 Spring Security 的时候,我被那一堆 WebSecurityConfigurerAdapter、UsernamePasswordAuthenticationFilter、AuthenticationManager 搞得头大。配置写了一大堆,不知道哪些是必须的,哪些是可选的。
后来我换了一种学习方式:从最简单的 JWT 验证流程入手,只实现"解析 Token → 设置 SecurityContext → 放行"这一件事,把不需要的配置全部去掉。搞清楚最小系统之后,再逐步加功能。
这篇文章就按这个思路来,从零到一实现一个完整的 JWT 认证方案。
一、JWT 认证流程
二、完整代码实现
2.1 Maven 依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT 处理 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>2.2 JWT 工具类
package com.laozhang.security.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
/**
* JWT 工具类
*/
@Slf4j
@Component
public class JwtUtil {
@Value("${jwt.secret:your-secret-key-must-be-at-least-256-bits-long}")
private String secret;
@Value("${jwt.expiration:86400}") // 默认24小时,单位秒
private long expirationSeconds;
/**
* 获取签名密钥
*/
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成 JWT Token
*/
public String generateToken(Long userId, String username, String role) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationSeconds * 1000);
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析 Token,返回声明(Claims)
* 如果 Token 无效或过期,抛出 JwtException
*/
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 验证 Token 是否有效
*/
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("[JWT] Token已过期: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.warn("[JWT] 不支持的Token格式: {}", e.getMessage());
} catch (MalformedJwtException e) {
log.warn("[JWT] Token格式错误: {}", e.getMessage());
} catch (SignatureException e) {
log.warn("[JWT] Token签名无效: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.warn("[JWT] Token为空: {}", e.getMessage());
}
return false;
}
/**
* 从 Token 中提取用户ID
*/
public Long getUserId(String token) {
return Long.parseLong(parseToken(token).getSubject());
}
/**
* 从 Token 中提取用户名
*/
public String getUsername(String token) {
return parseToken(token).get("username", String.class);
}
/**
* 从 Token 中提取角色
*/
public String getRole(String token) {
return parseToken(token).get("role", String.class);
}
}2.3 JWT 认证过滤器
package com.laozhang.security.filter;
import com.laozhang.security.util.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
/**
* JWT 认证过滤器
*
* 继承 OncePerRequestFilter 而不是普通的 Filter:
* OncePerRequestFilter 保证每次请求只执行一次,避免在 forward/include 时重复执行
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. 从请求头提取 Token
String token = extractToken(request);
// 2. 如果有Token,解析并验证
if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) {
try {
// 3. 解析Token,提取用户信息
Claims claims = jwtUtil.parseToken(token);
Long userId = Long.parseLong(claims.getSubject());
String username = claims.get("username", String.class);
String role = claims.get("role", String.class);
// 4. 构建 Authentication 对象
// UsernamePasswordAuthenticationToken(principal, credentials, authorities)
// principal: 可以是任意用户标识,这里用 userId
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userId, // principal(可以从接口里通过@AuthenticationPrincipal获取)
null, // credentials(JWT认证不需要密码)
List.of(new SimpleGrantedAuthority("ROLE_" + role)) // 权限列表
);
// 把用户名等附加信息存在 details 里(可选)
authentication.setDetails(username);
// 5. 把 Authentication 设置到 SecurityContextHolder
// 这一步非常关键:之后的代码可以通过 SecurityContextHolder.getContext().getAuthentication() 获取用户信息
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("[JWT] 认证成功 userId={} username={}", userId, username);
} catch (Exception e) {
log.error("[JWT] Token解析失败", e);
SecurityContextHolder.clearContext();
}
}
// 6. 继续执行后续过滤器
try {
filterChain.doFilter(request, response);
} finally {
// 7. 请求结束后清除 SecurityContext(非常重要!防止线程复用时的信息泄漏)
SecurityContextHolder.clearContext();
}
}
/**
* 从请求头提取 Token
*/
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}2.4 Spring Security 配置类
package com.laozhang.security.config;
import com.laozhang.security.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 配置
* Spring Boot 3.x 写法(无 WebSecurityConfigurerAdapter)
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启方法级权限控制(@PreAuthorize)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRF(JWT 无状态,不需要 CSRF 保护)
.csrf(csrf -> csrf.disable())
// 禁用 Session(JWT 无状态认证)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置请求权限
.authorizeHttpRequests(auth -> auth
// 开放接口(不需要认证)
.requestMatchers("/api/auth/login", "/api/auth/register").permitAll()
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 管理员接口
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 其他所有请求需要认证
.anyRequest().authenticated()
)
// 配置认证失败处理(返回JSON而不是重定向)
.exceptionHandling(exception -> exception
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(401);
response.getWriter().write("{\"code\":401,\"message\":\"未认证,请先登录\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(403);
response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
})
)
// 在 UsernamePasswordAuthenticationFilter 之前插入 JWT 过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}2.5 登录接口(颁发 Token)
package com.laozhang.security.controller;
import com.laozhang.security.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public Result<LoginResponse> login(@RequestBody LoginRequest request) {
// 用 Spring Security 的 AuthenticationManager 验证用户名密码
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 认证成功,获取用户信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 生成 JWT Token
String token = jwtUtil.generateToken(
userDetails.getUserId(),
userDetails.getUsername(),
"USER"
);
return Result.success(new LoginResponse(token, 86400L));
}
}2.6 在 Controller 里获取当前用户
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public Result<UserDTO> getProfile(
// @AuthenticationPrincipal 获取 SecurityContext 里的 principal
@AuthenticationPrincipal Long userId
) {
// userId 就是我们在 JwtAuthenticationFilter 里设置的 principal
return Result.success(userService.getUserById(userId));
}
// 也可以通过 SecurityContextHolder 手动获取
@GetMapping("/profile2")
public Result<UserDTO> getProfile2() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Long userId = (Long) auth.getPrincipal();
return Result.success(userService.getUserById(userId));
}
}四、踩坑实录
坑1:SecurityContextHolder 在异步方法里丢失
症状:在 @Async 方法里调用 SecurityContextHolder.getContext().getAuthentication() 返回 null。
根因:SecurityContextHolder 默认用 ThreadLocal,异步线程拿不到。
解决:改变 SecurityContextHolder 的策略为 MODE_INHERITABLETHREADLOCAL:
@Bean
public MethodInvokingFactoryBean methodInvokingFactoryBean() {
MethodInvokingFactoryBean factoryBean = new MethodInvokingFactoryBean();
factoryBean.setTargetClass(SecurityContextHolder.class);
factoryBean.setTargetMethod("setStrategyName");
factoryBean.setArguments(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
return factoryBean;
}或者在应用启动时设置:SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)。
坑2:OPTIONS 预检请求被 Security 拦截
症状:前端跨域请求时,OPTIONS 预检请求返回 401/403。
解决:放行 OPTIONS 请求:
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS).permitAll() // 放行所有OPTIONS请求
.anyRequest().authenticated()
)坑3:Token 过期时间设置过短,用户频繁被踢出
最佳实践:使用双 Token 机制:
accessToken:有效期短(15分钟~2小时),用于认证refreshToken:有效期长(7~30天),用于刷新 accessToken
当 accessToken 过期,客户端用 refreshToken 换取新的 accessToken,如果 refreshToken 也过期才要求重新登录。
五、总结与延伸
Spring Security JWT 认证的核心链路:
- 请求进入
JwtAuthenticationFilter(OncePerRequestFilter) - 提取并验证 Token
- 解析用户信息,构建
Authentication对象 - 设置到
SecurityContextHolder - 后续代码通过
@AuthenticationPrincipal或SecurityContextHolder获取当前用户
OncePerRequestFilter 的关键作用:保证在 Servlet Forward/Include 等场景下,过滤器只执行一次,避免重复认证。
