一次完整的 Spring Security JWT 鉴权链路解析
在实际项目中,我们经常会在 Controller 里写出这样一个方法签名:
java
@GetMapping("/me")
public AuthUserResponse me(@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
return authService.me(userId);
}
看起来 Spring 能"凭空"把一个 Jwt 对象塞进方法参数里,还自动从请求头里的 Authorization: Bearer 提取 Token 并完成校验。
这篇文章就结合项目,从配置 到代码,串起这条鉴权链路的每一步。
一、整体流程总览
从前端调用 /api/v1/auth/me 开始,到方法参数里拿到 Jwt jwt,整个链路可以概括为:
- 前端发请求 :HTTP 请求头里带
Authorization: Bearer <accessToken>。 - Security 过滤器拦截 :
SecurityFilterChain中的 Bearer Token 过滤器从请求头里截出<accessToken>。 - 交给
JwtDecoder校验解析 :过滤器调用配置好的JwtDecoder(基于 RSA 公钥的NimbusJwtDecoder),做签名、过期等校验,解析出 Claim,得到一个Jwt对象。 - 封装
Authentication放入SecurityContext:框架把Jwt封装成JwtAuthenticationToken,写入当前线程的SecurityContext。 - Controller 使用
@AuthenticationPrincipal Jwt注入当前用户 Token :@AuthenticationPrincipal从SecurityContext里取出 principal(类型为Jwt)注入到方法参数。 - 业务层解析用户 ID :
jwtService.extractUserId(jwt)从 Claim 中读取uid,实现当前登录用户识别与后续业务处理。
下面按照"配置层 → 过滤器层 → 控制器层"的顺序,一步步展开。
二、开启资源服务器模式:SecurityFilterChain 的核心配置
项目中 Spring Security 的入口配置在 SecurityConfig 中:
java
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// 公开内容:首页 Feed 不需要登录
.requestMatchers("/api/v1/knowposts/feed").permitAll()
// 知文详情等其他白名单接口...
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
这里有几个关键点:
- 无状态会话 :
SessionCreationPolicy.STATELESS- 服务端不使用 Session 记住"谁登录过",每个请求都必须自带 Token。
- 访问控制 :
- 白名单接口使用
permitAll()直接放行; - 其他接口通过
.anyRequest().authenticated()强制要求认证。
- 白名单接口使用
- 启用 JWT 资源服务器模式 :
oauth2ResourceServer(oauth -> oauth.jwt())- 告诉 Spring Security 本应用是一个 OAuth2 Resource Server;
- 请求需要通过 Bearer Token + JWT 的方式来进行认证;
- 框架自动往过滤器链里加上 Bearer Token 相关的过滤器和认证器。
这样一来,我们就不需要自己在每个接口里手动解析请求头,整个 Token 解析与校验流程都由 Spring Security 统一接管。
三、JWT 编解码 Bean:JwtEncoder 与 JwtDecoder
要让资源服务器真正"识别"JWT,我们必须告诉它如何签发和校验 Token,这部分逻辑集中在 AuthConfiguration 中:
java
@Configuration
@EnableConfigurationProperties(AuthProperties.class)
@RequiredArgsConstructor
public class AuthConfiguration {
private final AuthProperties properties;
@Bean
public JwtEncoder jwtEncoder() {
AuthProperties.Jwt jwtProps = properties.getJwt();
RSAPrivateKey privateKey = PemUtils.readPrivateKey(jwtProps.getPrivateKey());
RSAPublicKey publicKey = PemUtils.readPublicKey(jwtProps.getPublicKey());
RSAKey jwk = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(jwtProps.getKeyId())
.build();
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder() {
AuthProperties.Jwt jwtProps = properties.getJwt();
RSAPublicKey publicKey = PemUtils.readPublicKey(jwtProps.getPublicKey());
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
}
-
JwtEncoder- 读取配置中的 RSA 私钥、公钥与 keyId;
- 构造基于 Nimbus 的
JwtEncoder; - 用于登录/注册成功后签发 Access Token 与 Refresh Token。
-
JwtDecoder- 只读取 RSA 公钥;
- 创建
NimbusJwtDecoder,用于校验签名、过期时间等信息并解析 Claim。
关键点 :
一旦容器中存在一个 JwtDecoder Bean,oauth2ResourceServer().jwt() 会自动使用它来完成 JWT 的解析与验证,无需手动再把 JwtDecoder 绑定到过滤器上。
四、自定义 JWT 服务:签发与解析的业务封装 JwtService
在业务层,我们通过 JwtService 把 Token 的签发与解析从 Controller 和 Service 中抽离出来:
java
@Service
@RequiredArgsConstructor
public class JwtService {
private static final String CLAIM_TOKEN_TYPE = "token_type";
private static final String CLAIM_USER_ID = "uid";
private final JwtEncoder jwtEncoder;
private final JwtDecoder jwtDecoder;
private final AuthProperties properties;
private final Clock clock = Clock.systemUTC();
public TokenPair issueTokenPair(User user) {
String refreshTokenId = UUID.randomUUID().toString();
Instant issuedAt = Instant.now(clock);
Instant accessExpiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
Instant refreshExpiresAt = issuedAt.plus(properties.getJwt().getRefreshTokenTtl());
String accessToken = encodeToken(user, issuedAt, accessExpiresAt, "access", UUID.randomUUID().toString());
String refreshToken = encodeRefreshToken(user, issuedAt, refreshExpiresAt, refreshTokenId);
return new TokenPair(accessToken, accessExpiresAt, refreshToken, refreshExpiresAt, refreshTokenId);
}
public Jwt decode(String token) {
return jwtDecoder.decode(token);
}
public long extractUserId(Jwt jwt) {
Object claim = jwt.getClaims().get(CLAIM_USER_ID);
if (claim instanceof Number number) {
return number.longValue();
}
if (claim instanceof String text) {
return Long.parseLong(text);
}
throw new IllegalArgumentException("Invalid user id in token");
}
}
在这里:
-
签发阶段:
- 使用
JwtEncoder构造带有uid、token_type、jti等 Claim 的 Token; - Access Token 与 Refresh Token 使用不同的 TTL 与类型标记。
- 使用
-
解析阶段:
- 可以通过
decode()手动解析某个 Token(例如刷新或登出时用); - 对于
/me等需要"当前用户"的接口,更多使用extractUserId(jwt),从已经被框架解析过的Jwt中读取uidClaim。
- 可以通过
五、请求进入时:Filter 如何一步步完成 JWT 鉴权
当一个请求携带:
GET /api/v1/auth/meAuthorization: Bearer <accessToken>
到达服务端时,Spring Security 会按如下步骤进行处理:
-
进入
SecurityFilterChain- 请求首先会进入配置好的安全过滤器链;
- 因为启用了
.oauth2ResourceServer().jwt(),链中包含 Bearer Token 相关过滤器。
-
从请求头中提取 Bearer Token
- 过滤器内部使用
BearerTokenResolver(默认实现为DefaultBearerTokenResolver):- 从请求头里读取
Authorization; - 匹配
Bearer前缀; - 截取出
<accessToken>部分。
- 从请求头里读取
- 过滤器内部使用
-
创建未认证的
Authentication并委托给AuthenticationManager- 根据 Token 创建一个
BearerTokenAuthenticationToken,此时它还处于"未认证"状态; - 将该对象交由
AuthenticationManager(内部委托给JwtAuthenticationProvider)处理。
- 根据 Token 创建一个
-
JwtAuthenticationProvider使用JwtDecoder进行校验与解析JwtAuthenticationProvider注入的就是我们前面定义的JwtDecoderBean;- 调用
jwtDecoder.decode(accessToken):- 使用 RSA 公钥校验签名;
- 校验 Token 是否过期、是否生效(
exp/nbf等); - 解析出完整的 Claim 集合并构造
Jwt对象。
-
构造认证后的
JwtAuthenticationToken,写入SecurityContext- 若校验通过,
JwtAuthenticationProvider会创建一个认证成功的JwtAuthenticationToken:principal:解析得到的Jwt;authorities:可根据 Claim 映射出的权限列表(本项目中暂未做复杂映射)。
- 将这个
Authentication写入当前线程的SecurityContextHolder中,代表"当前请求已认证"。
- 若校验通过,
-
校验失败时的处理
- 若没有 Token、Token 非法或过期,
JwtDecoder会抛出异常; - 资源服务器模块会返回 401 或 403 响应,Controller 不会被执行。
- 若没有 Token、Token 非法或过期,
六、Controller 层:@AuthenticationPrincipal Jwt 的注入机制
回到 AuthController 中的 /me 接口:
java
@GetMapping("/me")
public AuthUserResponse me(@AuthenticationPrincipal Jwt jwt) {
long userId = jwtService.extractUserId(jwt);
return authService.me(userId);
}
这里的 jwt 参数是如何被自动注入的?
-
Spring MVC 参数解析 + Spring Security 协作
- Spring MVC 调用 Handler 方法前,会为每个参数寻找"数据来源";
- 当发现参数上有
@AuthenticationPrincipal注解时,会启用专门的参数解析器:- 从
SecurityContextHolder.getContext().getAuthentication()中获取当前Authentication; - 默认取
authentication.getPrincipal(); - 尝试匹配/转换为方法参数所声明的类型。
- 从
-
在资源服务器 JWT 场景下,principal 正是
Jwt- 前面的认证流程中,
JwtAuthenticationProvider构造的是JwtAuthenticationToken; - 其
getPrincipal()返回的就是org.springframework.security.oauth2.jwt.Jwt对象; - 因此,当方法参数声明为
@AuthenticationPrincipal Jwt jwt时,类型就刚好匹配,可以直接注入。
- 前面的认证流程中,
-
业务层通过 Claim 完成"当前用户"的识别
- 本项目在签发 Token 时把用户 ID 放在
uidClaim 中; - 因此在
/me中,可以通过jwtService.extractUserId(jwt)从 Claim 集合中读取uid,作为当前登录用户的唯一标识; - 后续调用
authService.me(userId)查询用户信息并返回。
- 本项目在签发 Token 时把用户 ID 放在
七、口头表达总结:如何在面试中讲清这条链路
如果在面试中被问到"你这个项目里 JWT 是如何鉴权的?@AuthenticationPrincipal Jwt 的 Jwt 从哪来的?",可以这样组织回答:
我这边是基于 Spring Security 的 OAuth2 Resource Server,做了基于 JWT 的无状态鉴权。不用 Session 记录登录状态,靠请求头中携带的 token 进行鉴权、记录登录状态。配置层面,配置基于 RSA 公钥的 JWT 的解码器注入 ioc 容器中。在 SpringSecurity 配置类中启用资源服务器的 JWT 模式,安全过滤器链中会添加一个 Bearer Token 过滤器,所有非白名单接口都会先走一遍这个过滤器,从请求头里提取 Token ,交由解码器进行校验与解析,若没有 Token、Token 非法或过期,解码器会抛出异常,返回 401 或 403 响应,Controller 不会被执行。校验通过后会构造一个 Jwt 对象交由线程上下文进行管理。Controller 里使用
@AuthenticationPrincipal Jwt jwt这种方式,Spring MVC 会从SecurityContext中拿出这个 Jwt 对象注入到参数里。到这一步鉴权完成,然后通过我封装的方法,可以从 Jwt 对象的声明中(Claim)里取出登录的用户 ID,整个过程是完全无状态的。
八、总结
整条从 Authorization: Bearer 到 @AuthenticationPrincipal Jwt 的链路,可以概括为四层:
- 配置层 :
SecurityFilterChain启用 JWT Resource Server 模式,AuthConfiguration提供JwtEncoder/JwtDecoderBean。 - 过滤器层 :Bearer Token 过滤器从请求头里提取 Token,交由
JwtAuthenticationProvider使用JwtDecoder校验与解析。 - 安全上下文层 :认证通过后,
JwtAuthenticationToken被写入SecurityContext,代表当前线程的认证状态。 - 控制器层 :
@AuthenticationPrincipal Jwt从SecurityContext中获取 principal 注入到方法参数,业务通过 Claim 完成用户识别。
掌握这条链路之后,我们不仅能在项目中更好地调试和扩展安全逻辑,也能在面试中把"Spring Security + JWT 鉴权"讲得更清晰、更体系化。