【WMS 仓储系统集成 AI Agent 实战】第 3 讲:Spring Security 6 + JWT------addFilterBefore 一词之差,全站 401
这一讲是整个系列最花时间的部分。三个问题:过滤器顺序天坑(一词之差全站 401)、SSE 异步丢认证(诡异到怀疑人生)、Token 过期体验(三层刷新机制)。每个都是真实踩坑,排查过程完整还原。
本讲复现环境与版本
| 项 | 版本/说明 |
|---|---|
| Spring Boot / Security | 3.5.16 / Spring Security 6.x(随 Boot 传递) |
| JWT | jjwt 0.12.6(api + impl + jackson) |
| Redis | 7.x(Token 黑名单 / refreshToken / 角色缓存) |
| Nginx | 1.30.4(Windows) |
| 浏览器 | Chrome(前端联调) |
| JDK | 17 |
本讲问题均按「版本号 → 复现环境 → 真实报错 → 项目实际现象」四要素记录。认证类问题多数不抛异常栈,报错要靠 HTTP 响应和日志还原,这也是它难排查的原因。
问题一:登录成功,但所有请求都 401
先描述现象,看看你有没有似曾相识的感觉:
- 登录接口正常,返回 token
- 前端拿到 token 存好,后续请求带上
Authorization: Bearer xxx - 然后所有
/api/**和/ai/**请求全部 401 - 用 curl 绕过前端直连后端 8089,同样 401
最后一条很重要------它证明了问题在后端 ,跟前端、Nginx 都没关系。这一步排查思路先记下:curl 直连是隔离变量的第一刀。
📋 问题档案
- 版本:Spring Security 6.x(Spring Boot 3.5.16 传递)· jjwt 0.12.6
- 复现环境 :登录成功后,
curl -i直连后端 8089 携带合法 token 请求任意/api/**接口- 真实报错(curl -i 原文节选):
text
curl -i http://127.0.0.1:8089/api/material/list -H "Authorization: Bearer eyJhbG..."
HTTP/1.1 401
Content-Type: application/json
{"timestamp":"...","status":401,"error":"Unauthorized","path":"/api/material/list"}
- 项目实际现象 :登录接口正常返回 token,但登录后所有业务接口 401。后端日志里没有任何 token 校验失败的记录------token 是好的,认证上下文在到达权限检查前被"擦"掉了
根因:过滤器顺序
我的 SecurityConfig 当初这么写的:
java
http.addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderFilter.class);
addFilterBefore------就这一个词,全站 401。
来理解一下 Spring Security 6 的过滤器链:
markdown
SecurityContextHolderFilter ← 负责从 Repository 加载/保存 SecurityContext
↓
JwtAuthenticationFilter ← 我自己的过滤器,解析 token 设置认证
↓
AuthorizationFilter ← 权限检查
问题出在:STATELESS 模式下,SecurityContextHolderFilter 会从 SecurityContextRepository 读取上下文并写入 SecurityContextHolder 。我的 JWT 过滤器配在它之前,设置的认证上下文会被它覆盖清空。
等轮到 AuthorizationFilter 检查权限时,上下文早没了,401。
解法
java
http.addFilterAfter(jwtAuthenticationFilter, SecurityContextHolderFilter.class);
before 改 after,一词之差。
修复后的链路:
markdown
SecurityContextHolderFilter ← 加载上下文(此时为空)
↓
JwtAuthenticationFilter ← 解析 token,设置认证(在它之后没人再覆盖)
↓
AuthorizationFilter ← 权限检查,认证还在,放行 ✅
划重点 :Spring Security 6 里自定义 JWT 过滤器,位置必须是 SecurityContextHolderFilter 之后 、AuthorizationFilter 之前。
问题二:SSE 首个数据块正常,后续块抛 AccessDeniedException
这个坑诡异程度五颗星。
📋 问题档案
- 版本:Spring Security 6.x + Spring MVC(Servlet 栈)· SSE 流式接口
- 复现环境:发起一次 AI 流式对话,观察前端收到的数据块
- 真实报错(异步调度线程抛出,日志节选):
text
org.springframework.security.access.AccessDeniedException: Access Denied
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilterInternal(...)
... (异步调度线程栈,非 Tomcat 工作线程)
- 项目实际现象 :前端第一个字正常显示,流随后中断;后端日志显示异常发生在另一个线程上------同一个请求,前半段认证还在,后半段认证没了
现象:AI 对话流式接口,第一个 数据块正常下发,然后后续数据块在异步调度时抛 AccessDeniedException。
第一个块正常?说明认证明明是通过的。但流跑到一半认证没了?
根因:SecurityContext 对象变更检测
我的 JWT 过滤器当初这么写:
java
// 错误写法
SecurityContextHolder.getContext().setAuthentication(authentication);
问题在:Spring Security 6 的 SecurityContextHolderFilter 通过对象引用比对 检测上下文变化。直接 getContext().setAuthentication(...) 修改的是同一个对象,SecurityContextHolderFilter 检测不到"上下文对象变更",没有把认证保存到 request 属性。
SSE 是异步处理:Tomcat 线程处理完请求头就返回了,后续数据块由另一个调度线程推送。异步线程要恢复认证上下文,只能从 request 属性里读------你没保存,它就读不到。第一个块之所以正常,是因为它还在原始请求线程的上下文窗口期内。
解法:两处修改
java
// 正确写法:新建 SecurityContext 对象
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
java
// SecurityConfig 里配套启用
http.securityContext(ctx ->
ctx.securityContextRepository(new RequestAttributeSecurityContextRepository()));
敲黑板 :任何涉及异步/流式响应的项目(SSE、WebFlux 混用 Servlet),JWT 过滤器都必须新建 SecurityContext 对象 并配合 RequestAttributeSecurityContextRepository,否则认证上下文活不过第一个异步边界。
问题三:Token 30 分钟过期,开发到一半被踢出
这是体验问题,但同样值得说。
📋 问题档案
- 版本 :jjwt 0.12.6 ·
ACCESS_EXPIRATION硬编码 30 分钟- 复现环境:登录后搁置 30 分钟(比如去开会/吃饭),回来继续操作
- 真实报错:无后端报错;前端收到 401 后跳登录页
- 项目实际现象 :写代码写到一半,切过去测个 AI 对话,登录态没了,重新登录、重新进入页面、重新输入问题......血压直接拉满。开发环境的 30 分钟过期是对开发效率的直接谋杀
初期 JwtUtils.ACCESS_EXPIRATION 硬编码 30 分钟。
解法分两层:
后端:过期时间改配置化,默认拉长到 24 小时:
java
@Value("${jwt.access-token-expiration:86400000}")
private long accessExpiration;
前端:双 Token + 三层刷新机制。这才是本讲的重头戏。
双 Token 与三层刷新机制
Token 设计
| Token | 有效期 | 存储位置 | 用途 |
|---|---|---|---|
| accessToken | 24h | localStorage | 接口认证 |
| refreshToken | 7 天 | localStorage(Redis 服务端缓存) | 换取新 accessToken |
Redis 侧的 Key 设计:
java
private static final String REDIS_TOKEN_BLACKLIST_PREFIX = "auth:token:blacklist:"; // 登出黑名单
private static final String REDIS_REFRESH_TOKEN_PREFIX = "auth:refresh:"; // refreshToken 缓存
private static final String REDIS_USER_ROLES_PREFIX = "auth:roles:"; // 角色缓存
登出的实现:accessToken 加入 Redis 黑名单(TTL = 剩余有效期),同时清理 refreshToken 和角色缓存。黑名单的意义:JWT 本身无状态无法撤销,登出后必须靠服务侧记录"这个 token 作废了"。
前端三层刷新
这是本项目我认为最有含金量的前端代码之一,完整设计如下:
csharp
第 1 层(请求前):
├─ token 已过期(提前 30s 判定)→ 阻塞请求,await 刷新成功后再发
└─ token 5 分钟内即将过期 → 后台静默刷新,不阻塞当前请求
第 2 层(响应后):
└─ 收到 401(业务码或 HTTP 状态)→ 刷新一次,成功则重放原请求
第 3 层(并发保护):
└─ isRefreshing 锁 + 等待队列,保证同一时刻只有一次刷新请求
核心代码(Axios 拦截器):
js
// 请求拦截器
request.interceptors.request.use(async config => {
const token = localStorage.getItem('token')
if (!token) { handle401('未登录'); return Promise.reject(new Error('未登录')) }
// ① 已过期 → 阻塞刷新
if (isTokenExpired()) {
const ok = await tryRefreshToken()
if (!ok) { handle401('登录已过期,请重新登录'); return Promise.reject(new Error('Token已过期')) }
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`
return config
}
// ② 即将过期(5 分钟内)→ 后台静默刷新
if (isTokenExpiringSoon(5) && !isRefreshing) {
tryRefreshToken().catch(() => {})
}
config.headers.Authorization = `Bearer ${token}`
return config
})
并发刷新风暴:锁 + 订阅队列
场景:页面加载时 6 个接口并发请求,token 恰好都过期了。没有保护的话,6 个请求各自触发一次刷新------refreshToken 是一次性的,第一个成功后其余 5 个全部失败,用户被强制登出。
解法是经典的"单飞 + 排队"模式:
js
let isRefreshing = false
let refreshSubscribers = [] // 等待队列
function onRefreshed(newToken) {
refreshSubscribers.forEach(cb => cb(newToken))
refreshSubscribers = []
}
async function tryRefreshToken() {
if (isRefreshing) {
// 已有刷新在进行 → 排队等结果,不重复发
return new Promise(resolve => {
addRefreshSubscriber(newToken => resolve(!!newToken))
})
}
isRefreshing = true
try {
const ok = await userStore.doRefreshToken()
isRefreshing = false
onRefreshed(ok ? userStore.token : null)
return ok
} catch (e) {
isRefreshing = false
onRefreshed(null)
return false
}
}
第一个 发现过期的请求去刷新,其余请求挂起等通知。刷新成功,队列里所有等待者带着新 token 继续;失败,一起登出。
还有一个容易忽略的细节:刷新接口本身用的是独立的 axios 实例 (refreshClient),不走主拦截器------不然刷新失败返回 401,又触发刷新,死循环。
SSE 的 fetch 也要接刷新逻辑
Axios 拦截器管不到 fetch。AI 对话的 SSE 用的是原生 fetch,所以 401 处理要单独写:
js
export async function chatStream(data) {
// ... 构造 headers ...
const res = await fetch('/ai/chat/stream', { method: 'POST', headers, body: JSON.stringify(data) })
if (res.status === 401) {
// 刷新一次后重试
const ok = await userStore.doRefreshToken()
if (ok) {
headers['Authorization'] = `Bearer ${localStorage.getItem('token')}`
const retryRes = await fetch('/ai/chat/stream', { method: 'POST', headers, body: JSON.stringify(data) })
if (retryRes.status === 401) { doLogout('登录已过期,请重新登录'); throw new Error('未登录') }
return retryRes
}
doLogout('登录已过期,请重新登录')
throw new Error('未登录或Token已过期')
}
return res
}
附:Nginx 透传的坑
开发环境全通了,上了 Nginx 又 401。这次根因简单:Nginx 的 /api/ 和 /ai/ location 没透传 Authorization 头。
📋 问题档案
- 版本:Nginx 1.30.4(Windows)
- 复现环境:直连 8089 正常、走 Nginx 80 端口 401------两相对比即锁定 Nginx 层
- 真实报错:同问题一的 401 JSON,但 curl 直连不复现
- 项目实际现象 :开发环境(Vite 代理直连后端)一切正常,部署到 Nginx 后登录成功但所有请求 401。同一个 bug 在开发环境不可见,这就是为什么建议联调阶段就引入 Nginx
nginx
location /api/ {
proxy_set_header Authorization $http_authorization; # ← 必须显式透传
proxy_pass http://127.0.0.1:8089;
}
排查这类问题的分层法,我总结成三步:
css
① curl 直连后端端口(绕过 Nginx/前端)
仍 401 → 后端问题(过滤器链、Token 校验、SecurityConfig)
正常 → Nginx/前端问题,进入 ②
② 检查 Nginx 是否透传 Authorization Header
未透传 → 加 proxy_set_header
已透传 → 进入 ③
③ 检查前端请求头是否携带正确的新 Token
旧 Token → 浏览器缓存 / 前端刷新逻辑问题
本讲踩坑清单
| # | 坑 | 涉及版本 | 根因 | 解法 |
|---|---|---|---|---|
| 1 | 全站 401 | Security 6.x(Boot 3.5.16) | JWT 过滤器配在 SecurityContextHolderFilter 之前,认证被覆盖 | addFilterBefore → addFilterAfter |
| 2 | SSE 后续块 AccessDenied | Security 6.x 异步调度 | 直接修改 Context 对象,变更检测不到,异步线程读不到认证 | 新建 SecurityContext + RequestAttributeSecurityContextRepository |
| 3 | 30 分钟被踢出 | jjwt 0.12.6 硬编码 | 过期时间写死 | 配置化 + 默认 24h |
| 4 | 并发刷新全失败 | 前端无并发保护 | refreshToken 一次性,多请求并发刷新 | isRefreshing 锁 + 订阅者队列 |
| 5 | 刷新死循环 | Axios 拦截器 | 刷新接口走主拦截器 | 独立 axios 实例 |
| 6 | Nginx 后 401 | Nginx 1.30.4 | 未透传 Authorization | proxy_set_header |
写在最后
认证这关过了,系统才算"能用"。回头看,addFilterBefore 那个坑最坑爹的地方在于:它不报错,只是安静地把你的认证擦掉------日志里一切正常,就是 401。
下一讲进入业务核心:15 张表怎么设计、AI 的 19 个工具怎么注册、库存防超卖怎么实现。其中"模型参数绑定不稳定"的坑(数字参数 100% 成功、字符串参数时灵时不灵)也在这讲展开。