第1864篇:Spring AI与Spring Security OAuth2的深度集成——API密钥与JWT的统一管理
第1864篇:Spring AI与Spring Security OAuth2的深度集成——API密钥与JWT的统一管理
我见过一类项目死亡方式特别典型:AI 功能做得很好,用户也爱用,但是安全这块一塌糊涂。API Key 写死在代码里,或者干脆没有认证,任何人能访问就能白嫖你的模型调用费用。
等被刷了一万块的账单才来问我怎么办,这时候已经晚了。
AI 项目的安全问题,比传统 Web 项目要复杂一点,因为它有两层 API Key 需要管:一层是你调用 OpenAI 等服务商的 API Key,一层是你暴露给外部用户或系统的 API Key。这两层混在一起不管,或者用同一套机制处理,都是问题。
今天这篇把 Spring Security OAuth2 和 Spring AI 的安全集成讲清楚:API Key 管理、JWT 认证、权限粒度控制,以及一些实际项目中的安全最佳实践。
一、明确两层安全问题
在开始写代码之前,先把问题说清楚:
安全层1 解决的问题:谁可以调用你的 AI 接口?调用频率有没有限制?不同用户能访问哪些功能?
安全层2 解决的问题:OpenAI 的 API Key 存在哪里?怎么防止泄漏?怎么做轮换?
这两个问题都要解决,缺一个都是隐患。
二、第一层:用 Spring Security 保护 AI 接口
先搭基础的 Spring Security 配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>基础 Security 配置:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // API 场景关闭 CSRF
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 健康检查不需要认证
.requestMatchers("/actuator/health").permitAll()
// 文档接口不需要认证(按需)
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
// AI 接口需要认证
.requestMatchers("/api/v1/chat/**").authenticated()
// 管理接口需要 ADMIN 角色
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// 支持两种认证方式:JWT 和 API Key
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())))
.addFilterBefore(new ApiKeyAuthFilter(apiKeyService()),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}三、支持 API Key 认证
很多 B2B 场景,合作方不用 OAuth2 那一套,而是用 API Key 来调用接口。需要自定义一个 Filter 来处理这种认证方式:
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private final ApiKeyService apiKeyService;
public ApiKeyAuthFilter(ApiKeyService apiKeyService) {
this.apiKeyService = apiKeyService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String apiKey = request.getHeader(API_KEY_HEADER);
if (StringUtils.hasText(apiKey) &&
SecurityContextHolder.getContext().getAuthentication() == null) {
// 验证 API Key 并获取关联的用户信息
Optional<ApiKeyPrincipal> principal = apiKeyService.validate(apiKey);
if (principal.isPresent()) {
ApiKeyAuthenticationToken auth = new ApiKeyAuthenticationToken(
principal.get(),
apiKey,
principal.get().getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(auth);
log.debug("API Key 认证成功: appId={}", principal.get().getAppId());
} else {
log.warn("无效的 API Key: {}", maskApiKey(apiKey));
}
}
filterChain.doFilter(request, response);
}
private String maskApiKey(String key) {
if (key.length() <= 8) return "****";
return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
}
}API Key 认证 Token:
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final ApiKeyPrincipal principal;
private final String credentials;
public ApiKeyAuthenticationToken(ApiKeyPrincipal principal,
String credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() { return credentials; }
@Override
public Object getPrincipal() { return principal; }
}
@Data
@Builder
public class ApiKeyPrincipal {
private String appId;
private String appName;
private List<GrantedAuthority> authorities;
private RateLimitConfig rateLimitConfig; // 该 appId 的限流配置
private Set<String> allowedModels; // 允许使用的模型列表
}四、API Key 的存储和管理
API Key 不能明文存数据库,要做哈希处理。这和密码存储的原则一样:
@Service
@Slf4j
public class ApiKeyService {
private final ApiKeyRepository apiKeyRepository;
private final PasswordEncoder apiKeyEncoder;
// 本地缓存,减少数据库查询
private final Cache<String, Optional<ApiKeyPrincipal>> cache;
public ApiKeyService(ApiKeyRepository apiKeyRepository) {
this.apiKeyRepository = apiKeyRepository;
// API Key 用 BCrypt 哈希
this.apiKeyEncoder = new BCryptPasswordEncoder(10);
// Guava Cache,缓存 5 分钟
this.cache = CacheBuilder.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
}
/**
* 生成新的 API Key
* 返回明文(只在创建时返回一次,之后无法查看)
*/
public ApiKeyCreateResult createApiKey(String appId, String appName,
List<String> roles) {
// 生成随机 API Key: sk-{appId前缀}-{随机32字节hex}
String rawKey = "sk-" + appId.substring(0, Math.min(8, appId.length()))
+ "-" + generateSecureRandom();
String hashedKey = apiKeyEncoder.encode(rawKey);
ApiKeyEntity entity = ApiKeyEntity.builder()
.appId(appId)
.appName(appName)
.keyPrefix(rawKey.substring(0, 12)) // 存前缀用于展示
.hashedKey(hashedKey)
.roles(roles)
.enabled(true)
.createdAt(Instant.now())
.build();
apiKeyRepository.save(entity);
// 只返回一次明文
return ApiKeyCreateResult.builder()
.apiKey(rawKey)
.keyPrefix(entity.getKeyPrefix())
.build();
}
/**
* 验证 API Key
*/
public Optional<ApiKeyPrincipal> validate(String rawKey) {
// 先从缓存查(缓存的是验证结果,不是明文key)
String cacheKey = DigestUtils.sha256Hex(rawKey);
return cache.get(cacheKey, () -> {
// 缓存未命中,查数据库
// 注意:无法通过哈希直接查,需要根据 prefix 缩小范围
String prefix = rawKey.length() > 12 ? rawKey.substring(0, 12) : rawKey;
List<ApiKeyEntity> candidates = apiKeyRepository.findByKeyPrefix(prefix);
return candidates.stream()
.filter(ApiKeyEntity::isEnabled)
.filter(e -> apiKeyEncoder.matches(rawKey, e.getHashedKey()))
.findFirst()
.map(this::toPrincipal);
});
}
private String generateSecureRandom() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private ApiKeyPrincipal toPrincipal(ApiKeyEntity entity) {
List<GrantedAuthority> authorities = entity.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return ApiKeyPrincipal.builder()
.appId(entity.getAppId())
.appName(entity.getAppName())
.authorities(authorities)
.build();
}
}五、第二层:AI 服务商 API Key 的安全管理
这是更多团队忽视的部分。OpenAI 的 API Key 怎么存、怎么用,直接关系到账单安全。
方案一:环境变量 + Spring 配置加密
最简单的方案,生产环境通过环境变量注入:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}Kubernetes 环境用 Secret:
apiVersion: v1
kind: Secret
metadata:
name: ai-secrets
type: Opaque
data:
openai-api-key: <base64编码的key>
---
# Deployment 里引用
env:
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: ai-secrets
key: openai-api-key方案二:动态 API Key 管理(支持轮换)
更进一步,实现一个 ApiKeyRotationManager,支持 API Key 的轮换和多 Key 负载均衡:
@Component
@Slf4j
public class AiProviderKeyManager {
private final List<String> apiKeys;
private final AtomicInteger currentIndex = new AtomicInteger(0);
private final Map<String, KeyHealthStatus> keyHealthMap = new ConcurrentHashMap<>();
public AiProviderKeyManager(
@Value("${spring.ai.openai.api-keys}") List<String> apiKeys) {
this.apiKeys = new ArrayList<>(apiKeys);
apiKeys.forEach(key ->
keyHealthMap.put(maskKey(key), new KeyHealthStatus(true, 0)));
}
/**
* 轮询获取健康的 API Key
*/
public String getNextHealthyKey() {
int attempts = 0;
while (attempts < apiKeys.size()) {
int index = currentIndex.getAndIncrement() % apiKeys.size();
String key = apiKeys.get(index);
KeyHealthStatus status = keyHealthMap.get(maskKey(key));
if (status.isHealthy()) {
return key;
}
attempts++;
}
throw new NoHealthyApiKeyException("所有 API Key 均不可用");
}
/**
* 标记某个 Key 失败(比如收到 429 或 401)
*/
public void markKeyFailed(String key, String reason) {
String maskedKey = maskKey(key);
log.warn("API Key 标记失败: {} 原因: {}", maskedKey, reason);
keyHealthMap.compute(maskedKey, (k, v) -> {
if (v == null) return new KeyHealthStatus(false, 1);
v.incrementFailCount();
if (v.getFailCount() >= 3) {
v.setHealthy(false);
log.error("API Key {} 已标记为不健康,连续失败{}次", maskedKey, v.getFailCount());
}
return v;
});
}
private String maskKey(String key) {
return key.length() > 8 ? key.substring(0, 7) + "****" : "****";
}
@Data
static class KeyHealthStatus {
private boolean healthy;
private int failCount;
public KeyHealthStatus(boolean healthy, int failCount) {
this.healthy = healthy;
this.failCount = failCount;
}
public void incrementFailCount() { this.failCount++; }
}
}六、在 ChatClient 调用中集成安全上下文
把安全上下文和 AI 调用结合起来,实现按用户的权限来限制模型使用:
@Service
@Slf4j
public class SecureChatService {
private final Map<String, ChatClient> chatClientMap; // 模型名 -> ChatClient
private final ChatClient defaultChatClient;
public SecureChatService(
ChatClient defaultChatClient,
@Qualifier("gpt4Client") ChatClient gpt4Client) {
this.defaultChatClient = defaultChatClient;
this.chatClientMap = Map.of(
"gpt-4o", gpt4Client,
"default", defaultChatClient
);
}
public String chat(String message, String requestedModel) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = extractUserId(auth);
// 根据用户权限选择可用的模型
String actualModel = resolvePermittedModel(auth, requestedModel);
ChatClient client = chatClientMap.getOrDefault(actualModel, defaultChatClient);
log.info("用户 {} 使用模型 {} 发起对话", userId, actualModel);
return client.prompt()
.user(message)
.call()
.content();
}
private String resolvePermittedModel(Authentication auth, String requested) {
// 普通用户只能用默认模型
if (!hasRole(auth, "PREMIUM") && !hasRole(auth, "ADMIN")) {
if (!"default".equals(requested)) {
log.info("用户无权使用模型 {},降级为 default", requested);
}
return "default";
}
// PREMIUM 用户可以用 gpt-4o
return requested != null ? requested : "default";
}
private boolean hasRole(Authentication auth, String role) {
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_" + role));
}
private String extractUserId(Authentication auth) {
if (auth instanceof JwtAuthenticationToken jwtAuth) {
return jwtAuth.getToken().getSubject();
}
if (auth instanceof ApiKeyAuthenticationToken apiKeyAuth) {
return ((ApiKeyPrincipal) apiKeyAuth.getPrincipal()).getAppId();
}
return auth.getName();
}
}七、基于 JWT Claims 的细粒度权限控制
除了模型访问控制,还可以把 AI 相关的权限编码到 JWT Claims 里:
// JWT Payload 示例
{
"sub": "user123",
"roles": ["USER"],
"ai_permissions": {
"max_tokens_per_day": 50000,
"allowed_models": ["gpt-3.5-turbo"],
"features": ["chat", "summarize"]
},
"exp": 1735689600
}从 JWT 里提取这些权限:
@Component
public class AiPermissionExtractor {
public AiPermission extractFromJwt(Jwt jwt) {
Map<String, Object> aiPerms = jwt.getClaimAsMap("ai_permissions");
if (aiPerms == null) {
return AiPermission.defaultPermission();
}
return AiPermission.builder()
.maxTokensPerDay(((Number) aiPerms.getOrDefault(
"max_tokens_per_day", 10000)).intValue())
.allowedModels((List<String>) aiPerms.getOrDefault(
"allowed_models", List.of("gpt-3.5-turbo")))
.features((List<String>) aiPerms.getOrDefault(
"features", List.of("chat")))
.build();
}
}然后在 Controller 层做特性检查:
@PostMapping("/api/v1/summarize")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResult<String>> summarize(
@RequestBody SummarizeRequest request,
@AuthenticationPrincipal Jwt jwt) {
AiPermission permission = permissionExtractor.extractFromJwt(jwt);
if (!permission.getFeatures().contains("summarize")) {
return ResponseEntity.status(403)
.body(ApiResult.error(403, "您的套餐不支持摘要功能"));
}
String result = summarizeService.summarize(request.getText(), jwt.getSubject());
return ResponseEntity.ok(ApiResult.success(result));
}八、流式接口的安全处理
流式接口(SSE)有个特殊情况:认证发生在 HTTP 连接建立时,但数据是持续推送的。如果用户在流式响应过程中账号被封禁,怎么处理?
@PostMapping(value = "/api/v1/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestBody @Valid ChatRequest request,
@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
return chatService.streamChat(userId, request.getMessage())
// 每个 chunk 发送前检查用户状态(可选,高频检查有性能消耗)
.filter(chunk -> {
// 轻量级检查,只检查缓存中的封禁状态
return !userStatusCache.isBanned(userId);
})
.map(content -> ServerSentEvent.<String>builder()
.data(content)
.build())
.onErrorReturn(SecurityException.class,
ServerSentEvent.<String>builder()
.event("error")
.data("认证状态异常,请重新登录")
.build());
}九、安全审计日志
AI 接口的安全审计不能少,特别是涉及到 token 消耗的场景:
@Component
@Slf4j
public class AuditLogAdvisor implements CallAroundAdvisor {
private final SecurityAuditLogger auditLogger;
public AuditLogAdvisor(SecurityAuditLogger auditLogger) {
this.auditLogger = auditLogger;
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest request,
CallAroundAdvisorChain chain) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String userId = extractUserId(auth);
Instant startTime = Instant.now();
try {
AdvisedResponse response = chain.nextAroundCall(request);
// 记录成功调用
auditLogger.log(AuditEvent.builder()
.userId(userId)
.action("AI_CHAT")
.status("SUCCESS")
.duration(Duration.between(startTime, Instant.now()).toMillis())
.tokenUsed(extractTokenCount(response.response()))
.build());
return response;
} catch (Exception e) {
// 记录失败调用
auditLogger.log(AuditEvent.builder()
.userId(userId)
.action("AI_CHAT")
.status("FAILED")
.errorMessage(e.getMessage())
.build());
throw e;
}
}
@Override
public String getName() { return "AuditLogAdvisor"; }
@Override
public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; }
}十、一个实际项目的安全配置清单
最后整理一个 AI 项目上线前的安全检查清单:
API Key 安全:
接口安全:
数据安全:
权限控制:
安全不是加个 @Authenticated 就完事了,它是一个系统工程。把这些东西在脚手架阶段就做好,比上线后补要省心得多。
