1. 项目概述:为什么SSE MCP Server需要API Key鉴权
最近在折腾SpringAI的SSE流式输出,结合MCP Server搞了个智能体应用。东西跑起来挺酷,但一上线就发现个要命的问题:我的API端点谁都能调,这不成公共厕所了吗?尤其是当你的服务里集成了像OpenAI、Anthropic这类按token计费的模型时,一个没有鉴权的端点,分分钟就能让你的账单爆炸,或者被恶意调用导致服务不可用。这可不是危言耸听,我亲眼见过有开发兄弟测试环境没关公网访问,一晚上被刷了几百刀。
所以,今天我们就来聊聊怎么给这个“SpringAI SSE MCP Server”上个锁,实现一套靠谱的API Key鉴权。这不仅仅是加个 Authorization 头那么简单,它涉及到流式场景下的连接管理、密钥的安全存储与验证、以及如何与Spring Security或者更轻量的方案优雅集成。很多教程只讲怎么用 SseEmitter 发数据,但到了生产环境,安全这一关不过,前面所有花里胡哨的功能都是空中楼阁。
简单说,我们要做的是: 在保持Server-Sent Events长连接、低延迟、实时推送特性的前提下,确保每一个连接请求都是经过授权的合法请求。 这适合所有正在或计划将SpringAI用于生产级流式对话、实时数据推送,并且后端集成了MCP Server(Model Context Protocol)来管理工具调用的开发者。无论你是做AI客服、实时报表、还是智能编程助手,这套加固方案都能让你的服务更健壮。
2. 核心方案选型与设计思路拆解
给SSE端点加鉴权,听起来简单,做起来坑不少。首要问题是: 鉴权发生在一个HTTP请求的哪个环节? 对于普通的REST API,我们通常在过滤器(Filter)或拦截器(Interceptor)里校验Token,无效就直接返回401。但SSE连接一旦建立,就是一个持久化的HTTP连接,数据是服务器单向、持续推送的。你不能在连接建立后,每发一条消息都去验一次权(虽然技术上可以,但极度不优雅且开销大)。
因此,最合理的设计是: 在连接建立之初(即客户端发起SSE连接请求时)完成鉴权。 鉴权通过,才创建 SseEmitter 对象并加入连接池;鉴权失败,则立即关闭连接并返回错误。这样,后续的数据流推送就建立在安全的连接之上。
接下来是鉴权方式的选择。常见的有:
- HTTP Basic Auth :简单,但不够安全,密钥在每次请求头中明文传输(除非全程HTTPS),且不易于轮换和管理。
- JWT (JSON Web Token) :无状态,适合分布式,但需要维护令牌的签发与验证逻辑,对于内部服务或简单的API网关场景可能稍重。
- 自定义API Key :最简单直接,也是很多AI服务商(如OpenAI)采用的方式。一个密钥对应一个客户端或一个租户,易于理解和实现。
对于SpringAI SSE MCP Server这种场景,我倾向于选择 自定义API Key 。原因如下:
- 心智负担轻 :客户端调用方式与调用OpenAI API完全一致,在
Authorization头里加个Bearer {api_key}即可,开发者无需学习新协议。 - 管理简单 :服务端维护一个API Key的白名单或数据库表即可,可以轻松绑定额度、调用次数、过期时间等元信息。
- 与MCP Server集成顺畅 :MCP Server本身可能已经有一套工具调用链,API Key鉴权可以作为最外层、最通用的安全防护,不影响内部业务逻辑。
那么,这个API Key从哪里来,存到哪里?我的设计思路是:
- 生成 :由服务管理员在后台生成,可以是一串高强度的随机字符串(如UUID),也可以是有特定格式的字符串。
- 存储 :绝不能硬编码在代码或配置文件中。对于生产环境,应该存储在环境变量、配置中心(如Nacos、Apollo)或者数据库中。这里我推荐结合环境变量和数据库:将主密钥或加密密钥放在环境变量中,将生成的API Key(加密后)和其元信息(如所属项目、状态、过期时间、调用次数)存在数据库里。
- 验证 :实现一个
HandlerInterceptor或Filter,在请求进入SSE控制器之前,截取Authorization头,解析出API Key,然后去查数据库(或缓存)验证其有效性和权限。
3. 核心组件与依赖准备
在动手写代码之前,我们需要把轮子准备好。这个项目基于Spring Boot和SpringAI,所以核心依赖是明了的。
首先,确保你的 pom.xml 或 build.gradle 包含了SpringAI的依赖。这里以Maven为例,你需要类似下面的配置。注意,SpringAI的版本迭代较快,建议使用当前稳定版。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>0.8.1</version> <!-- 请替换为最新版本 -->
</dependency>
<!-- 如果你使用其他模型,如Azure OpenAI、Ollama等,需引入对应starter -->
为了构建SSE端点,我们需要Spring MVC的Web支持:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
对于API Key的存储与验证,我们大概率需要操作数据库。这里选择常用的Spring Data JPA和H2内存数据库(用于演示,生产环境请换用MySQL/PostgreSQL)。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
最后,为了代码的健壮性,可以引入Lombok减少样板代码,以及Spring Boot Configuration Processor以便在 application.yml 中有更好的提示。
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
注意 :依赖版本兼容性是个大坑。特别是SpringAI,它可能对Spring Boot的版本有特定要求。在开始前,最好去 SpringAI官方文档 查看当前版本的兼容性矩阵,避免启动时报各种奇怪的
ClassNotFoundException。
4. 数据库设计与API Key管理
鉴权的核心在于对API Key的有效管理。我们不能简单地把密钥扔到一个 List<String> 里就完事。一个生产可用的设计至少需要记录:密钥本身、所属用户或应用、状态(启用/禁用)、创建时间、最后使用时间、调用次数限额、已用次数等。
我设计了一个简单的 ApiKey 实体,它将是我们在数据库中存储密钥信息的蓝图。
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_keys", indexes = {@Index(columnList = "keyHash")}) // 对哈希值建索引,加速查询
@Data
public class ApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name; // 密钥名称,如“移动端App-Prod”
@Column(nullable = false, length = 500)
private String keyHash; // 存储API Key的哈希值,而非明文
@Column(nullable = false)
private String salt; // 用于哈希的盐值
@Column(nullable = false)
private Boolean enabled = true; // 是否启用
@Column
private LocalDateTime expiresAt; // 过期时间,null表示永不过期
@Column(nullable = false)
private Long totalQuota = 1000L; // 总调用配额
@Column(nullable = false)
private Long usedQuota = 0L; // 已使用配额


633

被折叠的 条评论
为什么被折叠?



