【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 原文节选):
- 项目实际现象:登录接口正常返回 token,但登录后所有业务接口 401。后端日志里没有任何 token 校验失败的记录——token 是好的,认证上下文在到达权限检查前被"擦"掉了
根因:过滤器顺序
我的 SecurityConfig 当初这么写的:
addFilterBefore——就这一个词,全站 401。
来理解一下 Spring Security 6 的过滤器链:
问题出在:STATELESS 模式下,SecurityContextHolderFilter 会从 SecurityContextRepository 读取上下文并写入 SecurityContextHolder。我的 JWT 过滤器配在它之前,设置的认证上下文会被它覆盖清空。
等轮到 AuthorizationFilter 检查权限时,上下文早没了,401。
解法
before 改 after,一词之差。
修复后的链路:
划重点:Spring Security 6 里自定义 JWT 过滤器,位置必须是 SecurityContextHolderFilter 之后、AuthorizationFilter 之前。
问题二:SSE 首个数据块正常,后续块抛 AccessDeniedException
这个坑诡异程度五颗星。
📋 问题档案
- 版本:Spring Security 6.x + Spring MVC(Servlet 栈)· SSE 流式接口
- 复现环境:发起一次 AI 流式对话,观察前端收到的数据块
- 真实报错(异步调度线程抛出,日志节选):
- 项目实际现象:前端第一个字正常显示,流随后中断;后端日志显示异常发生在另一个线程上——同一个请求,前半段认证还在,后半段认证没了
现象:AI 对话流式接口,第一个数据块正常下发,然后后续数据块在异步调度时抛 AccessDeniedException。
第一个块正常?说明认证明明是通过的。但流跑到一半认证没了?
根因:SecurityContext 对象变更检测
我的 JWT 过滤器当初这么写:
问题在:Spring Security 6 的 SecurityContextHolderFilter 通过对象引用比对检测上下文变化。直接 getContext().setAuthentication(...) 修改的是同一个对象,SecurityContextHolderFilter 检测不到"上下文对象变更",没有把认证保存到 request 属性。
SSE 是异步处理:Tomcat 线程处理完请求头就返回了,后续数据块由另一个调度线程推送。异步线程要恢复认证上下文,只能从 request 属性里读——你没保存,它就读不到。第一个块之所以正常,是因为它还在原始请求线程的上下文窗口期内。
解法:两处修改
敲黑板:任何涉及异步/流式响应的项目(SSE、WebFlux 混用 Servlet),JWT 过滤器都必须新建 SecurityContext 对象并配合 RequestAttributeSecurityContextRepository,否则认证上下文活不过第一个异步边界。
问题三:Token 30 分钟过期,开发到一半被踢出
这是体验问题,但同样值得说。
📋 问题档案
- 版本:jjwt 0.12.6 ·
ACCESS_EXPIRATION硬编码 30 分钟
- 复现环境:登录后搁置 30 分钟(比如去开会/吃饭),回来继续操作
- 真实报错:无后端报错;前端收到 401 后跳登录页
- 项目实际现象:写代码写到一半,切过去测个 AI 对话,登录态没了,重新登录、重新进入页面、重新输入问题……血压直接拉满。开发环境的 30 分钟过期是对开发效率的直接谋杀
初期 JwtUtils.ACCESS_EXPIRATION 硬编码 30 分钟。
解法分两层:
后端:过期时间改配置化,默认拉长到 24 小时:
前端:双 Token + 三层刷新机制。这才是本讲的重头戏。
双 Token 与三层刷新机制
Token 设计
|
Token |
有效期 |
存储位置 |
用途 |
|
accessToken |
24h |
localStorage |
接口认证 |
|
refreshToken |
7 天 |
localStorage(Redis 服务端缓存) |
换取新 accessToken |
Redis 侧的 Key 设计:
登出的实现:accessToken 加入 Redis 黑名单(TTL = 剩余有效期),同时清理 refreshToken 和角色缓存。黑名单的意义:JWT 本身无状态无法撤销,登出后必须靠服务侧记录"这个 token 作废了"。
前端三层刷新
这是本项目我认为最有含金量的前端代码之一,完整设计如下:
核心代码(Axios 拦截器):
并发刷新风暴:锁 + 订阅队列
场景:页面加载时 6 个接口并发请求,token 恰好都过期了。没有保护的话,6 个请求各自触发一次刷新——refreshToken 是一次性的,第一个成功后其余 5 个全部失败,用户被强制登出。
解法是经典的"单飞 + 排队"模式:
第一个发现过期的请求去刷新,其余请求挂起等通知。刷新成功,队列里所有等待者带着新 token 继续;失败,一起登出。
还有一个容易忽略的细节:刷新接口本身用的是独立的 axios 实例(refreshClient),不走主拦截器——不然刷新失败返回 401,又触发刷新,死循环。
SSE 的 fetch 也要接刷新逻辑
Axios 拦截器管不到 fetch。AI 对话的 SSE 用的是原生 fetch,所以 401 处理要单独写:
附: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
排查这类问题的分层法,我总结成三步:
本讲踩坑清单
|
# |
坑 |
涉及版本 |
根因 |
解法 |
|
1 |
全站 401 |
Security 6.x(Boot 3.5.16) |
JWT 过滤器配在 SecurityContextHolderFilter 之前,认证被覆盖 |
|
|
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% 成功、字符串参数时灵时不灵)也在这讲展开。
更多推荐

所有评论(0)