JWT公钥轮换导致验签失败?Jwks-Uri动态刷新与本地缓存双写一致性

好的,我们开始。

JWT 公钥轮换导致的验签失败与 JWKS-URI 动态刷新及本地缓存双写一致性

大家好,今天我们要探讨一个在微服务架构中常见的安全问题:JWT (JSON Web Token) 公钥轮换导致的验签失败,以及如何通过 JWKS-URI (JSON Web Key Set URI) 动态刷新和本地缓存的双写策略来保证一致性,从而解决这个问题。

JWT 简介与公钥轮换的必要性

首先,我们简单回顾一下 JWT。JWT 是一种用于在各方之间安全地传输信息的开放标准 (RFC 7519)。它是一种紧凑、自包含的方式,用于以 JSON 对象的形式安全地传输信息。JWT 可以被验证和信任,因为它是经过数字签名的。

JWT 通常由三个部分组成:

  1. Header (头部): 定义 JWT 的类型和使用的签名算法,例如:

    {
      "alg": "RS256",
      "typ": "JWT",
      "kid": "your-key-id"
    }

    alg 表示签名算法,kid 表示密钥 ID,用于标识使用哪个密钥进行签名。

  2. Payload (载荷): 包含要传输的数据,例如用户 ID、角色等。

    {
      "sub": "user123",
      "name": "John Doe",
      "iat": 1516239022
    }

    sub 表示主题 (Subject),通常是用户 ID;name 表示用户姓名;iat 表示签发时间 (Issued At)。

  3. 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 上的公钥发生变化时,我们需要同时更新本地缓存。

常见的双写策略包括:

  1. 主动刷新 (Push-based):认证服务在公钥轮换后,主动通知所有资源服务更新本地缓存。这种方式可以保证实时性,但实现起来比较复杂,需要认证服务维护一个资源服务列表,并实现通知机制。

  2. 过期策略 (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 验证流程

  1. 接收 JWT: 资源服务接收到客户端发送的 JWT。
  2. 解析 JWT 头部: 从 JWT 头部获取 kid
  3. 查找本地缓存: 根据 kid 在本地缓存中查找相应的公钥。
  4. 如果找到公钥: 使用该公钥验证 JWT 的签名。
  5. 如果未找到公钥: 从 JWKS-URI 获取最新的公钥集合,更新本地缓存,并使用新的公钥验证 JWT 的签名。
  6. 验证 JWT 的其他声明: 验证 JWT 的过期时间、签发者等声明。
  7. 授权: 根据 JWT 中包含的用户信息进行授权。

安全性考虑

  • HTTPS: 必须使用 HTTPS 来保护 JWKS-URI,防止中间人攻击。
  • 缓存安全: 保护本地缓存,防止未经授权的访问。
  • 限制 JWKS-URI 的访问: 限制可以访问 JWKS-URI 的客户端 IP 地址。

总结:权衡策略与一致性至关重要

公钥轮换是保障 JWT 安全的重要手段,但它也带来了验签失败的问题。通过 JWKS-URI 动态刷新和本地缓存的双写策略,我们可以解决这个问题。选择合适的双写策略和过期时间,并进行适当的错误处理和安全性考虑,是保证 JWT 验证的可靠性和安全性的关键。最终需要在实时性和性能之间找到一个平衡点。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注