【WMS 仓储系统集成 AI Agent 实战】第 3 讲:Spring Security 6 + JWT——addFilterBefore 一词之差,全站 401

【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);

beforeafter,一词之差。

修复后的链路:

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 之前,认证被覆盖 addFilterBeforeaddFilterAfter
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% 成功、字符串参数时灵时不灵)也在这讲展开。

相关推荐
LinMINGJing00716 分钟前
PageHeaderData:Page 的页面头
后端
2601_9620715723 分钟前
如何利用SpringSecurity进行认证与授权
java·服务器·数据库
2601_9620697725 分钟前
SpringBoot开发——初步了解SpringBoot
java·spring boot·后端
旧梦952731 分钟前
Java 单例模式:从基础到实战的完整指南
java·开发语言·单例模式
jay神41 分钟前
【计算机毕业设计】基于Spring Boot的宠物领养管理系统
java·spring boot·后端·vue·毕业设计·宠物
cfm_29141 小时前
5种Java并发同步工具全解
java
十五喵源码网1 小时前
基于SpringBoot2+vue2的健身房管理系统
java·毕业设计·springboot·论文笔记
2601_962056601 小时前
可造成敏感信息泄露!Spring Boot之Actuator信息泄露漏洞三种利用方式总结
java·spring boot·后端
码视野1 小时前
基于 Spring Boot + Vue3 的【城市道路地下空洞塌陷微动传感监测与路面脱空沉降预警中台】设计与实现(含PRD/三端高保真源码/大屏)
vue.js·spring boot·后端