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

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. 整体安全架构

请求进来后,过三道关:

sequenceDiagram participant C as Client participant TF as TraceFilter
@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 鉴权

整体流程

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 库,几个静态方法:

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

设计思路

graph TB Req[请求] --> G{全局桶
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 实际执行顺序不符预期 SecurityConfigaddFilterBefore/After 显式注册
getNativeClient() 强转失败 Redis Cluster 模式下 ClassCastException Standalone 用 RedisClient,Cluster 用 RedisClusterClient
RateLimitFilter 取不到 username SecurityContextHolder 为空 确认 RateLimitFilterJwtFilter 之后执行
Token 过期后继续请求 静默返回 401,前端没有明显提示 检查 authenticationEntryPoint 是否正确配置,返回 JSON 格式
prod 启动用了开发密钥 JWT 签名可被预测,安全漏洞 ProductionSecretValidator 启动拦截,强制注入强密钥
Bucket4j bucket 每次请求都新建 令牌桶不持久,限流失效 createXxxBucket() 每次用同一个 key 构建,Bucket4j 内部会复用 Redis 状态
相关推荐
littleM1 小时前
深度拆解 HermesAgent(五):记忆系统与用户建模
jvm·人工智能·架构·ai编程
xhuiting1 小时前
项目技术总结
java
某人辛木1 小时前
JDK安装配置
java·开发语言
counting money1 小时前
Spring框架基础(依赖注入-全注解形式)
java·数据库·spring
小王师傅661 小时前
【Java结构化梳理】泛型-初步了解-下
java·开发语言
逝水如流年轻往返染尘1 小时前
JAVA中的String类
java
一只叫煤球的猫1 小时前
ThreadForge 1.2.0 发布:让 Java 并发代码更好写,这次补齐了高阶编排、示例与观测能力
java·设计模式·设计
counting money2 小时前
Spring框架基础(依赖注入-半注解形式)
java·后端·spring
CN-Dust2 小时前
【C++】for循环例题专题
java·c++·算法