☰
Spring AI Alibaba流式对话与Spring Security认证冲突的解决方案
2026/10/3 4:11:35 网站建设 项目流程

最近项目里接Spring AI Alibaba做流式对话,结果被Spring Security的认证冲突折腾了整整一个下午。接口单独调通义千问没问题,一走到统一的认证体系里就出各种幺蛾子:要么加上登录限制后SSE流式连接一直挂起、要么好不容易出流但写到一半直接断开、要么只是把接口放在permitAll里才能跑起来。如果你也在接Spring AI Alibaba的流式对话,并且项目里恰好有Spring Security这套认证体系,这篇文章应该能帮你把问题一次理清。

我要聊的是“认证冲突”到底发生在哪一环、根因是什么,以及四种可落地的解决方案。我会给你一套MVC场景下能直接跑的示例代码,也会把我在排查时遇到的高频报错整理成速查表。本文适合两类人:一类是在老项目里接入AI能力、不想动整体安全架构的Java后端,另一类是刚接触SSE流式接口、对SecurityContext和异步线程还没建立起直觉的Spring新手。耐心看完,至少能少踩一半坑。

1. 先说清楚:这里的“认证冲突”到底发生在哪一环

1.1 流式对话为什么容易和Security“打架”

一句话解释:Spring Security原本按“一次请求、一次认证、一次放行”的思路工作,而SSE流式响应天然要求“一次请求、多次分段写入”。这两者本来不至于冲突,但一旦涉及异步线程,事情就变了。

一个普通Controller接口,请求进来经过Spring Security过滤器链,认证通过后把用户信息放进SecurityContextHolder,然后调用Service层,最后返回一个Model或ResponseEntity。整个过程中,请求线程一直活着,SecurityContextHolder里的数据始终可读。所以你在Service里随便SecurityContextHolder.getContext().getAuthentication()都能拿到当前用户。

但流式接口不一样。Controller返回的不是普通响应体,而是SseEmitter或StreamingResponseBody。这个方法一返回,请求线程就会被释放,真正往浏览器或客户端推送数据的是容器里的异步线程池。而Spring Security默认的上下文策略是用ThreadLocal保存认证信息,父线程的ThreadLocal数据不会自动传递给子线程。于是异步线程里去读SecurityContext,十有八九是空的。这就是“认证冲突”的第一层根源。

1.2 我踩过的最典型三种表现

我在多个项目里反复见过这三种奇怪现象,你可以对照下自己遇到的是哪一种。

第一种:接口放在permitAll里能正常出流,但业务代码拿不到当前登录人。这是最常见的情况。你把流式接口放行了,Security过滤器不再拦截,SSE肯定能连上,但任何需要用户身份的逻辑全部失效。如果流式对话里要按用户拼接上下文、查数据库、记录消费额度,这一步就卡死了。

第二种:接口没放行,前端带Token请求,结果直接302或401。这种情况往往不是SecurityContext丢失,而是流式接口本身没有走你预期的认证路径。前端用EventSource发SSE请求时,有些浏览器会忽略自定义Header,导致Authorization带不过去,服务端自然认为没有认证信息。这是另一个容易被误判的坑。

第三种:认证明明通过了,流却写到一半断开,日志里报AuthenticationCredentialsNotFoundException。这种情况多半是异步线程在执行emitter.send()时拿不到SecurityContext,或者写响应时触发了并发/超时校验。典型表现是:前几个event能收到,后面突然断掉,毫无规律。

1.3 先分清Spring MVC还是WebFlux

排查这类问题,第一件事不是改代码,而是确认你的项目是基于Spring MVC还是Spring WebFlux。因为Spring AI Alibaba的starter会智能适配Web栈,但两种栈的处理思路完全不同。

Spring MVC项目里,流式对话依赖SseEmitter或StreamingResponseBody,Security上下文存在ThreadLocal,核心矛盾是“异步线程丢失上下文”。Spring WebFlux项目里,流式对话通常直接返回Flux<ServerSentEvent>或Flux<String>,Security上下文放在Reactor的Context里,核心矛盾变成了“怎么把认证信息传递进响应式链路”。

我把这两个栈的关键差异放在一张表里,排查前先对号入座。

