资讯详情

Spring Security接入SSE流式对话的认证冲突与解决方案

📅 2026/10/10 7:27:56 | 华诺云谱 👁 阅读
Spring Security接入SSE流式对话的认证冲突与解决方案
1. 现象定位接入 Spring Security 之后流式对话的四种典型“翻车”表现如果你也在做 AI 应用接入统一登录大概率会遇到一个很诡异的情况非流式接口一切正常接口文档里标着text/event-stream的流式对话接口一接入 Spring Security 就崩。我用的是 Spring AI Alibaba 做模型接入底层走阿里云百炼的 Qwen 系列模型ChatClient一把梭流式对话半天就调通了。结果加完认证整整折腾了三天把 Spring Security 的过滤器链、DispatcherType异步派发、SecurityContextHolder的线程传递机制翻了个底朝天才算把这个“认证冲突”彻底解决。为了让后面讲方案时大家能对上号我先把踩过的四种典型表现列出来。你可以对照自己的报错现象快速定位1.1 翻车一接口直接 403请求根本没进 Controller这是最直接的一种。前端用fetch发了一个POST /api/chat/stream响应回来 403打开控制台一看错误是 Spring Security 的 CSRF 校验失败。很多团队习惯把登录改成 JWT 之后就只把sessionManagement设成无状态却忘了 CSRF 的过滤器还在链上。Spring Security 6 的 CSRF 默认对所有会导致状态变更的请求做校验流式对话的POST请求正好撞枪口。1.2 翻车二流开了 1-2 秒被掐断前端收到不完整内容这种最阴间。请求进来了SSE 头也返回了前端 EventSource 已经开始往界面上吐字了结果吐到一半连接断开或者后端日志里突然抛出一个AccessDeniedException。原因很典型Servlet 容器处理异步请求时会再做一次 dispatch安全过滤器链在 ASYNC dispatch 上又跑了一遍。如果这个流式接口要求“必须登录”而异步线程里拿不到认证信息二次派发直接把你拒了。问题是响应头已经发出去了处于流中间状态这个时候拒绝既不能返回 401也不能返回 403最终表现就是“流被掐断”。1.3 翻车三异步消费链路里取不到登录用户用户信息全是 null这是最隐蔽的一种接口没有报错流也正常吐完了但你在doOnNext里想拿当前登录用户做审计、做个性化 prompt 的时候SecurityContextHolder.getContext().getAuthentication()返回的全是 null。Spring Security 默认把认证信息放在ThreadLocal里而流式响应的订阅和消费发生在容器异步线程上根本不继承请求线程的上下文。1.4 翻车四同一套代码在 WebFlux 下 SecurityContextHolder 直接报错如果你用的是 WebFlux 反应式栈情况又不一样。SecurityContextHolder在反应式环境下压根不会被填充你在 Controller 里同步调用它大概率拿到 null甚至在某些版本里还会让你误以为“认证没生效”。实际上反应式请求的认证信息存放在ReactiveSecurityContextHolder里是绑在响应式流上的不是绑在线程上的。用 Servlet 的思路去调就是张冠李戴。2. 冲突的本质SSE 长连接和 Spring Security 过滤器链的时序博弈要解决问题先得搞清楚为什么偏偏是“流式对话”这么特殊。核心原因有三个下面逐个拆开讲。2.1 SSE 的“先响应后上报”模式和 Spring Security 的“先认证后放行”模式普通接口是同步的请求进来 → 鉴权 → 执行业务 → 返回完整响应。整个过程中响应头是在最后才写出去的所以鉴权失败随时可以返回 401、403。流式接口不一样。Spring AI Alibaba 的ChatClient.stream()返回的是一个FluxStringSpring MVC 拿到这个 Flux 之后会把响应头包括Content-Type: text/event-stream先写出去然后才开始订阅、逐块推送数据。这就意味着鉴权动作必须发生在响应头写出之前也就是“在返回 Flux 之前”就必须完成认证。一旦流已经开始你发现用户没登录想返回 401 已经晚了头都发出去了。这是 SSE 与安全框架之间最根本的时序矛盾。2.2 CSRF 过滤器默认对 ASYNC 派发也会生效Servlet 的异步机制会触发多次 dispatch第一次是 REQUEST dispatch处理完请求进入异步模式后容器会再发一次 ASYNC dispatch。Spring Security 6 里CSRF 过滤器默认对所有派发类型都参与过滤。这意味着即使你第一次请求通过了校验异步派发时 CSRF 过滤器可能又跑一遍一旦会话里没有匹配的 token就会拒绝。实践中我的建议很简单流式接口如果用的是 Bearer Token 做认证CSRF 对它本来就没有保护意义。CSRF 防的是 Cookie 自动携带导致的跨站请求伪造你用的是Authorization头攻击者根本没有办法跨站带上你的 token所以直接针对流式路径关闭 CSRF 是安全的不代表整个应用都关。2.3 ThreadLocal 的 SecurityContext 跨线程传递难题Spring Security 在 Servlet 环境下把Authentication存在SecurityContextHolder里默认策略是MODE_THREADLOCAL也就是绑定当前线程。流式接口触发异步派发、或者 Flux 被容器线程订阅消费的时候线程切换了认证信息就带不过去。解决思路有两个方向一是换继承策略让子线程创建时复制一份父线程的 SecurityContext二是干脆不依赖上下文传递在进入流式链路之前把用户信息显式取出来作为参数传给后续处理。第二个方向我后面会重点讲因为它是最稳的。2.4 为什么“认证一次就够了”在流式场景不成立有人会问登录成功后安全过滤器已经在第一次 REQUEST 时放行了为什么异步派发还要再拦一次因为对 Spring Security 来说过滤器的执行是按派发类型独立触发的。第一次请求放行并不代表 ASYNC 派发自动放行。而且流式连接是长连接如果走 Session 认证Session 在连接期间过期后续的数据推送就可能被新的过滤器判定为未认证。所以对流式接口要么走无状态 JWT 手动校验要么明确告诉 Spring Security“流式路径的异步派发不需要再走认证”两条路选一条。3. 方案一流式接口放行 在业务层手动鉴权前后端分离项目首选这个方案是我最终线上在用的也是最推荐的。思路很朴素流式接口从 Spring Security 的默认保护范围里摘出来然后在 Controller 入参阶段手动校验 JWT。校验通过才继续校验失败直接返回 401。这样既绕开了异步派发、SecurityContext 丢失等一连串问题又保证了接口本身是安全的。3.1 放行配置怎么写才能不影响其它接口还是走SecurityFilterChain只是对/api/chat/stream这个路径单独做配置。注意两点一是 CSRF 要对该路径忽略二是授权规则里该路径要permitAll()但其它接口保持原有保护。Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.ignoringRequestMatchers(/api/chat/stream)) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(/api/chat/stream).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }这里jwtAuthFilter是已有的登录过滤器它会对其它接口自动填充 SecurityContext。流式接口虽然被permitAll()放行了但请求头里的Authorization依然会被这个过滤器解析也就是说流式接口其实“有机会”拿到认证信息。只是我们不依赖它而是在 Controller 里亲自把关双重保障。3.2 在 Controller 里手写 JWT 校验核心逻辑就一句话校验必须在返回 Flux 之前完成不能在doOnNext或doOnSubscribe里做。因为只要方法返回了 FluxMVC 就会先写响应头后续再抛异常就来不及返回 401 了。PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestHeader(Authorization) String authHeader, RequestBody ChatRequest request) { // 在返回 Flux 之前同步校验失败立刻抛异常HTTP 状态码才能正常返回 401 if (authHeader null || !authHeader.startsWith(Bearer )) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, 缺少认证信息); } String token authHeader.substring(7); if (!jwtService.validateToken(token)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, token 无效或已过期); } Long userId jwtService.getUserId(token); // 注意到这里为止一切还是同步执行的userId 已经拿到了 return chatClient.prompt() .user(request.getMessage()) .system(你是工单助手当前处理人 ID 是 userId) .stream() .content() .doOnError(e - log.error(流式对话失败, userId{}, userId, e)); }这段代码里有几个细节值得展开jwtService.validateToken是复用登录模块里已有的 JWT 校验逻辑不需要在新写一套。如果你的项目用的是 Spring Security 的JwtDecoder也可以直接注入进来用jwtDecoder.decode(token)。校验失败抛ResponseStatusExceptionSpring MVC 会把它转成 401 响应。因为这是在返回 Flux 之前发生的所以状态码能正确送达前端。用户 ID 作为普通变量被流式链路闭包捕获后续不管 Flux 在哪条线程上被订阅userId都不会丢。这就是“不依赖上下文传递”的含义。3.3 把当前用户身份注入到 AI 链路拿到 userId 之后怎么用就灵活了。最直接的是像上面那样拼进 system prompt让模型感知当前操作人也可以放进ChatClient的变量上下文里return chatClient.prompt() .user(request.getMessage()) .params(userId, userId) .stream() .content();如果你的业务逻辑里流式过程中还需要动态取用户信息我建议在进入流式链路之前就把需要的用户资料查出来放到一个 DTO 里闭包捕获。不要寄希望于在doOnNext里再去调SecurityContextHolder那样要么拿不到要么代码可读性和可维护性都很差。3.4 这个方案的两个边界第一手动校验只保护了这一个接口如果以后流式接口变多每个都要写一遍校验逻辑容易漏。建议把“解析 token → 校验 → 返回 userId”这段封装成一个私有方法或者一个小组件统一复用。第二permitAll()意味着该接口不经过安全过滤器链的授权判断如果你们有统一的 IP 白名单、限流过滤器放在安全链之后可能也会跟着被绕过。这种情况可以把限流、审计逻辑放到 Controller 层之外的单片过滤器里或者改用方案二。4. 方案二保留安全链通过 ASYNC 放行 上下文继承解决问题如果你的团队要求所有接口必须统一走 Spring Security 的过滤器链不允许在 Controller 里手动鉴权那方案二更适合你。思路是保留对/api/chat/stream的authenticated()要求但对异步派发做特殊处理同时解决上下文丢失问题。4.1 核心配置DispatcherType.ASYNC 放行问题的根源之一是 ASYNC 派发时安全过滤器又跑了一遍。解决办法是在授权规则里明确允许异步派发http .csrf(csrf - csrf.ignoringRequestMatchers(/api/chat/stream)) .authorizeHttpRequests(auth - auth .dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll() .requestMatchers(/api/chat/stream).authenticated() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);这里的关键是dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()。它表示第一次 REQUEST 派发时/api/chat/stream必须通过authenticated()校验但是后续容器发起的 ASYNC 派发和 ERROR 派发直接放行不再重复走认证。这样就能避免第二种“流开到一半被掐断”的现象。4.2 核心配置SecurityContextHolder 的继承策略解决了二次过滤还要解决线程上下文丢失。SecurityContextHolder支持切换策略为MODE_INHERITABLETHREADLOCAL子线程创建时会自动复制父线程里的 SecurityContext。在 Spring Boot 启动类里设置SpringBootApplication public class AiApplication { public static void main(String[] args) { SecurityContextHolder.setStrategyName( SecurityContextHolder.MODE_INHERITABLETHREADLOCAL ); SpringApplication.run(AiApplication.class, args); } }这样设置之后容器在异步派发时如果新建了工作线程线程启动时会带上请求线程的认证信息流式链路里读取SecurityContextHolder就不会是 null 了。4.3 完整 HttpSecurity 与调用链示例把以上两段配置合在一起配合已有的 JWT 过滤器完整链路是前端带 Bearer Token 请求/api/chat/stream→ JWT 过滤器解析 token 并写入 SecurityContext →authorizeHttpRequests首次 REQUEST 校验通过 → 返回 Flux容器提交 ASYNC 派发 → 因dispatcherTypeMatchers(ASYNC)配置直接放行 → 流式链路里通过继承策略拿到用户信息。Controller 里就可以放心用了PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { Authentication auth SecurityContextHolder.getContext().getAuthentication(); Long userId (Long) auth.getPrincipal(); return chatClient.prompt() .user(request.getMessage()) .system(当前操作人 ID: userId) .stream() .content(); }注意一个前提MODE_INHERITABLETHREADLOCAL只对新建的子线程生效。Tomcat 异步执行在某些情况下复用的是请求线程本身那没问题如果线程池是预先创建好的创建时父线程还没有上下文也拿不到。所以这个方案对线程模型有隐性依赖不如方案一那么“人定胜天”。如果你发现配了继承策略偶尔还是拿不到大概率就是线程池线程复用的时序问题这时候老老实实回到方案一。4.4 注意Session 方案下还有 Session 锁问题如果你项目里不是 JWT而是 Session 认证那还得留意一个坑SSE 是长连接请求线程被占住而 Session 在 Tomcat 下默认有锁机制同一 Session 的多线程并发访问会排队。流式连接长时间挂着其它走同一个 Session 的请求可能被阻塞。更早年间用HttpServletResponse直接写 SSE 流的项目经常在这个问题上卡很久。所以在流式场景下我强烈建议用无状态 JWT 而不是 Session这也是我在方案一里额外强调状态无状态化的原因。5. 方案三WebFlux 反应式栈的正确姿势如果你的服务本身就是 WebFlux 架构流式对话其实是天然的优势场景。Spring AI 的Flux和反应式栈是完美匹配的问题只出在你用了错误的上下文读取方式。5.1 反应式 SecurityContext 和 Servlet ThreadLocal 模型完全不同WebFlux 没有 ThreadLocal 这一说认证信息挂在反应式流的Context上通过ReactiveSecurityContextHolder访问。你用SecurityContextHolder.getContext()去拿拿回来的必然是 null。这不是认证失败是你拿错了地方。5.2 从 ReactiveSecurityContextHolder 取认证信息并装配到流正确写法是先把反应式上下文里的认证信息取出来再拼到业务流上PostMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .flatMapMany(auth - { Long userId (Long) auth.getPrincipal(); return chatClient.prompt() .user(request.getMessage()) .system(当前操作人 ID: userId) .stream() .content(); }) .switchIfEmpty( Flux.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, 未登录)) ); }ReactiveSecurityContextHolder.getContext()返回MonoSecurityContext它在整个反应式链路里自动传递不会因为线程切换而丢失。switchIfEmpty处理的是上下文里没有认证信息的情况返回一个错误流。同样要记住这个校验发生在订阅阶段响应头写出之前所以 401 还能正常返回。5.3 两种架构的取舍建议WebFlux 配 SSE 确实清爽但不代表无脑迁移。如果你们的网关、日志、文件上传等周边设施都是基于 Servlet 的强行切 WebFlux 会带来一堆额外成本。我的建议是新项目、纯 API 服务、高并发流式场景优先考虑 WebFlux老项目、周边生态已经成型用方案一的 Servlet 手动校验完全够用没必要为了“架构先进”把这口气咽下去。6. 浏览器端、CORS 与验证清单把最后 20% 的坑填完服务端再怎么对浏览器端细节没处理好流式照样出问题。这里说的是几个最容易让人事后拍大腿的环节。6.1 EventSource 带不了 Authorization 头换个姿势很多前端同学习惯用EventSource消费 SSE但EventSource是浏览器原生 API出于设计限制不支持自定义请求头。于是有人就把 token 放到 query string 里?tokenxxx。这在开发环境能跑但会带来几个问题URL 会出现在 Nginx 访问日志、浏览器历史记录、代理中间商的日志里Token 泄露风险很高。我见过不止一次因为这个上线的后来翻了日志才发现的。正确做法不用EventSource改用fetchReadableStream手动解析 SSE。这样既支持Authorization头也能用POST传参数还能拿到完整的 HTTP 状态码顺便解决部分版本 EventSource 无法读 401 状态的问题浏览器原生 EventSource 对非 200 响应会静默重连排查问题时非常迷惑。const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer localStorage.getItem(token) }, body: JSON.stringify({ message: 帮我总结这份工单 }) }); if (!resp.ok || !resp.body) { throw new Error(HTTP resp.status); } const reader resp.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop() || ; for (const line of lines) { if (line.startsWith(data:)) { const chunk line.slice(5).trim(); if (chunk chunk ! [DONE]) { renderChunk(chunk); } } } }这段代码的核心是手动按data:前缀拆行。Spring AI 返回的 SSE 流每一块数据就是一行data: ...末尾通常会有一个结束标记。用这个方式后端不需要为 EventSource 做任何特殊妥协。6.2 CORS 的精确配置前后端分离必然要处理跨域。流式接口涉及异步请求加上浏览器对事件流的特殊处理CORS 配置容易出几个连带问题忘了允许Authorization头浏览器预检直接失败表现为“请求发了但接口没反应”。allowCredentials(true)配了但没设置具体的allowedOrigins浏览器直接拒绝。长连接被代理缓冲前端一直接不到数据这个和 CORS 无关但容易一起出现。一个可用的配置如下Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(List.of(https://admin.example.com)); config.setAllowedMethods(List.of(GET, POST, OPTIONS)); config.setAllowedHeaders(List.of(Authorization, Content-Type)); config.setMaxAge(3600L); // 用 Bearer Token 时不需要 allowCredentials(true) config.setAllowCredentials(false); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }注意到我特意写了setAllowCredentials(false)。既然认证靠Authorization头就不需要携带 Cookie没必要开启 credentials。开了反而要求allowedOrigins不能是通配符徒增配置复杂度。如果是 Nginx 反代记得在流式接口的响应头里加X-Accel-Buffering: no或者关闭代理对该路径的缓冲否则流式数据会攒在代理层不往下吐前端看起来像“卡住了”。6.3 我的三层验证清单每次改完配置、上线之前我会按下面这个顺序做验证。别嫌麻烦这套流程帮我拦下了至少三次“线上事故”。第一层命令行验证。用 curl 直接测流式接口重点看状态码和头是否正确curl -N -X POST http://localhost:8080/api/chat/stream \ -H Content-Type: application/json \ -H Authorization: Bearer 你的测试token \ -d {message:讲个冷笑话}-N参数表示禁用缓冲能实时看到流式输出。这时候应该看到一行行data:内容往外吐。然后把 Authorization 头去掉再跑一遍应该立刻返回 401而不是先吐几行数据再断掉。第二层浏览器验证。用真实前端页面打开控制台 Network 面板确认请求状态是 200响应头里有Content-Type: text/event-stream登录失效后调用流式接口前端拿到的是 401而不是“连接挂起”或“EventSource 自动重连”。第三层异常链路验证。在流式对话进行到一半时手动把 JWT 改成无效值比如改最后一个字符确认后端行为是“流正常吐完”而不是“吐一半报错”。因为校验在最前面已经完成了流中间不可能再被鉴权逻辑打断这才是正确表现。做完这三层基本上这个认证冲突就彻底闭环了。我最后再分享一个经验遇到这种“两个框架互相打架”的问题别急着在网上搜“万能配置”先把请求的生命周期画清楚——从 REQUEST 派发到 ASYNC 派发认证信息在哪条线程上、什么时候生效、什么时候丢失三个问题答上来解决方案自己就浮现了。Spring Security 和 Spring AI Alibaba 本身都不难难的是它们俩在时序上互相不知道对方的存在而你要做的就是那个在中间牵线的人。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