微服务认证与授权: 12 — JWKS 深入解析

📅 2026/7/9 20:17:54
微服务认证与授权: 12 — JWKS 深入解析
12 — JWKS 深入解析 GitHub: https://github.com/geekchow/micro-service-authJWKS 是一个实时的公钥目录它让banking-api-service能按kid选出正确的密钥并安全地验证 JWT 签名 —— 即便Keycloak轮换了签名密钥。全文使用的固定术语见 01 — 概念KeycloakIdP、KongPEP、OPAPDP、banking-api-service资源服务器。快速定义JWK—— 描述一把密码学密钥的 JSON 对象。JWKS—— 一个含有keys数组的 JSON 文档数组里有一个或多个 JWK。kid—— 密钥 ID用于选出正确的密钥来做验证。jwk-set-uri—— 提供 JWKS 文档的端点。JWKS 为什么存在对于像RS256这样的非对称签名Keycloak用私钥签名 JWT。服务用配对的公钥验证 JWT。公钥可能轮换。如果服务硬编码某一把密钥轮换时验证就会失败。JWKS 通过在一个稳定的 URL 上发布当前的公钥来解决这个问题。服务按需从该 URL 获取并缓存结果。它在本 PoC 中如何落位banking-api-service在services/banking-api-service/src/main/resources/application.yml中配置了spring.security.oauth2.resourceserver.jwt.jwk-set-uri这告诉 Spring Security 到哪里去获取用于校验 JWT 签名的公钥。在本 PoC 中该值由docker-compose.yml注入为BANKING_API_JWK_SET_URI:http://keycloak:8080/realms/banking-poc/protocol/openid-connect/certs为什么是http://keycloak:8080而不是http://localhost:9081banking-api-service运行在 Docker Compose 网络内部。在该网络内部keycloak是服务主机名。容器内的localhost指向的是banking-api-service容器自身而不是Keycloak。访问者URL宿主机http://localhost:9081/realms/banking-poc/protocol/openid-connect/certs容器对容器http://keycloak:8080/realms/banking-poc/protocol/openid-connect/certsJWKS 查找流程当一个带 bearer 令牌的请求到来时Spring Security 按以下步骤进行接收带 bearer 令牌的请求。解码 JWT 的 header 并读取kid。从Keycloak获取 JWKS或使用已缓存的密钥集。选出kid与令牌 header 中kid匹配的那把 JWK。用该密钥验证 JWT 签名。若签名及所有其他校验器都通过则认证该请求。Keycloakbanking-api-serviceKongaliceKeycloakbanking-api-serviceKongaliceGET /api/accounts/A-1001 Bearer JWTForward request Bearer JWTDecode JWT header, read kidGET /realms/banking-poc/protocol/openid-connect/certs{ keys: [ ... ] }Select key by kid, verify signature200 / 401 / 403response本 PoC 中实际的 JWKS 响应获取Keycloak的 JWKS 端点会返回这种形态的 JSON{keys:[{kid:gMTvER9Ofps6D0UuEk2av7caU5GlZd4sS-c7fWkyxoA,kty:RSA,alg:RSA-OAEP,use:enc,n:...,e:AQAB,x5c:[...],x5t:...,x5t#S256:...},{kid:0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk,kty:RSA,alg:RS256,use:sig,n:...,e:AQAB,x5c:[...],x5t:...,x5t#S256:...}]}注意该端点返回了不止一把密钥。并非每把密钥用途相同 ——use用来区分它们。为什么出现了两把密钥Keycloak暴露了两把用途不同的 RSA 密钥。密钥 1 —— 加密密钥{alg:RSA-OAEP,use:enc}use: enc表示这把密钥用于加密场景而非签名验证。banking-api-service不会用这把密钥来验证 JWT 签名。密钥 2 —— 签名密钥{kid:0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk,kty:RSA,alg:RS256,use:sig}use: sig表示这把密钥用于签名验证。alg: RS256与本 PoC 中 JWT header 的算法匹配。这就是 Spring Security 用来验证 JWT 签名的密钥。banking-api-service关心的是第二把密钥而非第一把。一把 JWK 的解剖RSA一把典型的 RSA 公钥 JWK 看起来是这样{kty:RSA,kid:0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk,use:sig,alg:RS256,n:...,e:AQAB}字段含义kty密钥类型。这里是RSA。kid密钥 ID。必须与 JWT header 中的kid匹配。use预期用途。sig表示签名验证。alg预期算法。本 PoC 中是RS256。nRSA 模数公开部分。eRSA 公钥指数。x5c、x5t与x5t#S256的含义Keycloak在 RSA 密钥材料旁还附带了证书字段。字段含义x5cX.509 证书链 —— 公钥材料的另一种表示。x5t证书的 SHA-1 指纹。x5t#S256证书的 SHA-256 指纹。Spring Security 主要需要n与e来重建用于验证的 RSA 公钥。证书字段在 JWKS 响应中是正常的对本 PoC 的验证过程没有影响。通过kid把 JWT header 匹配到 JWK本 PoC 中的一个 JWT header 形如{alg:RS256,typ:JWT,kid:0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk}密钥选择过程从 JWT header 取出kid。在 JWKS 的keys数组中找到kid相同的那把 JWK。用该 JWK 的公钥验证签名。如果没有 JWK 匹配该kid验证失败Spring Security 返回401 Unauthorized。本 PoC 中的具体匹配Spring 寻找满足以下条件的 JWKkid 0BYek66uebuec84BqxfwJ9_qxIDr1Wka-1siBT2z0Lk它还确认use sigalg RS256那正是用于验证该令牌的确切公钥条目。密钥轮换与多把密钥JWKS 在轮换期间通常会同时持有多把密钥。轮换期间的示例形态{keys:[{kid:old-key,kty:RSA,use:sig,n:...,e:AQAB},{kid:new-key,kty:RSA,use:sig,n:...,e:AQAB}]}为什么这很重要旧令牌在过期前仍用旧密钥验证通过。新令牌立即用新密钥验证。服务不会仅因为签名密钥轮换就需要重新部署。每个令牌 header 中的kid始终精确指向那把正确的密钥无论此刻 JWKS 中有多少把密钥。实践中的 Spring Security 行为banking-api-service使用NimbusJwtDecoder它由jwk-set-uri构建并与签发者、受众校验器组合。运行时Spring Security 按以下顺序进行读取 JWT header 并提取kid。若尚未缓存则从Keycloak获取 JWKS。定位kid匹配的那把 JWK。从n与e重建 RSA 公钥。验证 JWT 签名。检查令牌未过期exp。检查iss与配置的签发者匹配。检查aud包含配置的受众。若任一检查失败Spring Security 会在控制器逻辑运行之前就拒绝该请求。请求被接受要求以下全部成立检查描述签名对所选 JWK 有效过期exp尚未过签发者iss与配置的Keycloakrealm 匹配受众aud包含期望的值所以 JWKS 端点实际上就是banking-api-service的一个实时公钥目录。排障时如何检查 JWKS从宿主机直接获取 JWKScurl-sShttp://localhost:9081/realms/banking-poc/protocol/openid-connect/certs|jq解码一个令牌的 header 看它带的是哪个kidTOKENaccess-tokenprintf%s$TOKEN|cut-d.-f1|base64--decode2/dev/null|jq检查令牌 header 中的kid存在于 JWKS 的keys数组中。令牌 header 中的算法与所选 JWK 的alg匹配。令牌载荷中的iss与aud与banking-api-service的配置匹配。常见失败模式失败症状错误的jwk-set-uribanking-api-service无法获取密钥所有令牌校验都失败。陈旧或缺失的kid令牌由一把不在所获取 JWKS 中的密钥签名401 Unauthorized。签发者不匹配令牌由另一个Keycloakrealm 签发。受众不匹配令牌并非发给banking-api-service。网络或路径问题banking-api-service在启动或刷新时无法到达Keycloak的 certs 端点。以上所有都会表现为401 Unauthorized。安全说明JWKS 端点只包含公钥 —— 绝不含私钥。暴露 JWKS 是预期且正常的它不是机密。信任来自 HTTPS、签发者验证与正确的端点配置 —— 而非来自把密钥藏起来。不要仅因为签名通过就关闭签发者或受众检查。四个校验器一起才构成正确的信任边界。← Prev: 11 — JWT 签名、校验与内省 · Next: 13 — 访问令牌与刷新令牌的生命周期 →