对比项Spring MVCSpring WebFlux
流式实现方式SseEmitter、StreamingResponseBodyFlux 、Flux
认证上下文存储SecurityContextHolder(ThreadLocal)ReactiveSecurityContextHolder(Reactor Context)
线程模型一个请求一个线程,异步后线程切换全异步、非阻塞、响应式线程池
典型冲突点异步线程里SecurityContext丢失Reactor Context链路传播被切断
排查侧重点线程ID、DispatcherType、手动恢复上下文订阅时间点、contextWrite、onContextLost

明确了这个前提,后面的方案才能落到正确的位置上。

2. 冲突的根因:SSE、SecurityContext和异步线程

2.1 认证信息到底存放在哪里

我们先看一下Spring Security在经典MVC流程里是怎么保存用户信息的。一个请求进来,过滤器链里的UsernamePasswordAuthenticationFilter或自定义的Jwt过滤器解析Token,生成Authentication对象,然后执行SecurityContextHolder.getContext().setAuthentication(auth)。

注意,这个SecurityContextHolder底层用的是ThreadLocal。你可以把它理解成一个“当前线程专属的储物柜”,每个线程只能看到自己柜子里的东西。线程A放了用户信息,线程B是读不到的。这么设计本来是为了隔离并发,但在异步场景里就成了坑。

更关键的是,Spring Security默认的ThreadLocal策略并不是可继承的。也就是说,即使你在线程池里创建了一个新线程,新线程也不会自动复制父线程的SecurityContext。所以一旦Controller返回SseEmitter,后续发送事件的线程已经换人了,SecurityContextHolder自然就成了一张白纸。

2.2 SSE流式响应的两个阶段与线程切换

SSE流式响应可以拆成两个阶段来看。

阶段一:请求进入Spring Security过滤器链,认证通过,DispatcherServlet匹配到Controller方法,Controller创建SseEmitter并立即返回。这个阶段里,请求线程还活着,SecurityContextHolder里也有完整的用户信息。

阶段二:容器进入ASYNCdispatch模式,把写响应的任务交给异步线程。应用可以通过SseEmitter.send()或StreamingResponseBody.writeTo()把数据一段段推到客户端。但此时请求线程已经归还给容器,异步线程里没有SecurityContext。

这两个阶段之间还夹着一个容易被忽略的点:Spring Security默认不会在ASYNC dispatch时重新执行完整的认证流程。这本来是件好事,避免了异步请求被重复拦截,但也意味着异步线程里不会有“重新认证”的机会,只能靠我们自己手动恢复上下文。

2.3 认证为什么“通过一半”

很多人的疑问是:明明请求已经通过了认证校验,为什么流式接口还是出问题?

因为“认证通过”只代表阶段一完成了。Spring Security做了它该做的事:验证了Token、设置了SecurityContext、让请求进入了Controller。但阶段二里真正写数据的是另一个线程,那个线程并没有继承认证状态。说得直白点,就像一个人过了安检进了站,但他的行李箱没有跟着他上车,最后到了目的地却发现箱子里什么都没有。

另外还有一种“通过一半”的情况和CSRF有关。如果你仍然保留着Spring Security默认的CSRF校验,而SSE接口又是通过POST方式建立的,那么异步响应阶段可能会再次触发CSRF检查,导致Invalid CSRF Token之类的异常。大多数无状态API项目会直接关闭CSRF,但如果你的项目保留了它,遇到流式接口异常时也要往这个方向排查。

3. 四种解决思路,按项目情况选

3.1 方案A:接口放行 + 手动验Token

