接口安全这件事,做到最后往往会发现,真正麻烦的不是“怎么把请求拦住”,而是“为什么用这套方式拦”。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。这个量级意味着什么?即使攻击者每秒尝试十亿次,穷举完一半密钥空间需要的年数也是天文数字,远超宇宙年龄。作为对比:
| 字符集大小 | 随机段长度 | 理论熵 | 评价 |
|---|---|---|---|
| 36(UUID 去掉横线) | 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</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 Cache<String, ApiClient> cache; public ApiKeyAuthenticationProvider(ApiClientRepository repository, Cache<String, 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); } List<GrantedAuthority> 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 FilterRegistrationBean<ApiKeyAuthenticationFilter> disableAutoRegistration( ApiKeyAuthenticationFilter filter) { FilterRegistrationBean<ApiKeyAuthenticationFilter> 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处理“没认证或认证失败”,返回 401;AccessDeniedHandler处理“已认证但权限不足”,返回 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 大小写、跨域预检被拦这些边角情况。我现在的习惯是每写完一个认证组件,先把失败分支全部列出来,逐个写测试用例,跑通了再合代码,比事后在线上抓日志要省心得多。