首页 / 资讯中心 / 文章详情

Spring Security API Key接口认证与过滤器链实践

Spring Security API Key接口认证与过滤器链实践 ★ FEATURED ARTICLE
接口安全这件事做到最后往往会发现真正麻烦的不是“怎么把请求拦住”而是“为什么用这套方式拦”。Spring Security 的 API Key 实现说白了就是给 SpringBoot 的接口加一道只认密钥、不认账号的口子调用方在请求头里带上一串事先下发的密钥服务端校验通过就放行校验失败就直接 401。它解决的核心问题是服务端之间的身份识别——第三方对接、内部服务互调、定时脚本拉数据、Webhook 回调这些场景既没有真实用户登录也不值得为它上一整套 OAuth2 授权服务器。这篇内容面向已经会用 SpringBoot 写接口、但对 Spring Security 过滤器链还停留在“抄配置”阶段的开发者也面向想把手写拦截器升级成标准认证流程的同行。下面这套方案基于 SpringBoot 3.x 与 Spring Security 6.x代码可以直接跑中间那些容易翻车的细节我会一个一个拆开讲。1. 先想清楚API Key 该不该进 Spring Security 的过滤器链1.1 三种鉴权方式的边界什么时候 API Key 比 JWT 更省事很多人一提到接口鉴权就条件反射上 JWT其实这是个典型的工具误用。JWT 的设计目标是承载用户身份和声明它天然适合“用户登录之后带着身份在多个服务之间流转”的场景代价是签发、刷新、吊销、时钟偏移、密钥轮换这一整套配套都得自己做。而 API Key 的语义非常窄它只回答一个问题——这次调用是谁发起的不回答“这个人是谁”“他能干什么”。正因为语义窄它才简单可靠一个字符串、一次查表没有签名验签的计算开销也没有令牌过期后前端要静默刷新的麻烦。我把三者的适用边界列成一张表选型的时候对着看基本不会错方案适合的场景明显不适合的场景主要代价API Key服务端对服务端调用、开放平台第三方接入、内部脚本与定时任务、Webhook 回调浏览器前端、需要区分到具体用户的业务没有用户身份吊销和轮换要自己设计JWT无状态会话、微服务之间传递用户上下文超长有效期、要求秒级吊销令牌体积大撤销困难需要维护签名密钥OAuth2 客户端凭证企业级对接、需要标准协议与授权范围内部小工具、临时脚本流程重要维护授权服务器和客户端注册表格里最容易被忽略的是第二列。我见过不少项目在浏览器里直接塞 API Key打包进前端 JS任何人打开开发者工具都能拿走这种用法等于没有鉴权。API Key 的前提是“密钥只存在于可信的服务端环境”一旦它出现在用户可控的地方安全性立刻归零。所以判断标准很简单调用方是不是一台你能控制的服务器。是就用 API Key不是老老实实走用户登录那套。还有一个常被混用的场景是“内部服务互调”。有些团队觉得都在内网直接裸奔就行结果内网一台机器被拿下横向就能调所有接口。API Key 在这种场景下成本极低每个服务分发一把出问题能定位到具体调用方日志里也能追溯性价比远高于上 mTLS。1.2 从拦截器到过滤器链为什么最后都迁到了 Security早期我确实是用HandlerInterceptor做的简单粗暴preHandle里读 header、查库、比对不通过就写 401 返回 false。这套写法在单模块小项目里够用但只要项目长到一定规模问题就暴露出来了。第一个问题是执行顺序拦截器在 DispatcherServlet 之后才执行意味着静态资源、错误页转发、异步请求这些路径它管不到第二个问题是上下文缺失拦截器里没法自然地往 Spring Security 的SecurityContext里塞认证信息导致后续想用PreAuthorize、想用Authentication参数拿当前调用方全都拿不到。ServletFilter能解决执行顺序但手写 Filter 又要自己处理“认证失败怎么返回 JSON”“和 Spring Security 的异常处理怎么衔接”“多个 Filter 顺序怎么排”这些琐事。Spring Security 的过滤器链本质就是一组有序的 Servlet Filter把 API Key 认证塞进去等于白嫖了它整套的异常处理、授权决策、上下文管理能力。关键点在于把 API Key 做成一个AuthenticationProvider而不是在 Filter 里直接判断放行。这个设计的价值在于认证成功后你得到的是一个标准的Authentication对象里面装着 principal 和 authorities后面所有基于hasAuthority、PreAuthorize的授权规则都能直接复用不需要为 API Key 单独写一套权限判断逻辑。Filter 只负责“从请求里取出凭证并触发认证”Provider 只负责“凭证对不对”职责切得干净测试也好写——Provider 可以脱离 HTTP 环境单独做单元测试。2. API Key 的生成、存储与校验几个一错就翻车的细节2.1 Key 的格式设计与熵值计算密钥的格式不是随便拼个 UUID 就完事。我在实际项目里用的结构是ak_{env}_{公开前缀}_{随机密钥}比如ak_live_K7mPq2Rt9XaB_8fHjLmNpQrStUvWxYz3cD5eF6gH7j。分四段各有各的用处ak是固定标识方便日志检索和密钥扫描工具识别env区分测试和生产避免测试密钥误用于生产环境第三段是公开前缀它会被明文存进数据库并建唯一索引作用是让校验时能用一次索引查询定位到记录而不是全表扫描最后一段才是真正的秘密部分只存哈希。熵值是这套设计的地基必须算清楚。我用的字符集去掉了容易混淆的I、O、l、0、1剩下大写 24 个、小写 25 个、数字 8 个合计 57 个字符。每个字符携带的信息量是 log₂(57) 约等于 5.83 bit随机段取 32 位总熵约 186 bit。这个量级意味着什么即使攻击者每秒尝试十亿次穷举完一半密钥空间需要的年数也是天文数字远超宇宙年龄。作为对比字符集大小随机段长度理论熵评价36UUID 去掉横线32约 165 bit够用但有混淆字符57去混淆字母数字32约 186 bit推荐兼顾可读性62全字母数字43约 256 bit更稳但人工抄写易错注意熵值算的是随机段不包括固定的ak_live_前缀。有些人把前缀也算进去凑长度那是自欺欺人因为前缀是公开可知的。生成代码用SecureRandom绝对不能用Random或者Math.random()。后者是线性同余伪随机种子可预测攻击者拿到几个输出就能反推内部状态等于密钥直接泄露。这一点看着像常识但我在代码评审里真的见过用UUID.randomUUID().toString()当密钥的——UUID v4 虽然用了安全随机源但它只有 122 bit 有效熵而且格式固定、带横线长度也不够属于“能用但不推荐”的档次。public final class ApiKeyGenerator { private static final SecureRandom RANDOM new SecureRandom(); private static final char[] ALPHABET ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789.toCharArray(); private ApiKeyGenerator() { } public static IssuedKey generate(String env) { String publicPrefix ak_ env _ randomString(12); String secret randomString(32); String raw publicPrefix _ secret; return new IssuedKey(raw, publicPrefix, ApiKeyHasher.sha256Hex(raw)); } private static String randomString(int len) { char[] buf new char[len]; for (int i 0; i len; i) { buf[i] ALPHABET[RANDOM.nextInt(ALPHABET.length)]; } return new String(buf); } public record IssuedKey(String raw, String publicPrefix, String hash) { } }公开前缀只有 12 位随机字符空间是 57¹² 约等于 10²¹碰撞概率极低但理论上仍可能撞。工程上的处理很简单public_prefix字段加唯一索引插入时捕获DuplicateKeyException重新生成一次最多重试三次。这比在代码里先查再插要可靠因为查和插之间有竞态窗口。2.2 落库只存哈希盐和 pepper 怎么用密钥明文绝对不能落库这一点没有讨论余地。但“存哈希”这件事有个非常常见的误区很多人一看要存密码哈希条件反射上 BCrypt 或者 Argon2。这在 API Key 场景下是错的而且错得很严重。BCrypt 是慢哈希设计目标是抵抗离线暴力破解单次计算故意做到几十到几百毫秒。用户登录一天一次这个开销完全无所谓。但 API Key 是每次请求都要校验的假设你的接口 QPS 是 500每个请求做一次 BCrypt光是哈希计算就要吃掉几十个 CPU 核。更要命的是 BCrypt 每次加盐结果都不同你没法用哈希值直接建索引查询只能先按前缀查出记录再比对等于把慢哈希的开销乘以请求量。我实测过一个中等配置的容器BCrypt 强度 10 的情况下单次约 60 到 80 毫秒放到高并发接口上直接就是灾难。正确的做法是用 SHA-256。理由是这样的慢哈希存在的意义是抵抗“低熵输入”的暴力破解用户密码通常只有几十 bit 熵所以要靠加盐和放慢计算来拉高攻击成本。而 API Key 本身有 186 bit 熵字典攻击和彩虹表在这种空间面前毫无意义SHA-256 一次运算微秒级既快又足够。那要不要加盐对高熵输入来说每条记录随机加盐其实没有必要因为盐的作用是防止相同输入产生相同哈希被批量破解而我们的输入空间大到不可能出现批量对应。但我会额外加一层 pepper用一个存放在配置文件或密钥管理服务里的服务端密钥对raw做 HMAC-SHA256而不是裸 SHA-256。这样做的价值在于如果数据库被拖走但应用服务器没有沦陷攻击者拿到的哈希没法离线验证——他缺少 pepper连“这个哈希对应哪个密钥”都判断不了。这是一个成本极低、收益明确的多一层防护。public final class ApiKeyHasher { private static final String HMAC_ALGORITHM HmacSHA256; private static final String PEPPER System.getenv(API_KEY_PEPPER); private ApiKeyHasher() { } public static String sha256Hex(String raw) { try { Mac mac Mac.getInstance(HMAC_ALGORITHM); mac.init(new SecretKeySpec(PEPPER.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); byte[] digest mac.doFinal(raw.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); } catch (GeneralSecurityException e) { throw new IllegalStateException(api key hash failed, e); } } }注意pepper 必须和数据库分开存放放在环境变量或者独立的密钥管理里。如果 pepper 和哈希在同一张表里那这层防护等于没加。另外 pepper 一旦轮换所有旧密钥都会失效所以要么设计双 pepper 并行验证的过渡期要么接受一次性全量重新下发。表结构对应如下key_hash固定 64 位十六进制key_prefix建唯一索引CREATE TABLE api_client ( id BIGINT NOT NULL AUTO_INCREMENT, client_name VARCHAR(64) NOT NULL COMMENT 调用方名称, key_prefix VARCHAR(32) NOT NULL COMMENT 密钥公开前缀用于索引定位, key_hash CHAR(64) NOT NULL COMMENT HMAC-SHA256 十六进制摘要, scopes VARCHAR(255) NOT NULL DEFAULT COMMENT 逗号分隔的授权范围, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 启用 0 停用, expires_at DATETIME NULL COMMENT 过期时间NULL 表示长期, last_used_at DATETIME NULL COMMENT 最近一次调用时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_prefix (key_prefix) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;2.3 常量时间比对与失败路径的限流前置哈希比对这一步用String.equals是个隐蔽的安全问题。普通字符串比较是短路的第一个不相等的字符就返回攻击者可以通过测量响应时间一个字符一个字符地猜出正确值这叫时序攻击。虽然理论上要猜出 64 位十六进制需要海量请求但修起来就一行代码的事没理由不修。Java 标准库提供了MessageDigest.isEqual它在实现上会遍历完整长度时间开销与内容无关。boolean matched MessageDigest.isEqual( computedHash.getBytes(StandardCharsets.UTF_8), storedHash.getBytes(StandardCharsets.UTF_8));比比对更值得花心思的是失败路径。密钥校验失败时如果什么都不做攻击者就可以无限次尝试。虽然 186 bit 的熵让穷举不可能成功但真实世界的攻击往往不是穷举而是撞库从别处泄露的密钥拿到你这里试一遍或者内部人员离职后拿旧密钥反复试探。所以失败次数必须限流。我的做法是在认证失败的分支里按“来源 IP 密钥前缀”做一个滑动窗口计数超过阈值比如每分钟 20 次直接返回 429并且在计数超限后不再走数据库查询直接在过滤器层短路。这个顺序很重要——限流要放在查库之前否则攻击者用随机密钥狂刷你的数据库先被打挂了。这里不需要引入重量级组件单机用 Caffeine 的expireAfterWrite计数器就够多实例部署换成 Redis 的INCR加EXPIRE逻辑一样。提示返回给调用方的错误信息要模糊化。密钥不存在、密钥已停用、密钥已过期这三种情况对外统一返回“认证失败”只在服务端日志里记录具体原因。否则攻击者可以通过错误信息的差异判断某个前缀是否存在等于免费拿到一个探测接口。还有一点容易被忽略last_used_at的更新。如果每次请求都同步写一次数据库等于把读多写少的接口变成了写密集接口很容易成为瓶颈。我的处理是用一个内存队列攒批每 30 秒或者攒够 200 条统一更新一次精度要求不高的审计场景完全够用。3. 动手实现一套能跑的 API Key 认证链3.1 工程结构与依赖、配置项整个实现拆成六块令牌对象、认证提供者、认证过滤器、异常处理器、安全配置、密钥仓储。包结构建议按职责分不要把认证相关的东西散落在各个业务包里后续排查问题时找不到入口。依赖只需要 Spring Security 的 starter、Web、JPA 和 Caffeine不需要任何第三方鉴权库dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency配置项集中在application.yml把请求头名称、缓存时长、限流阈值都做成可配置方便不同环境调整security: api-key: header-name: X-Api-Key cache-ttl: 60s cache-max-size: 10000 rate-limit-per-minute: 600 rate-limit-fail-per-minute: 20这里cache-ttl是 60 秒含义是密钥状态停用、过期的变更最多延迟 60 秒生效。这是个明确的取舍不缓存每个请求都查库QPS 上不去缓存太久吊销密钥后还有一段窗口期能被继续调用。60 秒对绝大多数业务是可接受的如果你们的密钥可能被即时盗用需要秒级阻断那就把 TTL 降到 5 秒并接受更高的数据库压力或者改用主动推送失效消息的机制。3.2 令牌对象、认证转换器与 AuthenticationProvider先定义令牌对象。它继承AbstractAuthenticationToken构造时区分“未认证”和“已认证”两种状态未认证状态下只携带原始密钥已认证状态下只携带客户端标识和权限列表原始密钥不保留在已认证对象里避免它在后续的日志或序列化中被意外带出。public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { private final String rawKey; private final String clientId; private ApiKeyAuthenticationToken(String rawKey, String clientId, Collection? extends GrantedAuthority authorities) { super(authorities); this.rawKey rawKey; this.clientId clientId; } public static ApiKeyAuthenticationToken unauthenticated(String rawKey) { return new ApiKeyAuthenticationToken(rawKey, null, List.of()); } public static ApiKeyAuthenticationToken authenticated(String clientId, Collection? extends GrantedAuthority authorities) { ApiKeyAuthenticationToken token new ApiKeyAuthenticationToken(null, clientId, authorities); token.setAuthenticated(true); return token; } Override public Object getCredentials() { return rawKey; } Override public Object getPrincipal() { return clientId; } }核心逻辑在AuthenticationProvider。它做四件事长度合法性检查、按前缀查记录、状态与有效期校验、哈希比对并组装权限。每一层失败抛不同的异常类型异常类型只用于服务端日志对外统一处理。Component public class ApiKeyAuthenticationProvider implements AuthenticationProvider { private static final Logger log LoggerFactory.getLogger(ApiKeyAuthenticationProvider.class); private static final int PREFIX_LENGTH 20; private final ApiClientRepository repository; private final CacheString, ApiClient cache; public ApiKeyAuthenticationProvider(ApiClientRepository repository, CacheString, ApiClient cache) { this.repository repository; this.cache cache; } Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String raw (String) authentication.getCredentials(); if (raw null || raw.length() 24 || raw.length() 128) { throw new BadCredentialsException(key format invalid); } String prefix raw.substring(0, PREFIX_LENGTH); ApiClient client cache.get(prefix, p - repository.findByKeyPrefix(p).orElse(null)); if (client null) { throw new BadCredentialsException(key not found, prefix prefix); } if (client.getStatus() ! 1) { throw new DisabledException(client disabled, id client.getId()); } if (client.getExpiresAt() ! null client.getExpiresAt().isBefore(LocalDateTime.now())) { throw new CredentialsExpiredException(key expired, id client.getId()); } String computed ApiKeyHasher.sha256Hex(raw); if (!MessageDigest.isEqual(computed.getBytes(StandardCharsets.UTF_8), client.getKeyHash().getBytes(StandardCharsets.UTF_8))) { log.warn(api key mismatch, prefix{}, remote{}, prefix, resolveClientIp()); throw new BadCredentialsException(key mismatch, prefix prefix); } ListGrantedAuthority authorities Arrays.stream(client.getScopes().split(,)) .map(String::trim) .filter(StringUtils::hasText) .map(scope - new SimpleGrantedAuthority(SCOPE_ scope)) .collect(Collectors.toList()); return ApiKeyAuthenticationToken.authenticated(String.valueOf(client.getId()), authorities); } Override public boolean supports(Class? authentication) { return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication); } }supports方法看着不起眼但它是整个机制能跑起来的前提。ProviderManager会遍历所有 provider只有supports返回 true 的那个才会被调用。如果你漏写或者写成了authentication.equals(...)这种错误形式表现就是认证永远失败但没有任何异常日志排查起来极其费劲。另外注意日志里只打前缀绝不打完整密钥。我见过线上日志把完整 header 打出来的情况日志系统一旦被外部人员接触等于密钥全量泄露。这个习惯必须从写代码的第一天就养成。3.3 过滤器、SecurityFilterChain 与多个链路并存过滤器只做两件事从请求头取出密钥、调用AuthenticationManager触发认证。取出之后如果上下文里已经有认证信息就直接放行避免重复认证。public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationManager authenticationManager; private final AuthenticationEntryPoint entryPoint; private final String headerName; public ApiKeyAuthenticationFilter(AuthenticationManager authenticationManager, AuthenticationEntryPoint entryPoint, String headerName) { this.authenticationManager authenticationManager; this.entryPoint entryPoint; this.headerName headerName; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String raw request.getHeader(headerName); if (!StringUtils.hasText(raw) || SecurityContextHolder.getContext().getAuthentication() ! null) { chain.doFilter(request, response); return; } try { Authentication result authenticationManager.authenticate( ApiKeyAuthenticationToken.unauthenticated(raw.trim())); SecurityContextHolder.getContext().setAuthentication(result); chain.doFilter(request, response); } catch (AuthenticationException ex) { SecurityContextHolder.clearContext(); entryPoint.commence(request, response, ex); } } Override protected boolean shouldNotFilter(HttpServletRequest request) { return HttpMethod.OPTIONS.matches(request.getMethod()); } }shouldNotFilter放过 OPTIONS 是必须的。浏览器跨域预检请求不会带自定义头如果被过滤器拦下返回 401前端会看到一个莫名其妙的 CORS 错误而真正的原因藏在网络面板的第二条请求里非常容易误判。安全配置这块用securityMatcher把 API 链路和普通 Web 链路分开。API 链路是无状态、无 CSRF、只认密钥Web 链路可以保留原有的登录逻辑。两条链路用Order排序顺序错了会出现“请求被前面的链路抢先匹配”的问题。Configuration EnableWebSecurity public class ApiSecurityConfig { Bean public ApiKeyAuthenticationFilter apiKeyAuthenticationFilter( ApiKeyAuthenticationProvider provider, ApiKeyAuthenticationEntryPoint entryPoint, Value(${security.api-key.header-name:X-Api-Key}) String headerName) { return new ApiKeyAuthenticationFilter(new ProviderManager(provider), entryPoint, headerName); } Bean Order(1) public SecurityFilterChain apiFilterChain(HttpSecurity http, ApiKeyAuthenticationFilter filter, ApiKeyAuthenticationEntryPoint entryPoint, ApiAccessDeniedHandler deniedHandler) throws Exception { http.securityMatcher(/api/**) .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement(s - s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(reg - reg .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasAuthority(SCOPE_admin) .anyRequest().authenticated()) .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex - ex .authenticationEntryPoint(entryPoint) .accessDeniedHandler(deniedHandler)); return http.build(); } Bean Order(2) public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(reg - reg.anyRequest().permitAll()); return http.build(); } }这里有个必须提前说明的坑ApiKeyAuthenticationFilter被声明成了Bean而它又实现了Filter接口Spring Boot 会自动把它注册到 Servlet 容器的过滤器链上导致它在 Security 过滤器链之外再执行一遍。表现是同一个请求认证两次缓存和限流计数翻倍。解决办法有两个我倾向用第一个Bean public FilterRegistrationBeanApiKeyAuthenticationFilter disableAutoRegistration( ApiKeyAuthenticationFilter filter) { FilterRegistrationBeanApiKeyAuthenticationFilter registration new FilterRegistrationBean(filter); registration.setEnabled(false); return registration; }另一个办法是不把它声明成 Bean直接在配置方法里new出来。前者更干净因为 filter 内部依赖的组件可以继续用依赖注入。3.4 作用域映射到接口权限与异常响应体授权规则用的是SCOPE_xxx前缀加hasAuthority。之所以不直接用hasRole是因为hasRole会自动加ROLE_前缀容易和角色体系混淆。作用域的好处是粒度可以很细order_read、order_write、report_export各管一段给不同调用方分配不同组合。异常处理分两个处理器职责不同。AuthenticationEntryPoint处理“没认证或认证失败”返回 401AccessDeniedHandler处理“已认证但权限不足”返回 403。这两个状态码混用是很常见的问题混用之后调用方无法区分“密钥错了该换密钥”还是“密钥没错但没开通这个权限该去申请”对接沟通成本直接翻倍。Component public class ApiKeyAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; public ApiKeyAuthenticationEntryPoint(ObjectMapper objectMapper) { this.objectMapper objectMapper; } Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setHeader(HttpHeaders.WWW_AUTHENTICATE, ApiKey realm\api\); objectMapper.writeValue(response.getOutputStream(), Map.of( code, AUTH_401, message, 认证失败请检查请求头中的密钥, path, request.getRequestURI() )); } }WWW-Authenticate响应头建议加上一是符合规范二是某些 HTTP 客户端和网关会依赖它来判断是否需要重试。响应体里不要带具体失败原因原因只进服务端日志。权限不足的处理器结构一样只是状态码换成 403错误码换成AUTH_403提示改成“当前密钥无权访问该资源”。这里有个细节403 的响应里可以带上“需要的作用域”方便对接方自助排查比如提示“需要 order_write 权限”这不构成信息泄露因为对方已经持有有效密钥了。3.5 实测记录MockMvc 与 curl 双向验证写完代码必须实测而且要用两种方式验证单元测试覆盖逻辑分支curl 验证真实的 HTTP 行为因为过滤器链的很多问题只在真实容器里才暴露。先准备测试数据在测试方法里插入一条记录密钥是ak_test_K7mPq2Rt9XaB_8fHjLmNpQrStUvWxYz3cD5eF6gH7j作用域设置为order_read。测试用例覆盖四种情况SpringBootTest AutoConfigureMockMvc class ApiKeyAuthTest { Autowired private MockMvc mockMvc; Test void 缺少请求头应返回401() throws Exception { mockMvc.perform(get(/api/v1/orders)) .andExpect(status().isUnauthorized()) .andExpect(jsonPath($.code).value(AUTH_401)); } Test void 错误密钥应返回401() throws Exception { mockMvc.perform(get(/api/v1/orders).header(X-Api-Key, ak_test_wrongprefix_xxxxxxxx)) .andExpect(status().isUnauthorized()); } Test void 正确密钥应放行() throws Exception { mockMvc.perform(get(/api/v1/orders).header(X-Api-Key, VALID_KEY)) .andExpect(status().isOk()); } Test void 作用域不足应返回403() throws Exception { mockMvc.perform(post(/api/v1/orders).header(X-Api-Key, VALID_KEY)) .andExpect(status().isForbidden()) .andExpect(jsonPath($.code).value(AUTH_403)); } }curl 验证真实的容器行为重点看响应头和状态码是否符合预期# 无密钥 curl -s -o /dev/null -w %{http_code}\n http://localhost:8080/api/v1/orders # 输出 401 # 正确密钥 curl -s -i -H X-Api-Key: ak_test_K7mPq2Rt9XaB_8fHjLmNpQrStUvWxYz3cD5eF6gH7j \ http://localhost:8080/api/v1/orders # 输出 200响应头包含 WWW-Authenticate 之外的内容 # 跨域预检 curl -s -i -X OPTIONS http://localhost:8080/api/v1/orders \ -H Origin: http://localhost:3000 \ -H Access-Control-Request-Method: GET我实测下来最容易出问题的是第三步。如果shouldNotFilter没写OPTIONS 会返回 401而浏览器控制台只会显示一句笼统的跨域错误排查时容易往 CORS 配置方向找实际根源在过滤器。4. 常见问题与排查实录4.1 高频问题速查表下面这张表是我和同行交流时收集到的高频问题基本覆盖了 90% 的排查场景。遇到问题先对着表定位比漫无目的地看日志快得多。现象大概率原因排查方式处理方式正确密钥也返回 401前缀长度与截取逻辑不一致打印raw.substring结果与库中key_prefix比对统一前缀常量抽成公共方法认证逻辑执行两次Filter 被自动注册到 Servlet 容器日志中出现两条相同 prefix 记录用FilterRegistrationBean.setEnabled(false)401 和 403 混用只配了 EntryPoint 没配 AccessDeniedHandler用无权限密钥访问看状态码补上accessDeniedHandler停用密钥后仍能调用走了缓存TTL 未到期看缓存命中日志缩短 TTL 或加主动失效跨域请求报错OPTIONS 被拦截网络面板看预检请求状态码在过滤器中放过 OPTIONS异步接口拿不到调用方SecurityContext未传递到子线程子线程里打印getAuthentication()为 null用DelegatingSecurityContextExecutor包装线程池日志里出现完整密钥打印了 header 全量内容全局搜索日志模板改为只打前缀加脱敏工具类表格里第一行值得展开说一下。前缀截取的长度必须和生成时保持一致但这两个地方往往在不同的类里一个是生成器一个是提供者一旦有人改了其中一个另一个就悄悄失效。我现在的做法是把前缀长度定义成一个常量放在公共类里两边都引用它改一处就全改。更稳妥的做法是提供者不去猜长度而是把整个 raw key 的前 N 位作为查询条件N 从常量读同时生成器的前缀格式也用同一个常量拼接。4.2 踩过的坑Filter 重复执行、异步上下文丢失、缓存与吊销Filter 重复执行这个问题第一次遇到时我完全没往这方向想。现象是每个请求的数据库查询次数翻倍缓存命中率算出来不对劲限流计数也比预期快一倍。加了日志才发现同一次请求里doFilterInternal打印了两遍中间夹着 Servlet 容器的调用栈。原因前面说过Filter类型的 Bean 会被 Boot 自动注册。这个坑的隐蔽之处在于如果过滤器内部做了幂等处理业务表现上完全正常只是多消耗了资源不上监控根本发现不了。异步上下文丢失是第二个高频问题。场景是接口里用CompletableFuture或者Async做并行调用子线程里想拿当前调用方的客户端标识做审计结果SecurityContextHolder.getContext().getAuthentication()返回 null。原因是SecurityContextHolder默认用ThreadLocal存储跨线程天然丢。解决办法是用DelegatingSecurityContextExecutor包装线程池Bean(auditExecutor) public Executor auditExecutor() { ThreadPoolTaskExecutor delegate new ThreadPoolTaskExecutor(); delegate.setCorePoolSize(4); delegate.setMaxPoolSize(16); delegate.setQueueCapacity(200); delegate.initialize(); return new DelegatingSecurityContextExecutor(delegate); }注意包装之后的线程池会捕获提交任务那一刻的上下文快照后续主线程上下文变化不会影响子线程。这在大部分场景是想要的但如果你的逻辑依赖共享可变状态要留意这个语义差异。缓存与吊销的矛盾前面提过这里补充一个实践细节。除了 TTL我会在管理后台的“停用密钥”操作里主动清一次本地缓存多实例部署就发一条广播消息各实例收到后清掉对应的 key。这样正常情况下吊销是秒级生效的TTL 只是兜底。这个机制不复杂但能省掉很多“为什么停用了还能调”的扯皮。还有一个容易忽略的点是密钥轮换。业务方经常提“我要换密钥但不想中断服务”那就得支持双密钥并行也就是一个客户端同时持有新旧两把密钥旧的在宽限期后失效。表结构上可以在api_client之外加一张api_client_key子表一个客户端对应多条密钥记录各自有独立的过期时间和状态。这个改造不复杂但如果一开始没设计后面迁移会很麻烦所以建议第一期就把这个扩展点留出来。4.3 上线前的加固清单与性能取舍上线前我会过一遍这份清单每一项目前都至少踩过一次坑密钥不以任何形式出现在 URL 参数里因为 URL 会进访问日志、会进浏览器历史、会被 Referer 带出去密钥不在响应体里回显哪怕是创建密钥的那一次返回也要明确告知调用方“只显示一次请立即保存”日志脱敏规则覆盖所有可能打印 header 的地方包括网关、反向代理、异常堆栈密钥的创建和停用操作要有独立的审计日志记录操作人和时间生产环境禁用测试前缀的密钥在提供者里加一道env校验。性能方面最终的瓶颈通常不在哈希计算而在数据库查询。注册实时监控关注两个指标认证缓存命中率和密钥查询的 P99 延迟。前者低于 90% 说明缓存 TTL 太短或者 key 分散度过高后者异常升高通常是key_prefix索引失效比如有人不小心在查询里用了LIKE %xxx。限流阈值的设定也要有依据。我的经验是按客户端的实际业务量分档白名单客户单独配置默认值给一个宽松但有上限的数字比如每分钟 600 次。设太紧会误伤正常业务设太松等于没有。另外失败次数的限流阈值要单独设且比正常请求的阈值严格得多因为正常业务不会连续失败。最后再分享一个实践中的体会这套东西的价值不在于代码写得多精巧而在于失败路径处理得够不够细。正常路径谁写都能跑通真正决定它能不能在线上稳定服役的是密钥错了、密钥过期了、密钥被停用了、调用方疯狂重试、网关改写了 header 大小写、跨域预检被拦这些边角情况。我现在的习惯是每写完一个认证组件先把失败分支全部列出来逐个写测试用例跑通了再合代码比事后在线上抓日志要省心得多。
阅读完成 · 觉得有帮助?
咨询建站