这一讲是整个系列最花时间的部分。三个问题:过滤器顺序天坑(一词之差全站 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 之前,认证被覆盖

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% 成功、字符串参数时灵时不灵)也在这讲展开。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