Spring AI聊天接口会话隔离实战:拦截器实现与越权访问防护

发布时间:2026/10/6 18:00:53
Spring AI聊天接口会话隔离实战:拦截器实现与越权访问防护 1. 从一次线上事故说起聊天接口的越权访问到底有多普遍去年冬天我接手了一个基于 Spring AI 搭建的企业内部智能助手项目。上线第三天安全团队就找上门来——有员工通过修改请求里的conversationId直接读到了另一位同事和 AI 的完整对话记录里面甚至包含了一些项目预算讨论的细节。这件事让我意识到聊天接口的会话隔离远不是加个登录校验就完事了它涉及会话标识的生成、归属校验、上下文存储、流式响应等多个环节任何一个环节偷懒都会留下越权访问的口子。这篇文章就是那次事故之后我花了大概两周时间重构会话鉴权体系的完整总结。核心要解决的问题很明确在 Spring AI 聊天接口中如何确保一个用户只能访问属于自己的会话任何试图访问他人会话的请求都必须被拦截。内容会覆盖会话模型设计、拦截器实现、上下文归属校验、流式接口的特殊处理以及我在实际排查中踩过的坑。适合正在用 Spring AI 或 Spring AI Alibaba 做聊天类应用的后端开发也适合任何需要做资源级权限校验的接口开发者参考。先说结论会话隔离的本质是资源归属校验而不是身份认证。很多人把登录态校验当成万能钥匙但登录只回答了你是谁没回答这个会话是不是你的。这两件事必须分开做而且要在每一次涉及会话的请求里都做一遍。2. 会话越权是怎么发生的先搞清楚攻击面2.1 聊天接口的典型请求结构在动手写拦截逻辑之前得先看清楚一个聊天请求长什么样。以 Spring AI 常见的对话接口为例客户端发过来的请求体通常包含这么几个字段{ conversationId: conv_8f3a2b1c, message: 帮我总结一下昨天的会议纪要, stream: true }服务端拿到conversationId之后会去会话存储里捞出历史消息拼进 Prompt再调用大模型。问题就出在捞历史消息这一步——如果代码写成conversationRepository.findById(conversationId)然后直接拼上下文那么只要conversationId猜对了或者泄露了任何人都能读到别人的对话。我见过更离谱的写法有的项目把conversationId直接用用户 ID 拼接比如conv_${userId}_${timestamp}看起来好像绑定了用户但只要知道别人的 userId很多系统里 userId 是自增的肉眼可猜就能构造出对应的会话 ID。这种看起来安全的设计比明摆着不安全还危险。2.2 三类常见的越权路径把攻击面梳理清楚后面写拦截逻辑才有针对性。我总结下来主要是三条路径直接改 ID 访问请求里带别人的conversationId服务端不校验归属直接返回数据。这是最粗暴也最常见的一种。会话列表泄露/conversations/list这类接口返回了不属于当前用户的会话摘要攻击者拿到 ID 后再去访问详情。流式接口绕过普通接口做了校验但 SSE 流式接口stream: true走的是另一套 Controller 方法忘了加校验形成侧门。注意第三条路径特别容易被忽略。很多团队在 REST 接口上做了统一的拦截器但流式接口因为返回类型是Flux或SseEmitter拦截器配置的路径匹配没覆盖到结果就成了漏网之鱼。2.3 为什么不能只靠前端隐藏有同学会说前端不显示别人的会话 ID 不就行了这个思路的问题在于前端隐藏只是看不见不是拿不到。只要接口返回的数据里带了会话 ID或者 ID 有规律可循攻击者用 Postman、curl 直接构造请求就能绕过前端。安全校验必须落在服务端而且要在数据真正被读取之前完成。我在重构时定了一条硬规矩任何以conversationId为入参的接口进入业务逻辑前必须先过归属校验。这条规矩后来被写进了团队的接口规范文档新接口评审时如果没体现这一点直接打回。3. 会话模型设计把归属关系落到数据结构上3.1 会话表的关键字段拦截逻辑能不能写得干净很大程度上取决于会话模型设计得好不好。如果会话表里根本没有归属人这个字段那拦截器再怎么写都是空中楼阁。我最终采用的会话表结构大致是这样字段名类型说明idvarchar(32)会话唯一标识用 UUID 去横线生成user_idbigint归属用户 ID建索引tenant_idbigint租户 ID多租户场景用titlevarchar(128)会话标题取首条消息前若干字statustinyint0 正常 1 已删除created_atdatetime创建时间updated_atdatetime最后活跃时间这里有两个设计点值得展开说。第一id用 UUID 而不是自增 ID。自增 ID 可枚举攻击者从 1 开始遍历就能碰到别人的会话UUID 虽然不能替代权限校验但至少提高了猜测成本属于纵深防御的一环。第二user_id必须建索引。会话列表查询、归属校验都会高频用到这个字段没索引的话数据量一上来查询就会拖慢整个接口。3.2 会话 ID 的生成策略关于会话 ID 的生成我试过几种方案最后选了UUID 前缀的组合public String generateConversationId() { return conv_ UUID.randomUUID().toString().replace(-, ); }前缀conv_的作用是让日志和监控里一眼能认出这是会话 ID排查问题时方便过滤。UUID 本身 122 位随机性碰撞概率可以忽略。有团队喜欢用雪花算法生成 ID也可以但要注意雪花 ID 是趋势递增的如果泄露了时间信息理论上能被推测出生成顺序安全性上不如纯随机。提示不要用userId 时间戳这种看起来绑定了用户的方案。它既没有真正的归属校验拦截器还是得查库又泄露了用户信息属于两头不讨好。3.3 会话与消息的关联消息表通过conversation_id外键关联到会话表这里有个细节消息表不需要冗余user_id。因为只要会话归属校验通过了消息自然就是属于这个用户的。冗余字段反而会带来一致性问题——万一某条消息的user_id和会话的user_id对不上你该信哪个我的做法是消息表只存conversation_id、role、content、created_at归属关系完全由会话表决定单一数据源不会打架。4. 拦截器实现把校验做成一道必经关卡4.1 为什么选拦截器而不是 AOP实现归属校验有好几种方式可以在每个 Service 方法里手动查一遍可以用 AOP 切面也可以用 Spring MVC 的拦截器HandlerInterceptor。我最终选了拦截器理由有三条。第一拦截器在请求进入 Controller 之前执行校验不通过直接返回 403业务代码根本不会被执行避免了先查数据再校验的浪费。第二拦截器能拿到完整的请求信息包括路径、方法、请求体方便做统一的路径匹配和参数提取。第三拦截器配置集中新增接口时只要路径规则覆盖到自动就受保护不像 AOP 那样依赖注解容易漏加。AOP 的问题在于它通常靠注解触发开发者忘了加注解就漏了。而拦截器是默认拦截、白名单放行的思路安全性更高。这个思路上的差异很关键安全机制应该是默认开启的而不是默认关闭、需要手动打开的。4.2 拦截器的核心代码拦截器的实现分三步判断是否需要校验、提取conversationId、查库比对归属。核心代码如下Component public class ConversationAuthInterceptor implements HandlerInterceptor { Autowired private ConversationRepository conversationRepository; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 只拦截聊天相关接口 String uri request.getRequestURI(); if (!uri.startsWith(/api/chat/)) { return true; } // 从登录态里拿当前用户 Long currentUserId UserContext.getCurrentUserId(); if (currentUserId null) { writeError(response, 401, 未登录); return false; } // 提取 conversationId String conversationId extractConversationId(request); if (conversationId null) { // 新建会话场景放行由业务层创建并绑定当前用户 return true; } // 查库校验归属 Conversation conv conversationRepository .findByIdAndStatus(conversationId, 0); if (conv null) { writeError(response, 404, 会话不存在); return false; } if (!conv.getUserId().equals(currentUserId)) { writeError(response, 403, 无权访问该会话); return false; } // 把会话对象塞进上下文业务层直接用避免重复查库 request.setAttribute(currentConversation, conv); return true; } }这段代码里有几个我反复打磨过的细节。第一findByIdAndStatus而不是findById把已删除的会话一并过滤掉避免软删除的会话还能被访问。第二校验通过后把会话对象放进 request 属性业务层直接从属性里取省掉一次重复查询这个优化在高并发下能省不少数据库压力。第三错误码区分 404 和 403会话不存在返回 404存在但不属于你返回 403这样既准确又不会通过错误码泄露这个会话是否存在的信息——等等这里其实有个安全权衡后面第 6 节会专门讲。4.3 注册拦截器与路径匹配拦截器写完还得注册否则不生效。注册时路径匹配要写全尤其是流式接口Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private ConversationAuthInterceptor conversationAuthInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(conversationAuthInterceptor) .addPathPatterns(/api/chat/**) .excludePathPatterns(/api/chat/health); } }/api/chat/**这个通配符能覆盖/api/chat/send、/api/chat/stream、/api/chat/history等所有子路径。我特意把健康检查接口排除掉因为健康检查不需要登录态如果被拦截器拦下会误报服务不可用。注意如果你的流式接口路径不在/api/chat/下比如单独放在/api/sse/那一定要记得把这条路径也加进addPathPatterns。我踩过这个坑流式接口漏配拦截器上线后被测出来返工了一晚上。5. 流式接口的特殊处理SSE 场景下的校验时机5.1 流式响应的校验为什么容易出问题普通接口的校验很直观拦截器返回 false请求就结束了。但流式接口SSE不一样它的响应是分块推送的一旦开始推送HTTP 状态码就已经发出去了这时候再想返回 403 就来不及了。所以流式接口的归属校验必须在响应开始之前完成也就是在拦截器的preHandle阶段或者 Controller 方法的第一行。我见过有项目把校验写在流式回调里结果就是攻击者能收到一部分数据之后才被中断虽然最终没拿到全部但已经泄露了开头的内容。这种半泄露在安全上等同于泄露。5.2 拦截器对流式接口的适配好消息是Spring MVC 的拦截器对 SSE 接口同样生效preHandle会在 Controller 方法执行前调用。所以只要路径匹配覆盖到了第 4 节的拦截器代码不用改就能保护流式接口。真正需要注意的是响应已经提交后的异常处理——如果校验通过后流式推送过程中出了别的错这时候不能再返回 JSON 错误体了得用 SSE 的 error 事件。GetMapping(value /api/chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String conversationId, RequestParam String message) { // 拦截器已经校验过归属这里直接取 Conversation conv (Conversation) request.getAttribute(currentConversation); SseEmitter emitter new SseEmitter(60_000L); // ... 异步推送逻辑 return emitter; }5.3 超时与断连的处理SSE 连接默认会一直挂着如果客户端异常断开服务端得能感知到并释放资源。SseEmitter提供了onCompletion、onTimeout、onError三个回调我一般会在这三个回调里统一做资源清理比如把会话的updated_at更新一下、释放模型调用的连接。这里有个经验超时时间不要设太长我一开始设了 5 分钟结果大量僵尸连接堆积后来改成 60 秒配合前端的心跳保活稳定多了。6. 归属校验的边界情况那些容易想当然的地方6.1 新建会话时没有 conversationId 怎么办新建会话的请求里通常不带conversationId拦截器提取不到这时候不能拦得放行让业务层创建。但放行不等于不管——业务层创建会话时必须强制绑定当前登录用户绝不能信任前端传来的userId。我见过有接口设计成POST /api/chat/create?userId123这就等于把归属权交给了前端攻击者传别人的 userId 就能创建挂在别人名下的会话后续再配合其他漏洞就能搞事情。正确做法是从UserContext里取当前用户前端传什么都不看public Conversation createConversation(String title) { Conversation conv new Conversation(); conv.setId(generateConversationId()); conv.setUserId(UserContext.getCurrentUserId()); // 只信服务端 conv.setTitle(title); conv.setStatus(0); conversationRepository.save(conv); return conv; }6.2 404 还是 403一个安全权衡前面提到会话不存在返回 404存在但不属于你返回 403。这个设计从接口语义上是对的但它有个副作用攻击者可以通过错误码区分会话不存在和会话存在但不是我的从而枚举出哪些会话 ID 是有效的。虽然 UUID 很难猜但如果 ID 通过其他渠道泄露了这个信息就有价值。更严格的做法是统一返回 404不管会话不存在还是不属于你都告诉调用方找不到。这样攻击者无法区分枚举成本更高。我在项目里最终选了这个方案代价是调试时稍微麻烦一点——明明会话存在却报 404得去看日志才知道是权限问题。为了安全这个代价我认。场景宽松方案严格方案推荐会话不存在404404会话存在但非本人403404会话已删除404404调试友好度高低防枚举能力弱强6.3 共享会话的需求怎么处理有些业务场景确实需要共享比如团队协作里多个成员看同一个 AI 会话。这时候不能简单地把归属校验去掉而应该引入会话成员表把归属扩展成访问权限字段名说明conversation_id会话 IDmember_id成员用户 IDroleowner / editor / viewerjoined_at加入时间拦截器的校验逻辑相应改成查成员表里有没有当前用户而不是比对 user_id。这样既支持了共享又保持了校验的严谨性。关键原则是权限判断永远基于数据而不是基于请求参数。7. 常见问题与排查技巧实录7.1 拦截器不生效的几种原因排查拦截器问题时我一般按这个顺序查路径匹配对不对addPathPatterns写的是/api/chat/**但实际请求是/chat/send那就匹配不上。用日志把request.getRequestURI()打出来一眼就能看出。拦截器有没有注册WebMvcConfig类有没有被 Spring 扫描到有没有加Configuration。是不是被其他拦截器短路了如果项目里有多个拦截器执行顺序由注册顺序决定前面的拦截器返回 false后面的就不执行了。静态资源路径干扰有时候请求被当成静态资源处理了压根没进拦截器链。7.2 会话 ID 提取失败的排查extractConversationId这个方法要根据请求方式分别处理GET 请求从 query 参数取POST 请求从 body 取。如果只处理了一种另一种就会提取失败导致校验被跳过因为提取不到就放行了。我的做法是提取失败时记录 warn 日志这样即使逻辑有漏洞日志里也能发现异常请求。private String extractConversationId(HttpServletRequest request) { String id request.getParameter(conversationId); if (id ! null) return id; // POST body 场景用 ContentCachingRequestWrapper 读缓存 if (request instanceof ContentCachingRequestWrapper wrapper) { String body new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8); // 解析 JSON 取 conversationId return JsonUtils.extractField(body, conversationId); } return null; }注意HttpServletRequest的 body 默认只能读一次读完之后 Controller 就读不到了。所以要用ContentCachingRequestWrapper包装或者干脆在拦截器里不读 body改成在 Controller 方法参数上做校验。我后来选了后者更简单也更可靠。7.3 高频问题速查表现象可能原因解决方向能访问他人会话拦截器路径没覆盖检查 addPathPatterns流式接口越权SSE 路径漏配补上流式接口路径新建会话报 403拦截器把无 ID 请求拦了无 ID 时放行业务层绑定校验通过但查不到数据会话被软删除查询加 status 条件并发下偶发越权上下文串了检查 UserContext 是否线程隔离性能明显下降每次请求都查库加缓存或复用 request 属性7.4 一个隐蔽的并发坑最后说一个我踩过的坑UserContext如果用ThreadLocal存当前用户在异步流式场景下会失效。因为 SSE 的推送是在另一个线程里执行的ThreadLocal取不到值结果就是校验时用的是主线程的用户推送时取到的是 null 或者别的用户。解决办法是用InheritableThreadLocal或者干脆把用户信息作为参数一路传下去。这个坑在同步接口上完全看不出来只有流式接口才会暴露排查起来相当费劲。8. 我在实际项目中的几点体会重构完这套会话鉴权体系之后我最大的感受是安全校验的难点从来不是写代码而是想清楚哪些请求需要校验、校验什么、校验失败怎么办。代码本身可能就几十行但背后的设计决策——用拦截器还是 AOP、404 还是 403、无 ID 时放行还是拦截——每一个都需要结合业务场景权衡。另外分享一个实用的小技巧我在拦截器里加了一个开关配置chat.auth.enabled默认开启但在本地开发和单元测试时可以关掉。这样既保证了生产环境的安全又不影响开发效率。上线前我特意检查了这个开关在生产配置里是true这种默认安全、显式关闭的设计比反过来要靠谱得多。后续如果要做更细粒度的权限比如按消息级别控制、按时间窗口限制访问可以在会话成员表的基础上继续扩展。但无论怎么扩展核心原则不变权限判断永远基于服务端数据永远不信任前端传来的身份信息。这条原则守住了大部分越权问题都能挡在门外。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询