AI 客服系统安全加固:JWT 鉴权 + Bucket4j 三层限流

0 阅读4分钟

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 组合下有个小坑,对照本文配置抄就行。


系列进度

主题状态
01Spring AI Alibaba 接入智谱 GLM-4,搭基础骨架✅ 已发
02情绪感知 + 意图识别 + Agent 工具链✅ 已发
03多 Agent 路由 + 多轮记忆 + 敏感词过滤✅ 已发
04JWT 鉴权 + 三层限流 + 链路追踪👈 本篇
05RAG 知识库(向量检索 + 混合检索)📝 计划中

1. 整体安全架构

请求进来后,过三道关:

sequenceDiagram
    participant C as Client
    participant TF as TraceFilter<br/>@Order(10)
    participant JF as JwtFilter<br/>@Order(50)
    participant RL as RateLimitFilter<br/>@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 鉴权

整体流程

graph LR
    A[POST /api/auth/login] --> B[AuthService.login]
    B --> C{查 sys_user 表}
    C -->|不存在| D[401 用户名或密码错误]
    C -->|已禁用| E[403 用户已禁用]
    C -->|密码错误| D
    C -->|通过| F[生成 JWT Token]
    F --> G[返回 token + username + role + expiresIn]

JwtUtil

工具类用 JJWT 库,几个静态方法:

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

@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

登录逻辑干净,三步走:

@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

@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. 三层限流

设计思路

graph TB
    Req[请求] --> G{全局桶<br/>100 req/s}
    G -->|已消耗| U{用户桶<br/>20 req/s per user}
    G -->|超限| R429_G[429 系统繁忙]
    U -->|已消耗| L{LLM桶<br/>10 req/s per user<br/>仅 /api/chat**}
    U -->|超限| R429_U[429 请求过于频繁]
    L -->|已消耗| OK[放行]
    L -->|超限| R429_L[429 AI服务繁忙]

三层桶各有分工:

  • 全局桶:防突发流量打垮服务
  • 用户桶:防单个用户刷接口
  • LLM 桶:专门保护 AI 对话接口,成本最贵,额外收紧

依赖

<!-- 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

@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

@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())
        ));
    }
}

限流参数配置

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

顺手把这个也记一下,不单独写一篇了。

@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:

logging:
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{30} - %msg%n"

5. 生产环境密钥校验

上线前最怕的一件事:开发用的默认密钥被带到生产。加了一个 @PostConstruct 校验器,prod profile 启动时强制检查:

@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);
    }
}

触发时直接让应用启动失败——比启动成功但用了危险密钥要安全得多。

生产环境配置通过环境变量注入,不带任何默认值:

# 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,日志自动携带
密钥安全ProductionSecretValidatorprod profile 启动时强制校验,失败则拒绝启动

源码怎么拿

公众号「亦暖筑序」底部菜单【获取源码】,Gitee 仓库直接拉。

源码包含完整可运行的实现,包括:

  • SysUser 表建表 SQL(含 BCrypt 加密的测试账号)
  • application-dev.yml / application-prod.yml 配置示例
  • Bucket4j + Redis 限流单元测试

附录:踩坑速查

现象解决
Filter 顺序只用 @Order实际执行顺序不符预期SecurityConfigaddFilterBefore/After 显式注册
getNativeClient() 强转失败Redis Cluster 模式下 ClassCastExceptionStandalone 用 RedisClient,Cluster 用 RedisClusterClient
RateLimitFilter 取不到 usernameSecurityContextHolder 为空确认 RateLimitFilterJwtFilter 之后执行
Token 过期后继续请求静默返回 401,前端没有明显提示检查 authenticationEntryPoint 是否正确配置,返回 JSON 格式
prod 启动用了开发密钥JWT 签名可被预测,安全漏洞ProductionSecretValidator 启动拦截,强制注入强密钥
Bucket4j bucket 每次请求都新建令牌桶不持久,限流失效createXxxBucket() 每次用同一个 key 构建,Bucket4j 内部会复用 Redis 状态