AI 客服系统安全加固:JWT 鉴权 + Bucket4j 三层限流
一套跑在 Spring Boot 3.5 + Spring Security 上的 JWT 鉴权方案,加上 Bucket4j + Redis 的三层令牌桶限流------顺便把踩过的 Filter 顺序坑一起记了
先说结论
| 上篇已有的 | 本篇新增的 |
|---|---|
| Agent 多轮对话主链路 | JWT 无状态鉴权体系 |
| 多 Agent 路由 | 三层令牌桶限流(全局 / 用户 / LLM) |
| 敏感词双向过滤 | 链路追踪 Filter(MDC + traceId) |
| - | 生产环境密钥安全校验 |
结论先讲:Filter 顺序不能只靠 @Order,得在 SecurityConfig 里显式注册;Bucket4j 分布式版的 ProxyManager 初始化方式在 Spring Boot 3 + Lettuce 组合下有个小坑,对照本文配置抄就行。
系列进度
| 篇 | 主题 | 状态 |
|---|---|---|
| 01 | Spring AI Alibaba 接入智谱 GLM-4,搭基础骨架 | ✅ 已发 |
| 02 | 情绪感知 + 意图识别 + Agent 工具链 | ✅ 已发 |
| 03 | 多 Agent 路由 + 多轮记忆 + 敏感词过滤 | ✅ 已发 |
| 04 | JWT 鉴权 + 三层限流 + 链路追踪 | 👈 本篇 |
| 05 | RAG 知识库(向量检索 + 混合检索) | 📝 计划中 |
1. 整体安全架构
请求进来后,过三道关:
@Order(10) participant JF as JwtFilter
@Order(50) participant RL as RateLimitFilter
@Order(30) participant API as Controller C->>TF: 请求 TF->>TF: 生成/透传 X-Trace-Id,写入 MDC TF->>JF: 继续 JF->>JF: 解析 Bearer Token,写入 SecurityContext JF->>RL: 继续 RL->>RL: 全局桶 → 用户桶 → LLM桶 RL-->>C: 429(限流触发) RL->>API: 通过 API-->>C: 业务响应(含 X-Trace-Id)
三个 Filter 的执行顺序:TraceFilter → JwtFilter → RateLimitFilter。
踩坑提醒:
@Order值只影响 Spring 容器里 Bean 的排序,不影响 Security Filter Chain 里的执行顺序。必须在SecurityConfig里显式用addFilterBefore/addFilterAfter注册,否则三个 Filter 可能以任意顺序执行。
2. JWT 鉴权
整体流程
JwtUtil
工具类用 JJWT 库,几个静态方法:
java
public class JwtUtil {
private JwtUtil() {}
public static String generateToken(String username, String role,
String secret, long expirationMs) {
return Jwts.builder()
.claims(Map.of("username", username, "role", role))
.subject(username)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSecretKey(secret))
.compact();
}
public static boolean validateToken(String token, String secret) {
try {
parseToken(token, secret);
return true;
} catch (Exception e) {
return false;
}
}
public static String getUsername(String token, String secret) {
return parseToken(token, secret).getSubject();
}
public static String getRole(String token, String secret) {
return parseToken(token, secret).get("role", String.class);
}
private static Claims parseToken(String token, String secret) {
return Jwts.parser()
.verifyWith(getSecretKey(secret))
.build()
.parseSignedClaims(token)
.getPayload();
}
private static SecretKey getSecretKey(String secret) {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
}
JwtFilter
从 Authorization: Bearer <token> 头解析 Token,验证通过就写入 SecurityContextHolder:
java
@Order(50)
@Component
public class JwtFilter extends OncePerRequestFilter {
@Value("${ai-csr.auth.jwt-secret}")
private String jwtSecret;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
if (JwtUtil.validateToken(token, jwtSecret)) {
String username = JwtUtil.getUsername(token, jwtSecret);
String role = JwtUtil.getRole(token, jwtSecret);
var auth = new UsernamePasswordAuthenticationToken(
username, null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
log.warn("JWT validation failed: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
}
验证失败只打 WARN、不直接拒绝------由 Spring Security 的路由规则统一决定是否返回 401,这样白名单路径(/api/auth/**、/swagger-ui/**)不受影响。
AuthService
登录逻辑干净,三步走:
java
@Service
public class AuthService {
@Value("${ai-csr.auth.jwt-secret}")
private String jwtSecret;
@Value("${ai-csr.auth.jwt-expiration}")
private long jwtExpiration;
public LoginResponse login(LoginRequest request) {
SysUser user = userMapper.selectOne(
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, request.getUsername())
);
if (user == null) {
throw BizException.of(ResultCode.UNAUTHORIZED.getCode(), "用户名或密码错误");
}
if (!user.getEnabled()) {
throw BizException.of(ResultCode.FORBIDDEN.getCode(), "用户已禁用");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw BizException.of(ResultCode.UNAUTHORIZED.getCode(), "用户名或密码错误");
}
String token = JwtUtil.generateToken(
user.getUsername(), user.getRole(), jwtSecret, jwtExpiration
);
return new LoginResponse(token, user.getUsername(), user.getRole(),
jwtExpiration / 1000);
}
}
踩坑提醒:用户不存在和密码错误故意返回同一条错误消息------防止通过错误消息枚举有效用户名。
SecurityConfig
关键在显式注册 Filter 顺序,别只依赖 @Order:
java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.setCharacterEncoding("UTF-8");
res.getWriter().write(
"{\"code\":\"401\",\"message\":\"未认证或登录已过期\",\"success\":false}");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.setCharacterEncoding("UTF-8");
res.getWriter().write(
"{\"code\":\"403\",\"message\":\"无权限访问该资源\",\"success\":false}");
})
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/swagger-ui/**", "/api-docs/**",
"/v3/api-docs/**", "/doc.html").permitAll()
.requestMatchers("/ws/**").permitAll()
.anyRequest().authenticated()
);
// ⚠️ 关键:显式指定执行顺序,不要只靠 @Order
http.addFilterBefore(traceFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(jwtFilter, TraceFilter.class);
http.addFilterAfter(rateLimitFilter, JwtFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
3. 三层限流
设计思路
100 req/s} G -->|已消耗| U{用户桶
20 req/s per user} G -->|超限| R429_G[429 系统繁忙] U -->|已消耗| L{LLM桶
10 req/s per user
仅 /api/chat**} U -->|超限| R429_U[429 请求过于频繁] L -->|已消耗| OK[放行] L -->|超限| R429_L[429 AI服务繁忙]
三层桶各有分工:
- 全局桶:防突发流量打垮服务
- 用户桶:防单个用户刷接口
- LLM 桶:专门保护 AI 对话接口,成本最贵,额外收紧
依赖
xml
<!-- Bucket4j 核心 + Redis 分布式扩展 -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-redis</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Lettuce(Spring Boot 默认 Redis 客户端,通常已传递依赖) -->
RateLimitConfig
这里有个坑:LettuceBasedProxyManager 需要底层 RedisClient(Lettuce 原生客户端),不是 Spring 的 RedisTemplate。
java
@Configuration
public class RateLimitConfig {
private final RateLimitProperties properties;
private final LettuceBasedProxyManager<byte[]> proxyManager;
public RateLimitConfig(RateLimitProperties properties,
LettuceConnectionFactory connectionFactory) {
this.properties = properties;
// 从 LettuceConnectionFactory 拿底层 RedisClient
RedisClient redisClient = (RedisClient) connectionFactory.getNativeClient();
this.proxyManager = LettuceBasedProxyManager.builderFor(redisClient).build();
}
public BucketProxy createGlobalBucket() {
return proxyManager.builder()
.build(key("rate-limit:global"), globalConfig());
}
public BucketProxy createUserBucket(String username) {
return proxyManager.builder()
.build(key("rate-limit:user:" + username), userConfig());
}
public BucketProxy createLlmBucket(String username) {
return proxyManager.builder()
.build(key("rate-limit:llm:" + username), llmConfig());
}
private byte[] key(String k) {
return k.getBytes(StandardCharsets.UTF_8);
}
private Supplier<BucketConfiguration> globalConfig() {
return () -> BucketConfiguration.builder()
.addLimit(l -> l
.capacity(properties.getGlobal().getCapacity())
.refillIntervally(
properties.getGlobal().getRefillIntervalSeconds(),
Duration.ofSeconds(1)))
.build();
}
// userConfig / llmConfig 同理,略
}
踩坑提醒:
connectionFactory.getNativeClient()返回的是Object,必须强转RedisClient。如果用的是 Redis Cluster,getNativeClient()返回的是RedisClusterClient,对应要换LettuceBasedProxyManager.builderFor(clusterClient)。
RateLimitFilter
java
@Slf4j
@Component
@Order(30)
public class RateLimitFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if (!properties.isEnabled()) {
filterChain.doFilter(request, response);
return;
}
// Layer 1: 全局限流
ConsumptionProbe globalProbe =
rateLimitConfig.createGlobalBucket().tryConsumeAndReturnRemaining(1);
if (!globalProbe.isConsumed()) {
log.warn("全局限流触发: {}", request.getRequestURI());
sendTooManyRequests(response, "系统繁忙,请稍后重试");
return;
}
// Layer 2: 用户限流(需要 JwtFilter 先写入 SecurityContext)
String username = extractUsername();
if (username != null) {
ConsumptionProbe userProbe =
rateLimitConfig.createUserBucket(username).tryConsumeAndReturnRemaining(1);
if (!userProbe.isConsumed()) {
log.warn("用户限流触发,用户: {}", username);
sendTooManyRequests(response, "请求过于频繁,请稍后重试");
return;
}
}
// Layer 3: LLM 限流(仅 /api/chat** 路径)
if (request.getRequestURI().startsWith("/api/chat") && username != null) {
ConsumptionProbe llmProbe =
rateLimitConfig.createLlmBucket(username).tryConsumeAndReturnRemaining(1);
if (!llmProbe.isConsumed()) {
log.warn("LLM限流触发,用户: {}", username);
sendTooManyRequests(response, "AI服务繁忙,请稍后重试");
return;
}
}
filterChain.doFilter(request, response);
}
private String extractUsername() {
Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
return (auth != null && auth.isAuthenticated()) ? auth.getName() : null;
}
private void sendTooManyRequests(HttpServletResponse response,
String message) throws IOException {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(
Map.of("code", 429, "message", message,
"timestamp", System.currentTimeMillis())
));
}
}
限流参数配置
yaml
ai-csr:
rate-limit:
enabled: true
global:
capacity: 100 # 全局令牌桶容量
refill-interval-seconds: 1
per-user:
capacity: 20 # 单用户令牌桶容量
refill-interval-seconds: 1
llm:
capacity: 10 # LLM 令牌桶容量
refill-interval-seconds: 1
通过 @ConfigurationProperties(prefix = "ai-csr.rate-limit") 绑定,调整参数不用改代码。enabled: false 可以一键关闭所有限流,调试时很方便。
4. 链路追踪 Filter
顺手把这个也记一下,不单独写一篇了。
java
@Component
@Order(10)
public class TraceFilter extends OncePerRequestFilter {
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_ID_MDC_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 支持客户端传入,方便前后端联调
String traceId = request.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put(TRACE_ID_MDC_KEY, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID_MDC_KEY); // 线程池场景必须清理
}
}
}
日志 Pattern 加上 [%X{traceId}],每条日志自动携带链路 ID:
yaml
logging:
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{30} - %msg%n"
5. 生产环境密钥校验
上线前最怕的一件事:开发用的默认密钥被带到生产。加了一个 @PostConstruct 校验器,prod profile 启动时强制检查:
java
@Component
public class ProductionSecretValidator {
private static final String INSECURE_JWT_DEFAULT =
"4koeHj6LECyBVIhyYeZuWLF/JQSDj7LtudMAShVnWp8=";
private static final String PLACEHOLDER_API_KEY = "your-api-key-here";
@Value("${ai-csr.auth.jwt-secret:}")
private String jwtSecret;
@Value("${spring.ai.zhipuai.api-key:}")
private String zhipuApiKey;
@PostConstruct
public void validate() {
if (!isProdProfileActive()) return;
if (!StringUtils.hasText(jwtSecret) || INSECURE_JWT_DEFAULT.equals(jwtSecret)) {
throw new IllegalStateException(
"Production profile requires a strong JWT_SECRET value.");
}
if (jwtSecret.length() < 32) {
throw new IllegalStateException(
"Production JWT_SECRET is too short. Minimum length is 32.");
}
if (!StringUtils.hasText(zhipuApiKey) || PLACEHOLDER_API_KEY.equals(zhipuApiKey)) {
throw new IllegalStateException(
"Production profile requires a valid ZHIPUAI_API_KEY.");
}
}
private boolean isProdProfileActive() {
return Arrays.stream(environment.getActiveProfiles())
.anyMatch("prod"::equalsIgnoreCase);
}
}
触发时直接让应用启动失败------比启动成功但用了危险密钥要安全得多。
生产环境配置通过环境变量注入,不带任何默认值:
yaml
# application-prod.yml
ai-csr:
auth:
jwt-secret: ${JWT_SECRET} # 无默认值,必须注入
jwt-expiration: ${JWT_EXPIRATION:86400000}
spring:
ai:
zhipuai:
api-key: ${ZHIPUAI_API_KEY} # 无默认值,必须注入
6. 几个设计决定
为什么不用 OAuth2/OIDC
内部 B 端客服工作台,用户量小、角色简单(ADMIN / AGENT / CUSTOMER)。引 OAuth2 全家桶会带来大量依赖和配置复杂度,不值当。JWT + 自定义 Filter 这套够用,代码也容易读懂。等后续要对接企业微信或第三方 SSO,再评估升级。
为什么 Bucket4j 而不是 Resilience4j
项目已经用了 Redis,Bucket4j 分布式版可以直接复用 Redis 连接;Resilience4j 的分布式限流需要额外的协调器,相对重一些。客服场景的 QPS 量级,Bucket4j + Redis 完全够用。
关于 Token 刷新
目前只有登录接口,没有 refresh_token 端点(有效期 24h,覆盖工作日班次)。已知取舍,如果后续有需要,加一个 POST /api/auth/refresh 端点就行。
方案总结
| 组件 | 技术选型 | 关键点 |
|---|---|---|
| 鉴权 | Spring Security + JJWT | 无状态 Session,Filter 顺序显式注册 |
| 登录 | AuthService + BCrypt | 用户名/密码错误统一错误消息,防枚举 |
| 全局限流 | Bucket4j + Redis(LettuceProxyManager) | 100 req/s,令牌桶算法 |
| 用户限流 | 同上,key = rate-limit:user:{username} |
20 req/s per user |
| LLM 限流 | 同上,key = rate-limit:llm:{username} |
10 req/s,仅 /api/chat** |
| 链路追踪 | TraceFilter + MDC | 生成 / 透传 X-Trace-Id,日志自动携带 |
| 密钥安全 | ProductionSecretValidator | prod profile 启动时强制校验,失败则拒绝启动 |
源码怎么拿
公众号「亦暖筑序」底部菜单【获取源码】,Gitee 仓库直接拉。
源码包含完整可运行的实现,包括:
SysUser表建表 SQL(含 BCrypt 加密的测试账号)application-dev.yml/application-prod.yml配置示例- Bucket4j + Redis 限流单元测试
附录:踩坑速查
| 坑 | 现象 | 解决 |
|---|---|---|
Filter 顺序只用 @Order |
实际执行顺序不符预期 | 在 SecurityConfig 里 addFilterBefore/After 显式注册 |
getNativeClient() 强转失败 |
Redis Cluster 模式下 ClassCastException | Standalone 用 RedisClient,Cluster 用 RedisClusterClient |
| RateLimitFilter 取不到 username | SecurityContextHolder 为空 |
确认 RateLimitFilter 在 JwtFilter 之后执行 |
| Token 过期后继续请求 | 静默返回 401,前端没有明显提示 | 检查 authenticationEntryPoint 是否正确配置,返回 JSON 格式 |
| prod 启动用了开发密钥 | JWT 签名可被预测,安全漏洞 | ProductionSecretValidator 启动拦截,强制注入强密钥 |
| Bucket4j bucket 每次请求都新建 | 令牌桶不持久,限流失效 | createXxxBucket() 每次用同一个 key 构建,Bucket4j 内部会复用 Redis 状态 |