Spring Boot认证服务创建JWKS端点及资源服务对接方案
Spring Boot双服务JWKS实现方案
一、认证服务侧JWKS端点实现
不要手动拼接JWKS的JSON结构,直接使用nimbus-jose-jwt这类成熟的JOSE库处理,避免字段格式、Base64编码错误导致公钥无法被资源服务解析。
实现步骤
- 先把.jks密钥对的加载逻辑抽成Spring全局可复用的组件,通过配置类绑定密钥库路径、密码、别名参数,服务启动时一次性加载RSA密钥对:
@ConfigurationProperties(prefix = "jwt") public record RsaKeyProperties( String keystorePath, String keystorePassword, String keyAlias, String keyPassword ) { @Bean public KeyPair rsaKeyPair() throws Exception { KeyStore keyStore = KeyStore.getInstance("JKS"); try (InputStream is = new ClassPathResource(keystorePath).getInputStream()) { keyStore.load(is, keystorePassword.toCharArray()); } RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyStore.getKey(keyAlias, keyPassword.toCharArray()); RSAPublicKey publicKey = (RSAPublicKey) keyStore.getCertificate(keyAlias).getPublicKey(); return new KeyPair(publicKey, privateKey); } }
对应的application.yml配置示例:
jwt: keystore-path: auth-service.jks keystore-password: 你的密钥库密码 key-alias: auth-key key-password: 你的密钥密码
- 实现标准JWKS端点,路径统一使用
/.well-known/jwks.json(这是行业通用约定,不需要自定义路径),注意给这个接口放开匿名访问权限,不要加JWT鉴权逻辑:
@RestController public class JwksController { private final KeyPair rsaKeyPair; // 密钥ID,密钥轮换时更新该值即可 private static final String ACTIVE_KEY_ID = "auth-rsa-202409"; public JwksController(KeyPair rsaKeyPair) { this.rsaKeyPair = rsaKeyPair; } @GetMapping("/.well-known/jwks.json") public Map<String, Object> getJwks() throws JOSEException { RSAKey activeRsaKey = new RSAKey.Builder((RSAPublicKey) rsaKeyPair.getPublic()) .keyUse(KeyUse.SIGNATURE) .algorithm(JWSAlgorithm.RS256) .keyID(ACTIVE_KEY_ID) .build(); return new JWKSet(activeRsaKey).toJSONObject(); } }
- 改造原有JWT生成逻辑,在JWT Header中写入和JWKS一致的
kid字段,否则后续密钥轮换时资源服务无法匹配对应公钥验签。
注意事项
- 接口返回内容绝对不能包含私钥相关参数,仅返回公钥的模数、指数等公开信息即可
- 如果存在跨域访问场景,给该接口配置CORS规则,仅允许GET方法访问即可
- 密钥轮换过渡期,可以在JWKS的keys数组中同时返回新旧两个公钥,等所有旧JWT过期后再移除旧公钥条目
二、资源服务侧动态公钥获取最佳实践
核心要避开两个常见坑:一是不要每次处理请求都实时拉取JWKS,二是不要在拉取新密钥失败时直接清空本地缓存导致服务雪崩。
实现逻辑
- 引入
nimbus-jose-jwt依赖用于解析JWKS结构,Maven坐标如下:
<dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.37.3</version> </dependency>
- 实现公钥本地缓存组件,服务启动后首次拉取JWKS,后续按TTL异步刷新缓存,拉取失败时保留旧缓存继续服务:
@Component public class CachedJwksKeyRepository { private final RestTemplate restTemplate; @Value("${jwt.issuer-jwks-url:http://auth-service/.well-known/jwks.json}") private String jwksEndpoint; // kid与对应公钥的缓存映射 private volatile Map<String, PublicKey> keyCache = Collections.emptyMap(); private volatile Instant lastRefreshTime = Instant.MIN; // 缓存有效期1小时,可根据自身密钥轮换频率调整 private static final Duration CACHE_TTL = Duration.ofHours(1); public CachedJwksKeyRepository(RestTemplate restTemplate) { this.restTemplate = restTemplate; // 启动时首次加载密钥 refreshKeys(); } public PublicKey getVerifyKey(String kid) { // 缓存过期触发异步刷新,不阻塞当前请求 if (Duration.between(lastRefreshTime, Instant.now()).compareTo(CACHE_TTL) > 0) { CompletableFuture.runAsync(this::refreshKeys); } PublicKey publicKey = keyCache.get(kid); if (publicKey == null) { // 本地找不到对应kid,同步刷新一次(应对密钥刚轮换的场景) refreshKeys(); publicKey = keyCache.get(kid); if (publicKey == null) { throw new IllegalArgumentException("无效的JWT密钥ID: " + kid); } } return publicKey; } private synchronized void refreshKeys() { try { ResponseEntity<Map> resp = restTemplate.getForEntity(jwksEndpoint, Map.class); List<Map<String, Object>> keyEntries = (List<Map<String, Object>>) resp.getBody().get("keys"); Map<String, PublicKey> newCache = new HashMap<>(); for (Map<String, Object> entry : keyEntries) { RSAKey jwk = RSAKey.parse(entry); newCache.put(jwk.getKeyID(), jwk.toRSAPublicKey()); } // 仅当拉取、解析全部成功时才替换缓存 this.keyCache = newCache; this.lastRefreshTime = Instant.now(); } catch (Exception e) { // 拉取失败仅打印日志、触发告警,保留原有可用缓存,不要清空 // 可接入自身监控告警组件 e.printStackTrace(); } } }
- 改造原有JWT解码逻辑,先从JWT Header中解析kid,再从缓存中获取对应公钥验签:
private final CachedJwksKeyRepository keyRepository; public Claims decodeJWT(String jwt) { // 解析无签名的JWT头,获取kid String headerPart = jwt.substring(0, jwt.indexOf('.')); JwsHeader header = Jwts.parser() .parseClaimsJwt(new String(Base64.getUrlDecoder().decode(headerPart))) .getHeader(); String kid = header.getKeyId(); PublicKey verifyKey = keyRepository.getVerifyKey(kid); return Jwts.parser() .requireIssuer("auth-service") .requireAudience("posts-service") .setSigningKey(verifyKey) .parseClaimsJws(jwt).getBody(); }
注意事项
- 不要硬编码单个公钥,始终通过kid匹配公钥,后续做密钥轮换、多密钥兼容时不需要修改核心验签逻辑
- 公钥缓存不要设置永久有效期,否则密钥轮换后资源服务会持续用旧公钥验签导致失败
- 不要把JWKS拉取逻辑放到拦截器的每次请求流程里,高并发场景下会给认证服务造成不必要的压力,也会增加资源服务的响应延迟
内容的提问来源于stack exchange,提问作者Kris
相关产品推荐
相关产品推荐

