无状态认证:JWT 在 Spring Security 中的完整链路

0. 开篇:要解决什么问题

HTTP是无状态的请求,假设一个网站是一个需要登录才可以访问信息的网站并且用户的账号密码已经保存在数据库中,用户连续发送两次请求,一次是登录请求,一次是访问内容的请求(用户个人信息),假如第一次登录请求能够成功,第二次请求还是无法得到用户信息,因为服务器不知道第二次请求是谁发的,所以也无法获取用户信息。

为了解决这个问题,有两种思路:

  1. Session:用户登录成功后,服务器会创建Session保存在内存(Redis),假设SessionID = test,则这个SessionID会对应一个UserId,通过Cookie返回(Set-Cookie: JSESSIONID=abc123),返回后由浏览器保存,随后的每次请求都会带有这个Cookie,服务器根据这个SessionID来判断用户
  2. JWT:用户登录成功后,服务器会用UserId创建一个Token返回给客户端,客户端通过自带Token即可进行访问

Session vs JWT 对比:

Session JWT
状态存在哪 服务器内存 / Redis 客户端(token 自带)
服务器压力 需要存储和查询 零存储,只做验签
主动失效 直接删 SessionID 即可 无法主动失效,只能等过期
水平扩展 多实例需共享 Session(Redis) 无状态,天然支持多实例
安全凭证传输 Cookie 自动携带 通常放 Authorization 请求头

token 放哪:Authorization Header vs Cookie 对比:

Authorization Header Cookie
携带方式 前端手动设置,不自动携带 浏览器自动携带
CSRF 风险 无(浏览器不自动带请求头) 有(浏览器自动带 Cookie)
XSS 风险 高(JS 可读取 localStorage) 可设 HttpOnly 防 JS 读取
适用场景 前后端分离 API 传统 Web / 需要 HttpOnly 保护

本文分两层:① JWT 本身是什么(脱离 Spring 也成立)→ ② 它怎么嵌进 Spring Security 过滤器链。


第一层:JWT 本身

1.1 它长什么样——header.payload.signature 三段

用户登录成功之后,服务器端会生成Token,Token的三段分别为header.payload.signature

header:存token的元信息,包含用于签名的的算法,token类型
payload:以 base64 编码存储,是明文可读的,不能用于存放用户密码,可以存放UserId用于判断用户
signature: header + payload 用服务器上保存的密钥做一次 HMAC 运算,得到 signature,验证时服务器拿同一把密钥对收到的 header + payload 重新算一遍,结果和 signature 对上了就说明没被篡改。

1.2 签发——JwtUtil.generateToken

服务器通过保存在配置文件上的key来进行签名,为了保证隐私安全,所以secret最好不要硬编码,一旦泄露,攻击者可以通过改动payload生成正确的签名从而获取信息。

签发时,payload 里除了 subject(userId)之外,还会写入两个时间字段:

  • iat(issuedAt):签发时间,即当前时间 now
  • exp(expiration):过期时间,即 now + EXPIRE_MS

这两个字段同样是 base64 明文,直接存在 payload 里,不需要服务器另存——这是 JWT 无状态的关键之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
public class JwtUtil {

// ⭐ 从 application.yaml 的 jwt.secret 读, 外部化避免硬编码泄漏
@Value("${jwt.secret}")
private String secret;

private SecretKey key;

// 注入完成后初始化 key (因为 @Value 在构造后才生效)
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
}

// token 有效期:这里设 24 小时(学习方便;真实项目常更短 + 配刷新机制)
private static final long EXPIRE_MS = 24 * 60 * 60 * 1000L;

// 签发:把 userId 装进 token,签名,返回 "xxx.yyy.zzz" 字符串
public String generateToken(Long userId) {
Date now = new Date();
return Jwts.builder()
.subject(String.valueOf(userId)) // 载荷里放 userId(subject 标准字段)
.issuedAt(now) // 签发时间
.expiration(new Date(now.getTime() + EXPIRE_MS)) // 过期时间
.signWith(key) // 用服务器密钥签名(第三段)
.compact(); // 拼成最终字符串
}

1.3 验签——JwtUtil.parseUserId

相比于Session,JWT是无状态的,因为服务器不存内容,服务器只负责验证签名,正因如此,假设管理员需要封号或者让用户注销登录,session只需要删除记录即可,但JWT不行。

parseSignedClaims 在解析时会自动做两件事:

  1. 验签:用同一把密钥重新计算 signature,对不上就抛异常
  2. 验过期:读取 payload 里的 exp 字段,和当前时间对比,过期也抛异常

