You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 03:06:10