这个方案最省事,适合老项目里快速接入AI能力。做法很直接:在SecurityFilterChain里对/api/ai/stream/**这类路径配置permitAll,然后在Controller方法入口处自己解析请求头里的Token,验完再往下走。

好处是改动小,不碰全局安全体系,也不需要在异步线程里去恢复上下文。坏处是脱离了Spring Security统一管理,登录审计、RBAC权限判断、Token刷新这些能力在这个接口上全部失效,得自己实现。

我的建议是:如果只是做一个内部工具类AI问答接口,这个方案够了;如果流式接口涉及用户核心数据,不要偷懒,至少要把Token解析逻辑统一复用。

http.authorizeHttpRequests(auth -> auth .requestMatchers("/api/ai/stream/**").permitAll() .requestMatchers("/api/user/**").authenticated() .anyRequest().authenticated() );

Controller里手动验Token,验不过直接抛异常或返回401,验过了就手动把Authentication塞进SecurityContextHolder。注意,这个时候塞进去的上下文只在当前Controller线程有效,如果后续要用异步线程发送数据,还得配合方案B处理。

3.2 方案B:异步线程里恢复SecurityContext

这个方案适合必须保留认证信息、又必须用自己的异步线程来发送SSE数据的场景。核心就一句话:在异步任务真正执行之前,把当前Authentication对象手动赋给新线程的SecurityContextHolder。

Spring Security其实已经提供了工具类,你可以直接使用SecurityContextHolder.createEmptyContext()来创建新上下文,然后setContext()放到新线程里。更省事的方式是使用SecurityContextCallable这类包装器,但很多人不知道,所以经常自己踩坑。

在实际代码里,我倾向于自己封装一个Runnable或Callable,在run方法开头恢复上下文,在finally里清理掉。清理这一步非常重要,因为异步线程是线程池复用的,不清理的话,下个任务可能读到上一个用户的认证信息,这就是典型的数据串号Bug。

3.3 方案C:WebFlux里别折腾ThreadLocal

如果你的项目是基于Spring WebFlux的,方案B完全不适用。因为WebFlux是响应式链路,没有“一个请求一个线程”的对应关系,你就算手动set ThreadLocal也没用,反而不符合Reactor的工作方式。

WebFlux下的正确做法是使用ReactiveSecurityContextHolder。它利用Reactor Context传递认证信息,在响应式链路里,上游通过contextWrite()或SecurityContextRepository写入,下游通过ReactiveSecurityContextHolder.getContext()读取。

Spring AI Alibaba在WebFlux模式下,流式接口返回的是Flux<String>,你可以让这个Flux通过deferContextual()拿到认证信息,然后把用户身份传给通义千问的调用逻辑。只要保证认证上下文是在订阅之前写入的,整条链路都能读到。

3.4 方案D:自建Filter全链路接管

如果上面的方案都满足不了你,比如你的认证体系非常特殊,或者需要彻底绕开Spring Security的过滤器链结构,那可以考虑自建一个Filter,在SecurityFilterChain之前接管流式路径。

这种方案的本质是把“认证”这件事完全掌握在自己手里:你自己解析Token、自己创建Authentication、自己决定放不放行。Spring Security只需要保证对这部分路径不拦截,或者你干脆让自己的Filter排在前面,抢先处理。

我不建议常态使用这个方案,因为它等于抛弃了Spring Security的核心能力。但它确实是某些极端情况下的救命稻草,比如你的Spring Security版本和Spring AI Alibaba的兼容性出了问题,短时间查不出根因时,可以先用自建Filter把业务救起来,再慢慢定位问题。

4. 实战案例:MVC + SseEmitter手动恢复上下文

4.1 准备依赖和配置

我先给你一套我实测可用的MVC场景示例。技术上不复杂,但每一步背后都有前面分析的逻辑支撑。

在pom.xml里需要引入Spring AI Alibaba相关的starter。不同版本的坐标有差异,我这里用简化写法,具体版本号请以官方文档为准。骨架依赖大概是下面这个样子:

<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>

接着在application.yml里配置通义千问的API Key:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} # model: qwen-plus

这里我用的是通义千问的标准配置项。如果你用的是百炼平台或其他兼容渠道,配置项可能略有不同,但整体思路一致。

4.2 核心代码:安全配置与Token校验

首先生成一个自定义的Jwt认证过滤器。这个过滤器只负责解析我们认可的Authorization头,并把Authentication对象放进SecurityContextHolder:

@Component public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); Authentication auth = TokenParser.parse(token); if (auth != null) { SecurityContextHolder.getContext().setAuthentication(auth); } } chain.doFilter(request, response); } }

然后配置SecurityFilterChain。对流式路径先放行,因为后面的异步流程里Security不会重新认证,我们要自己在Controller里校验:

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/ai/stream/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

4.3 核心代码:流式接口与异步上下文恢复

接下来是流式接口的核心。我使用SseEmitter来推送数据,为了让SecurityContext能传到异步线程,我在线程内部手动设置Authentication,并通过doOnSubscribe再强化一次,确保响应式链路的第一个节点能读到用户信息。

@RestController @RequestMapping("/api/ai/stream") public class ChatStreamController { private final ChatClient chatClient; private final TokenParser tokenParser; public ChatStreamController(ChatClient chatClient, TokenParser tokenParser) { this.chatClient = chatClient; this.tokenParser = tokenParser; } @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestHeader(value = "Authorization", required = false) String authHeader) { // 手动校验Token,这步不能省 Authentication auth = tokenParser.parseOrThrow(authHeader); SseEmitter emitter = new SseEmitter(60_000L); Flux<String> contentFlux = chatClient.prompt() .user("讲一个关于Java开发者的段子") .stream() .content(); contentFlux .doOnSubscribe(s -> { // 在订阅时恢复认证上下文 SecurityContextHolder.getContext().setAuthentication(auth); }) .doOnNext(chunk -> { emitter.send(SseEmitter.event().name("message").data(chunk)); }) .doOnComplete(() -> { emitter.complete(); SecurityContextHolder.clearContext(); }) .doOnError(ex -> { emitter.completeWithError(ex); SecurityContextHolder.clearContext(); }) .subscribe(); return emitter; } }

这段代码的思路是:在Controller线程里完成Token校验,创建SseEmitter;在Flux订阅时,把Authentication写进当前线程的SecurityContextHolder;发送事件时,所有doOnNext逻辑都能读到用户身份。

为什么要在doOnSubscribe里设置上下文?因为Spring AI内部的流式调用可能在独立的线程池里执行,订阅动作一旦发生,后续回调就都在那个线程里。你不在这里设置,后面读到的就是空上下文。

4.4 验证与踩坑细节

写完代码,先用curl -N验证:

curl -N -H "Authorization: Bearer <token>" \ http://localhost:8080/api/ai/stream/chat

-N参数关闭curl的缓冲,让它收到数据就立刻打印。如果能看到一段段文字实时刷出来,说明流式接口通了。这里有几个我踩过的细节,专门提醒一下。

第一个坑是SseEmitter的超时时间。有些人直接new SseEmitter(),默认30秒超时,通义千问如果思考时间超过30秒就会断开,前端表现为“流只出一半”。我习惯按业务需要设置成60秒或更长,甚至0L表示不超时,但要注意配合服务端和网关的超时设置。

第二个坑是HTTP响应头的Content-Type。Controller必须用produces = MediaType.TEXT_EVENT_STREAM_VALUE,如果返回的是application/json,前端EventSource解析不到事件流格式,可能报错或者说“连接失败”。

第三个坑是不要在doOnError里再调用emitter.send()。连接已经出错时,再发数据大概率会抛AsyncRequestNotUsableException。正确做法是completeWithError(ex),把错误交给容器处理,让前端关闭本次连接。

5. 排查实战:报错速查与三条检查顺序

5.1 常见异常与定位

我在几个项目里遇到过这些具体报错,整理成一个速查表,你看到任何一个都能顺藤摸瓜。

报错或现象大概率原因解决方向
AuthenticationCredentialsNotFoundException异步线程里SecurityContext为空在异步逻辑执行前手动setContext
No SecurityContext found in ThreadLocal手动恢复上下文的时机不对在订阅、任务执行前恢复,而不是之后
SseEmitter连接后一直挂起,不触发任何事件超时时间太小或模型响应较长但连接被掐断调大SseEmitter超时时间,排查反向代理buffering
AsyncRequestNotUsableException在客户端断开或连接超时后还尝试写响应send前捕获异常,用completeWithError优雅关闭
接口在permitAll后还是401路径匹配错误,或前端EventSource没带Authorization头用原生EventSource时通过withCredentials或改用fetch+ReadableStream
Invalid CSRF Token保留了CSRF校验但SSE接口是异步写响应无状态API直接关闭csrf,或对相关路径排除

其中“接口放行了还401”最容易误导人。很多人以为是Security配置没生效,其实是你拿EventSource请求,浏览器默认不允许在SSE连接上设置自定义Header,Token根本没传到服务端。这时候后端再放行也没用,因为前端连路径都带不上认证信息。解决办法有两个:要么让前端改用fetch加ReadableStream消费SSE流,要么把Token放在URL的query参数里(不推荐,但有时候确实省事)。

5.2 按顺序排查,少走弯路

我给后来者总结一条排查顺序,基本上照着走能省一小时。

第一步:确认Web栈。看一眼pom里是spring-boot-starter-web还是spring-boot-starter-webflux,然后决定用ThreadLocal思路还是Reactor Context思路。两种思路混用,代码怎么改都不对。

第二步:确认安全过滤器对目标路径的匹配规则。用Debug或直接看日志,确认Spring Security有没有拦截到这条流式请求。很多时候问题不在异步线程,而在于你的requestMatchers根本没有匹配上你想要的地址。

第三步:在Controller入口、Flux订阅时、SseEmitter.send回调里分别打印线程ID和SecurityContext内容。如果线程ID不一样,就说明是异步切换导致上下文丢失,直接往手动恢复方向查;如果线程ID一样但没有上下文,说明是过滤器根本没写入。

第四步:用curl做最小验证,排除前端干扰。SSE这块有个特点,前端库的兼容性问题往往会让真实后端报错变得难以定位,先用curl把服务端行为验证清楚,再回头查前端。

5.3 几个值得留意的细节

除了报错本身,还有几个跟SSE+Security搭配时的隐藏细节。

优先考虑是否关闭gzip压缩。SSE是长连接、分段推送,如果代理层开了gzip,可能会为了凑压缩块而缓冲数据,导致前端好几秒收不到任何内容。你如果发现流式接口“有数据但就是一截一截地卡”,先用curl带--compressed或者临时关闭gzip试试。

前端如果用的是EventSource,默认请求方式是GET,而且不能自定义Header。如果你的接口设计成需要Authorization头,请务必考虑这一点。我见过不少项目在联调阶段反复“认证失败”,最后发现前端EventSource压根没把Token发过来。

还有一点是线程池。如果你在项目里手动创建了Executors.newFixedThreadPool()去执行异步发送任务,注意这个线程池的线程不会继承任何上下文。我在项目里会自己封装一个SecurityContextAwareExecutor,把所有任务包装成带上下文恢复的Runnable,这样所有业务侧线程都统一处理,避免到处手动setContext。

6. 关于spring-ai-alibaba的维护状态与最终心得

6.1 项目到底是不是“停更”

接Spring AI Alibaba的开发者社区里经常有人问“spring ai alibaba是不是停更了”。我自己的观察是:这个项目一直有提交和发布,只是发布节奏不像互联网大厂内部工具那么频繁,容易让人产生“停更”的错觉。更合理的判断标准是看三点:GitHub仓库是否还有持续commit、阿里云官方文档是否还在维护、Spring Initializr里是否还有对应starter入口。这几个信号都正常的话,就不用太担心。

对普通业务项目来说,其实不用纠结“停更”这个词。只要接口兼容Spring AI的标准编程模型,流式调用、聊天记忆、结构化输出这些能力都是稳定可用的。选型时关注的是功能是否满足业务,而不是某个项目是否每周提交一次代码。

6.2 我的最终选型建议

如果你现在正准备把Spring AI Alibaba的流式对话接进一个已有Spring Security体系的项目,我的建议是这样:

第一步,先想清楚你的流式接口需不需要“用户的完整认证信息”。如果只是匿名问答,直接在SecurityConfig里permitAll,然后什么都不用改。如果需要知道当前用户是谁,那就在流式路径放行后,进Controller手动验Token,并在异步发送链路里恢复SecurityContext。

第二步,不要为了绕坑把整个Security都废掉。我见过一些项目为了流式接口省事,直接在配置里http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()),结果整个后台所有接口都没了防护。这种接法短期爽了,审计时一定出问题。

第三步,把“统一处理异步上下文”做成基础设施,而不是在每个Controller里复制粘贴。我自己会在项目里维护一个SecurityContextAwareExecutor和一个SecurityContextRestoreHelper,所有涉及异步线程边界的地方都走这两个工具类。这样一来,新同事接流式接口时不需要理解ThreadLocal的细节,照着模板写就行。

踩过几次坑之后,我自己对这个问题的理解变成了八个字:异步边界、手动恢复。SSE和Spring Security本身没有不可调和的矛盾,矛盾只在于认证上下文没有越过线程边界。只要你在代码里明确“哪个线程负责写数据,就在那个线程里恢复身份”,绝大多数诡异问题都能消解。这不仅是Spring AI Alibaba的问题,所有异步流式接口在接入认证框架时都会遇到类似的坎。希望这篇文章能让你少走这一趟弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询