服务器全程不查任何存储,只做数学运算——这就是”无状态”的本质。

1
2
3
4
5
6
7
8
9
// 解析+验签:合法则返回里面的 userId;被篡改/过期/伪造会抛异常(上层处理)
public Long parseUserId(String token) {
Claims claims = Jwts.parser()
.verifyWith(key) // 用同一把密钥验签——签名对不上这里就抛异常
.build()
.parseSignedClaims(token) // 解析;过期也会在这抛异常
.getPayload(); // 拿到载荷
return Long.valueOf(claims.getSubject()); // 取出当初放进去的 userId
}

第二层:嵌进 Spring Security

2.1 认证(Authentication)vs 授权(Authorization)

认证:用于登录,判断用户是谁
授权:判断用户能否访问这个接口(比如游客用户可以访问部分授权内容,但部分功能需要登录后才能授权使用)

2.2 过滤器——JwtAuthenticationFilter

这一层主要用于认证,所以这个Filter的职责为登记身份,假如能够正确认证,则把UserId写入SecurityContext,如果token失效则当作游客后继续放行

SecurityContextHolder本质是一个“线程级别的用户上下文容器”,默认基于 ThreadLocal 存储当前请求的认证信息(Authentication),ThreadLocal——每个线程有自己独立的一份存储,线程之间完全隔离。一个请求对应一个线程,所以请求之间不会互相干扰。

filter 把身份存进去,后面的 controller 就能通过 @AuthenticationPrincipal 直接取出来

SecurityConfig代码中,把JWT 过滤器插入到 Spring 默认“UsernamePasswordAuthenticationFilter”之前执行的原因:Spring 默认是“Session 登录体系”,我们做的是在 Spring “认为你未登录之前”,先帮它识别 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Collections;

// 职责:验 token → 把"你是谁"登记进 SecurityContext。它【不】决定放行/拦截
// extends OncePerRequestFilter:Spring 提供的过滤器基类,保证每个请求只过一次
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtUtil jwtUtil;

public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 取 Authorization: Bearer xxx
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Long userId = jwtUtil.parseUserId(token); // 验签,合法则拿到 userId

// 造一个 Spring Security 的"已认证"凭证对象
// 参数:principal(谁=userId)、credentials(密码=null,已用JWT验过)、authorities(权限=暂空)
// 用这个三参构造 → 该对象被标记为"已认证"
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());

// 把身份登记进 SecurityContext —— 这是过滤器的核心动作
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
// token 无效/过期:不登记身份。不拦、不抛——后续授权规则会因"无身份"而拒
}
}
// 不管认没认证成功,都要放行到过滤器链下一环(拦不拦由授权规则决定,不是这里)
filterChain.doFilter(request, response);
}
}

2.3 配置——SecurityConfig.securityFilterChain

cors:启用 Spring Security 的跨域支持,具体允许的域名和方法由 CorsConfigurationSource Bean 定义。

**sessionCreationPolicy(STATELESS)**:告诉 Spring Security 不创建、不使用 Session。不设的话 Spring 默认会建 Session,和 JWT 无状态的设计冲突。

**csrf().disable()**:CSRF(跨站请求伪造)的攻击原理是——你登录了网站 A,浏览器存着 A 的 Cookie(Session),攻击者诱你访问恶意页面,恶意页面偷偷向 A 发请求,浏览器会自动带上 Cookie,服务器以为是你发的。JWT 放在 Authorization 请求头里,浏览器不会自动携带请求头,所以 CSRF 无法利用,可以安全关闭。(注意:如果把 JWT 存进 Cookie,则不能关)

authorizeHttpRequests 路径规则:Spring Security 从上到下按顺序匹配,第一条命中的规则生效,所以越精确的规则要放越前面。

