好的,我们开始。
JWT 公钥轮换导致的验签失败与 JWKS-URI 动态刷新及本地缓存双写一致性
大家好,今天我们要探讨一个在微服务架构中常见的安全问题:JWT (JSON Web Token) 公钥轮换导致的验签失败,以及如何通过 JWKS-URI (JSON Web Key Set URI) 动态刷新和本地缓存的双写策略来保证一致性,从而解决这个问题。
JWT 简介与公钥轮换的必要性
首先,我们简单回顾一下 JWT。JWT 是一种用于在各方之间安全地传输信息的开放标准 (RFC 7519)。它是一种紧凑、自包含的方式,用于以 JSON 对象的形式安全地传输信息。JWT 可以被验证和信任,因为它是经过数字签名的。
JWT 通常由三个部分组成:
-
Header (头部): 定义 JWT 的类型和使用的签名算法,例如:
{ "alg": "RS256", "typ": "JWT", "kid": "your-key-id" }alg表示签名算法,kid表示密钥 ID,用于标识使用哪个密钥进行签名。 -
Payload (载荷): 包含要传输的数据,例如用户 ID、角色等。
{ "sub": "user123", "name": "John Doe", "iat": 1516239022 }sub表示主题 (Subject),通常是用户 ID;name表示用户姓名;iat表示签发时间 (Issued At)。 -
Signature (签名): 使用头部指定的签名算法和密钥对头部和载荷进行签名。
Signature = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
在微服务架构中,通常使用 JWT 进行身份验证和授权。服务 A (例如认证服务) 使用私钥对 JWT 进行签名,其他服务 (例如资源服务) 使用相应的公钥来验证 JWT 的签名,从而验证 JWT 的有效性。
公钥轮换的必要性:
- 安全性增强: 定期更换密钥可以降低密钥泄露后造成的安全风险。即使一个密钥泄露,攻击者也只能在密钥有效期间利用它。
- 合规性要求: 某些安全标准和法规要求定期轮换密钥。
- 密钥管理: 密钥轮换是密钥管理策略的重要组成部分。
公钥轮换带来的问题:验签失败
当认证服务轮换公钥时,如果资源服务仍然使用旧的公钥来验证 JWT,就会导致验签失败。这会影响服务的可用性和用户体验。为了解决这个问题,我们需要一种机制来让资源服务能够动态地获取最新的公钥。
JWKS-URI 方案:动态获取公钥
JWKS-URI (JSON Web Key Set URI) 是一种标准的解决方案,用于发布公钥集合。认证服务提供一个 HTTP(S) 端点,该端点返回一个 JSON 文档,其中包含当前有效的公钥集合。资源服务可以定期从 JWKS-URI 获取最新的公钥集合,并使用它来验证 JWT。
JWKS 文档示例:
{
"keys": [
{
"kty": "RSA",
"kid": "your-key-id-1",
"n": "...", // Base64URL 编码的模数
"e": "AQAB" // Base64URL 编码的指数
},
{
"kty": "RSA",
"kid": "your-key-id-2",
"n": "...",
"e": "AQAB"
}
]
}
kty: 密钥类型,例如 RSA。kid: 密钥 ID,用于标识特定的密钥。n: RSA 模数。e: RSA 指数。
当 JWT 的头部包含 kid 时,资源服务可以根据 kid 在 JWKS 文档中查找相应的公钥,并使用它来验证 JWT 的签名。
动态刷新与本地缓存:权衡与挑战
虽然 JWKS-URI 提供了一种动态获取公钥的机制,但频繁地从 JWKS-URI 获取公钥会带来以下问题:
- 性能影响: 每次验证 JWT 都需要发起 HTTP(S) 请求,会增加延迟。
- 依赖性增加: 资源服务依赖于认证服务的可用性。如果认证服务不可用,资源服务将无法验证 JWT。
为了解决这些问题,通常采用本地缓存的策略。资源服务将从 JWKS-URI 获取的公钥缓存在本地,并在验证 JWT 时首先从本地缓存中查找公钥。只有当本地缓存中没有找到相应的公钥时,才从 JWKS-URI 获取。
双写一致性的挑战:
在使用本地缓存时,我们需要考虑一个重要的问题:如何保证本地缓存与 JWKS-URI 的数据一致性?如果 JWKS-URI 上的公钥已经轮换,但本地缓存仍然包含旧的公钥,就会导致验签失败。
双写一致性解决方案:主动刷新与过期策略
为了保证本地缓存与 JWKS-URI 的数据一致性,我们需要采用一种双写策略。双写策略指的是,当 JWKS-URI 上的公钥发生变化时,我们需要同时更新本地缓存。
常见的双写策略包括:
-
主动刷新 (Push-based):认证服务在公钥轮换后,主动通知所有资源服务更新本地缓存。这种方式可以保证实时性,但实现起来比较复杂,需要认证服务维护一个资源服务列表,并实现通知机制。
-
过期策略 (Pull-based):资源服务定期从 JWKS-URI 获取最新的公钥集合,并更新本地缓存。这种方式实现起来比较简单,但需要合理设置过期时间,以保证数据的一致性。
由于主动刷新实现较为复杂,我们这里重点介绍过期策略。
过期策略的实现:
- 定期刷新: 资源服务使用一个定时任务,定期从 JWKS-URI 获取最新的公钥集合,并更新本地缓存。
- 基于 TTL (Time-To-Live) 的过期: 为本地缓存中的每个公钥设置一个 TTL。当公钥过期时,从 JWKS-URI 重新获取。
代码示例 (Java):
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class JwksCache {
private static final String JWKS_URI = "your_jwks_uri"; // 替换为你的 JWKS URI
private static final long REFRESH_INTERVAL = 60; // 刷新间隔 (秒)
private static final Map<String, PublicKey> publicKeyCache = new HashMap<>();
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
static {
// 初始化时刷新一次
refreshJwks();
// 定期刷新
scheduler.scheduleAtFixedRate(JwksCache::refreshJwks, 0, REFRESH_INTERVAL, TimeUnit.SECONDS);
}
public static PublicKey getPublicKey(String keyId) {
return publicKeyCache.get(keyId);
}
private static void refreshJwks() {
try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
HttpGet httpGet = new HttpGet(JWKS_URI);
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
if (response.getStatusLine().getStatusCode() == 200) {
String json = EntityUtils.toString(response.getEntity());
JsonNode root = objectMapper.readTree(json);
if (root.has("keys") && root.get("keys").isArray()) {
root.get("keys").forEach(key -> {
try {
String kid = key.get("kid").asText();
String kty = key.get("kty").asText();
String n = key.get("n").asText();
String e = key.get("e").asText();
if ("RSA".equals(kty)) {
byte[] modulusBytes = Base64.getUrlDecoder().decode(n);
byte[] exponentBytes = Base64.getUrlDecoder().decode(e);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(createPublicKeyDer(modulusBytes, exponentBytes));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);
publicKeyCache.put(kid, publicKey);
System.out.println("刷新公钥: kid=" + kid);
} else {
System.out.println("不支持的密钥类型: " + kty);
}
} catch (Exception e) {
System.err.println("解析 JWKS 失败: " + e.getMessage());
e.printStackTrace();
}
});
} else {
System.err.println("JWKS 格式错误: 缺少 'keys' 属性或 'keys' 不是数组");
}
} else {
System.err.println("获取 JWKS 失败: HTTP 状态码 " + response.getStatusLine().getStatusCode());
}
}
} catch (IOException e) {
System.err.println("获取 JWKS 失败: " + e.getMessage());
e.printStackTrace();
}
}
// 创建符合 X.509 DER 编码的公钥
private static byte[] createPublicKeyDer(byte[] modulus, byte[] exponent) throws IOException {
// ASN.1 结构定义
// PublicKeyInfo ::= SEQUENCE {
// algorithm AlgorithmIdentifier,
// subjectPublicKey BIT STRING
// }
// AlgorithmIdentifier ::= SEQUENCE {
// algorithm OBJECT IDENTIFIER,
// parameters ANY DEFINED BY algorithm OPTIONAL
// }
// RSAPublicKey ::= SEQUENCE {
// modulus INTEGER, -- n
// publicExponent INTEGER -- e
// }
//构建 RSAPublicKey
byte[] modulusSequence = createLengthEncodedBytes(modulus);
byte[] exponentSequence = createLengthEncodedBytes(exponent);
byte[] rsaPublicKeyContent = concat(modulusSequence, exponentSequence);
byte[] rsaPublicKeySequenceHeader = {(byte) 0x30, (byte) (rsaPublicKeyContent.length & 0xFF)}; //SEQUENCE
byte[] rsaPublicKey = concat(rsaPublicKeySequenceHeader, rsaPublicKeyContent);
//构建 AlgorithmIdentifier
byte[] algorithmOid = {(byte) 0x06, (byte) 0x09, (byte) 0x2A, (byte) 0x86, (byte) 0x48, (byte) 0x86, (byte) 0xF7, (byte) 0x0D, (byte) 0x01, (byte) 0x01, (byte) 0x01}; //OID for RSA
byte[] algorithmSequence = {(byte) 0x30, (byte) 0x0D}; // SEQUENCE
byte[] algorithmIdentifier = concat(algorithmSequence, algorithmOid);
// 构建 SubjectPublicKeyInfo
byte[] publicKeyBitStringHeader = {(byte) 0x03, (byte) (rsaPublicKey.length + 1), (byte) 0x00}; //BIT STRING, 加 1 是因为 BIT STRING 需要一个 unused bits 的字节
byte[] publicKeyBitString = concat(publicKeyBitStringHeader, rsaPublicKey);
byte[] subjectPublicKeyInfoContent = concat(algorithmIdentifier, publicKeyBitString);
byte[] subjectPublicKeyInfoSequenceHeader = {(byte) 0x30, (byte) (subjectPublicKeyInfoContent.length & 0xFF)}; //SEQUENCE
return concat(subjectPublicKeyInfoSequenceHeader, subjectPublicKeyInfoContent);
}
private static byte[] createLengthEncodedBytes(byte[] content) {
int length = content.length;
byte[] lengthBytes;
if (length < 128) {
lengthBytes = new byte[]{(byte) length};
} else if (length <= 255) {
lengthBytes = new byte[]{(byte) 0x81, (byte) length};
} else if (length <= 65535) {
lengthBytes = new byte[]{(byte) 0x82, (byte) ((length >> 8) & 0xFF), (byte) (length & 0xFF)};
} else {
throw new IllegalArgumentException("Content too long for DER encoding");
}
return concat(new byte[]{(byte) 0x02}, lengthBytes, content); //INTEGER
}
private static byte[] concat(byte[]... arrays) {
int totalLength = 0;
for (byte[] array : arrays) {
totalLength += array.length;
}
byte[] result = new byte[totalLength];
int destPos = 0;
for (byte[] array : arrays) {
System.arraycopy(array, 0, result, destPos, array.length);
destPos += array.length;
}
return result;
}
public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeySpecException {
// 模拟获取 JWT 并验证
String jwt = "your_jwt_token"; // 替换为你的 JWT
String keyId = "your-key-id-1"; // 替换为你的 Key ID
PublicKey publicKey = JwksCache.getPublicKey(keyId);
if (publicKey != null) {
System.out.println("找到公钥, 开始验证 JWT...");
// 在这里使用 publicKey 验证 JWT
// ...
} else {
System.out.println("未找到公钥, 无法验证 JWT");
}
// 保持程序运行,观察定期刷新效果
try {
Thread.sleep(300000); // 5 分钟
} catch (InterruptedException e) {
e.printStackTrace();
}
scheduler.shutdown();
}
}
代码解释:
JWKS_URI: 需要替换成你自己的JWKS URI地址。REFRESH_INTERVAL: 定义了多久刷新一次,这里设置为60秒,可以根据实际情况调整。publicKeyCache: 用于缓存公钥,Key为Key ID (kid),Value为PublicKey。refreshJwks(): 这个方法负责从JWKS URI获取公钥,并更新本地缓存。- 使用
ScheduledExecutorService来定期执行refreshJwks()方法,实现定期刷新。 - 加入了RSA密钥的DER编码处理,使代码能够正确解析和使用RSA公钥。
选择合适的过期时间:
过期时间的设置需要权衡性能和一致性。
- 短过期时间: 可以更快地反映公钥轮换,但会增加 HTTP(S) 请求的频率,影响性能。
- 长过期时间: 可以减少 HTTP(S) 请求的频率,提高性能,但可能会导致在公钥轮换后的一段时间内验签失败。
通常建议将过期时间设置为略小于公钥的轮换周期。例如,如果公钥每天轮换一次,可以将过期时间设置为 20 小时。
错误处理:
在实际应用中,我们需要考虑各种错误情况,例如:
- 网络错误: 无法连接到 JWKS-URI。
- JWKS-URI 返回错误: 返回 500 错误或无效的 JSON 文档。
- 密钥解析错误: 无法解析 JWKS 文档中的密钥。
在遇到这些错误时,我们需要进行适当的错误处理,例如:
- 重试: 尝试重新获取 JWKS。
- 使用旧的公钥: 如果无法获取新的公钥,可以使用旧的公钥进行验证,直到旧的公钥过期。
- 记录日志: 记录错误信息,以便进行排查。
完整的 JWT 验证流程
- 接收 JWT: 资源服务接收到客户端发送的 JWT。
- 解析 JWT 头部: 从 JWT 头部获取
kid。 - 查找本地缓存: 根据
kid在本地缓存中查找相应的公钥。 - 如果找到公钥: 使用该公钥验证 JWT 的签名。
- 如果未找到公钥: 从 JWKS-URI 获取最新的公钥集合,更新本地缓存,并使用新的公钥验证 JWT 的签名。
- 验证 JWT 的其他声明: 验证 JWT 的过期时间、签发者等声明。
- 授权: 根据 JWT 中包含的用户信息进行授权。
安全性考虑
- HTTPS: 必须使用 HTTPS 来保护 JWKS-URI,防止中间人攻击。
- 缓存安全: 保护本地缓存,防止未经授权的访问。
- 限制 JWKS-URI 的访问: 限制可以访问 JWKS-URI 的客户端 IP 地址。
总结:权衡策略与一致性至关重要
公钥轮换是保障 JWT 安全的重要手段,但它也带来了验签失败的问题。通过 JWKS-URI 动态刷新和本地缓存的双写策略,我们可以解决这个问题。选择合适的双写策略和过期时间,并进行适当的错误处理和安全性考虑,是保证 JWT 验证的可靠性和安全性的关键。最终需要在实时性和性能之间找到一个平衡点。