规则 说明
ASYNC / ERROR dispatcher 异步请求和错误转发放行,否则 Spring 内部转发会被拦
/api/internal/**permitAll 服务间调用不走 JWT,认证在 controller 层单独处理
/api/auth/meauthenticated 必须登录才能查自己信息,精确规则先于下面的通配
/api/auth/registerdenyAll 注册接口已关闭,直接拒绝所有请求
/api/auth/**permitAll 登录等认证接口无需 token
/swagger-ui/** 等 → permitAll 开发文档不需要登录
anyRequestauthenticated 其余所有接口必须登录
1
2
3
4
5
6
7
8
9
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers("/api/internal/**").permitAll()
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers("/api/auth/register").denyAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)

2.4 失败处理——JwtAuthenticationEntryPoint

未认证访问受保护接口时,默认行为是返回空响应体,前端收到不知道发生了什么;替换成 Result 信封后前端可以统一按 code 处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

import java.io.IOException;

// 认证失败入口:Spring Security 遇到"未认证访问受保护接口"时调用 commence
// 作用:把默认的空 401 换成统一的 Result 信封
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper;

public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK); // 统一信封风格:HTTP 200,结果看 body.code
Result<Void> body = Result.fail(401, "未登录或 token 无效");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

2.5 闭环——@AuthenticationPrincipal Long userId

  1. filter 解出 userId,放进 UsernamePasswordAuthenticationToken 的第一个参数(principal)
  2. 把这个 token 存进 SecurityContextHolder
  3. 请求到达 controller 时,Spring 看到 @AuthenticationPrincipal,自动从 SecurityContextHolder 取出 principal,注入进来
1
2
3
4
5
6
7
8
9
10
@GetMapping("/me")
@Operation(summary = "当前登录用户信息", description = "从 token 解析出 userId,返回用户基本信息(不含密码)")
public Result<UserResponse> me(@AuthenticationPrincipal Long userId) {
User dbuser = service.findById(userId);
UserResponse user = new UserResponse();
user.setId(dbuser.getId());
user.setUsername(dbuser.getUsername());
user.setCreatedAt(dbuser.getCreatedAt());
return Result.success(user);
}

@AuthenticationPrincipal 本质是个语法糖——Spring 帮你从 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 里取出值直接注入,省去手动取的样板代码。能答出”userId 从 filter 进 SecurityContext,再由 @AuthenticationPrincipal 注入”,整条链就串通了。


第三层:进阶

JWT 的硬伤:签发后无法主动失效

JWT 无状态的代价是:服务器签发之后就管不了这个 token 了。用户注销、管理员封号、用户改密码——这些操作当下都不会让已签发的 token 失效,只能等它自然过期。

生产环境通常有效期设更短(比如 15 分钟),配合以下方案缓解:

  • 短有效期 + Refresh Token:access token 短期(15 分钟),另发一个长期 refresh token,access token 过期后用 refresh token 换新的,注销时只需让 refresh token 失效
  • 黑名单:注销时把 token 存进 Redis,每次验签后额外查一下黑名单。代价是重新引入了服务器状态,部分牺牲无状态优势

前端拿到 token 之后存在哪,是一个安全权衡:

localStorage HttpOnly Cookie
JS 能读取 能(XSS 可窃取) 不能(HttpOnly 阻止)
自动携带 不会,需手动加请求头 浏览器自动带,有 CSRF 风险
推荐场景 前后端分离,需手动控制 对 XSS 防护要求高

前后端分离场景通常 token 存 localStorage,请求时手动加 Authorization: Bearer xxx 请求头——规避了 CSRF,XSS 防护靠前端输入过滤。

服务间认证:内部接口走独立 Header

JWT 解决的是用户和服务器之间的认证。微服务架构中还存在另一类请求——后端服务之间互相调用的内部接口,这类请求不代表任何用户,不适合走 JWT。

常见做法:内部接口走独立的自定义请求头(如 X-Internal-Token),在 controller 层手动校验,SecurityConfig 里直接 permitAll 跳过 JWT 认证:

1
2
// SecurityConfig 里
.requestMatchers("/api/internal/**").permitAll()

两套机制职责分离:JWT 管用户认证,X-Internal-Token 管服务间认证。


结尾:请求的完整旅程

登录阶段:

  1. 前端发送用户名 + 密码到 /api/auth/login
  2. 服务器验证通过后,将 userIdiat(签发时间)、exp(过期时间)写入 payload,base64 编码后与 header 一起用 secret 生成 signature,拼成 header.payload.signature 返回给前端
  3. 前端将 token 存入 localStorage

后续请求阶段:

  1. 前端每次请求在 header 中带上 Authorization: Bearer <token>
  2. 请求进入 JwtAuthenticationFilter
    • token 有效 → 解出 userId,封装成 UsernamePasswordAuthenticationToken,存入 SecurityContextHolder,放行
    • token 无效/过期 → 不登记身份,当作游客放行
  3. SecurityConfig 的授权规则判断该路径是否需要登录:
    • 需要登录但无身份 → 触发 JwtAuthenticationEntryPoint,返回 {"code": 401, "msg": "未登录或 token 无效"}
    • 放行 → 进入 controller
  4. controller 通过 @AuthenticationPrincipal Long userId 直接拿到 userId,Spring 自动从 SecurityContextHolder 注入,无需手动取

能把这条线自己串起来,JWT + Spring Security 的整条链就真正理解了。