1. 概述

本文将演示如何配置 Java 的 SSLContext,使其能根据目标服务器动态选择不同的客户端证书。我们先使用 Apache HttpComponents 实现一个简单方案,然后通过自定义路由 KeyManagerTrustManager 提供更灵活的解决方案。

2. 场景与环境搭建

我们将模拟一个 Java 客户端,需要向两个不同的 HTTPS 接口发起请求,且每个接口都要求使用不同的客户端证书进行双向 TLS 认证。为便于演示,假设 https://api.service1/https://api.service2/ 是两个内网服务。

2.1 生成 CA、密钥和信任库

我们的环境为每个主机准备了独立的客户端/服务器密钥对。每个密钥由共享的证书颁发机构(CA)签名,且 TLS 连接的双方都信任该 CA。

手动创建 CA、签名证书、密钥库和信任库容易出错且重复。为简化流程,我们提供了一个自动化脚本

  • 创建私有 CA 并添加到信任库(如 trust.api.service1.p12
  • 为客户端和服务器生成独立的密钥对
  • 使用 CA 对客户端和服务器证书进行签名
  • 将证书和密钥打包到 PKCS12 密钥库(如 client.api.service1.p12server.api.service1.p12

此外,我们使用与接口主机名相同的别名作为文件名前缀,便于区分。为简化示例,所有密码统一设置。生产环境中请务必使用不同密码!

2.2 模拟服务器与证书配置

使用 WireMock 模拟服务器,依赖两个属性:CERTS_DIR(存放 p12 文件的目录)和 PASSWORD

private static WireMockServer mockHttpsServer(String hostname, int port) {
    return new WireMockServer(WireMockConfiguration.options()
      .bindAddress(hostname)
      .httpsPort(port)
      .trustStorePath(CERTS_DIR + "/trust." + host + ".p12")
      .trustStorePassword(password)
      .keystorePath(CERTS_DIR + "/server." + host + ".p12")
      .keystorePassword(PASSWORD)
      .keyManagerPassword(PASSWORD)
      .needClientAuth(true));
}

关键配置说明

  • 绑定地址:每个证书绑定特定主机名,需在此明确指定
  • HTTPS 端口:测试使用的端口
  • 密钥库/信任库路径:因使用自签名证书,必须指定信任库。示例中约定信任库以 trust. 前缀命名,服务器密钥库以 server. 前缀命名
  • 客户端认证:显式启用 mTLS
  • 密码:示例中统一使用相同密码

3. 使用 Apache HTTP Components

首先介绍如何利用 Apache HTTP 库为不同客户端配置独立的 SSL 上下文。

3.1 配置客户端

基于约定的文件前缀(trust.client.),只需传入接口主机名即可构建 SSLContext借助库提供的 SSLContexts 工具类 加载信任材料和密钥材料

private CloseableHttpClient httpsClient(String host) {
    char[] password = PASSWORD.toCharArray();

    SSLContext context = SSLContexts.custom()
      .loadTrustMaterial(Paths.get(CERTS_DIR + "/trust." + host + ".p12"), password)
      .loadKeyMaterial(Paths.get(CERTS_DIR + "/client." + host + ".p12"), password, password)
      .build();

    // ...
}

接着创建连接管理器,并将新构建的 SSL 上下文设置为 TLS 套接字策略。**使用 HttpComponents Core 5 引入的 DefaultClientTlsStrategy**:

var manager = PoolingHttpClientConnectionManagerBuilder.create() 
  .setTlsSocketStrategy(new DefaultClientTlsStrategy(context)) 
  .build();

最后基于管理器返回已配置的 HTTPS 客户端,可直接用于安全 API 调用:

return HttpClients.custom()
  .setConnectionManager(manager)
  .build();

3.2 调用接口

准备好基础代码后,为每个 API 调用创建独立的客户端配置:

@Test
void whenBuildingSeparateContexts_thenCorrectCertificateUsed() {
    CloseableHttpClient client1 = httpsClient("api.service1");

    HttpGet api1Get = new HttpGet("https://api.service1:10443/test");
    client1.execute(api1Get, response -> {
        assertEquals(HttpStatus.SC_OK, response.getCode());
        return response;
    });

    CloseableHttpClient client2 = httpsClient("api.service2");

    HttpGet api2Get = new HttpGet("https://api.service2:20443/test");
    client2.execute(api2Get, response -> {
        assertEquals(HttpStatus.SC_OK, response.getCode());
        return response;
    });
}

接下来看看如何在不依赖第三方库且使用单个 SSLContext 的情况下实现相同功能。

4. 自定义 KeyManagerTrustManager

通过扩展 Java SSL 包中的抽象类 X509ExtendedKeyManagerX509ExtendedTrustManager,可完全控制 SSL 握手过程中证书密钥和信任库的加载与使用。我们将利用它们创建 RoutingSslContextBuilder,根据主机名动态选择证书。

4.1 加载 KeyStore

先实现加载密钥库的工具方法

public class CertUtils {

    private static KeyStore loadKeyStore(Path path, String password) {
        KeyStore store = KeyStore.getInstance(path.toFile(), password.toCharArray());
        try (InputStream stream = Files.newInputStream(path)) {
            store.load(stream, password.toCharArray());
        }
        return store;
    }

    // ...
}

4.2 加载 KeyManagerTrustManager

组合工具方法加载 X509KeyManager 类型的密钥管理器。选择此类型是因为 KeyManager 仅是标记接口,而 X.509 是证书格式标准:

public static X509KeyManager loadKeyManager(Path path, String password) {
    KeyStore store = loadKeyStore(path, password);

    KeyManagerFactory factory = 
      KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
    factory.init(store, password.toCharArray());

    return (X509KeyManager) Stream.of(factory.getKeyManagers())
      .filter(X509KeyManager.class::isInstance)
      .findAny()
      .orElseThrow();
}

同样为信任库加载信任管理器:

public static X509TrustManager loadTrustManager(Path path, String password) {
    KeyStore store = loadKeyStore(path, password);

    TrustManagerFactory factory = 
      TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    factory.init(store);

    return (X509TrustManager) 
      filter(factory.getTrustManagers(), X509TrustManager.class::isInstance);
}

4.3 创建 RoutingKeyManager

通过自定义密钥管理器,根据主机名或证书别名选择使用的密钥库。我们将所有密钥库存储在 Map 中,并重写 X509ExtendedKeyManager 的方法。

扩展此类时并非总能获取主机名,此时将在 select() 方法中接收别名。因此该策略仅在目标服务器主机名与证书别名匹配时有效

public class RoutingKeyManager extends X509ExtendedKeyManager {

    private final Map<String, X509KeyManager> hostMap = new HashMap<>();

    public void put(String host, X509KeyManager manager) {
        hostMap.put(host, manager);
    }

    private X509KeyManager select(String host) {
        X509KeyManager manager = hostMap.get(host);
        if (manager == null)
            throw new IllegalArgumentException("key manager not found for " + host);

        return manager;
    }

    // ...
}

chooseEngineClientAlias() 方法用于选择客户端认证别名。我们从 SSLEngine 参数获取主机名,并委托给对应管理器的 chooseClientAlias(),忽略 Socket 参数

@Override
public String chooseEngineClientAlias(
  String[] keyType, Principal[] issuers, SSLEngine engine) {
    String host = engine.getPeerHost();
    return select(host).chooseClientAlias(keyType, issuers, (Socket) null);
}

接下来重写并委托 getCertificateChain()注意这次基于别名选择密钥管理器

@Override
public X509Certificate[] getCertificateChain(String alias) {
    return select(alias).getCertificateChain(alias);
}

最后需要委托的方法是 getPrivateKey()

@Override
public PrivateKey getPrivateKey(String alias) {
    return select(alias).getPrivateKey(alias);
}

对于其他 X509KeyManager 方法,我们直接抛出 UnsupportedOperationException

@Override
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
    throw new UnsupportedOperationException();
}

// ...

4.4 创建 RoutingTrustManager

自定义信任管理器与 RoutingKeyManager 思路一致,根据主机名委托给对应的信任管理器:

public class RoutingTrustManager extends X509ExtendedTrustManager {

    private final Map<String, X509TrustManager> hostMap = new HashMap<>();

    public void put(String host, X509TrustManager manager) {
        hostMap.put(host, manager);
    }

    private X509TrustManager select(String host) {
        X509TrustManager manager = hostMap.get(host);
        if (manager == null)
            throw new IllegalArgumentException("trust manager not found for " + host);

        return manager;
    }

    // ...
}

**这次只需实现带 SSLEngine 参数的 checkServerTrusted()**:

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
  throws CertificateException {
    String host = engine.getPeerHost();
    select(host).checkServerTrusted(chain, authType);
}

其他重写方法同样抛出 UnsupportedOperationException

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
  throws CertificateException {
    throw new UnsupportedOperationException();
}

5. 整合实现 RoutingSslContextBuilder

最后一步是构建 SSL 上下文。由于我们的实现内部处理路由逻辑,所有 API 调用只需单个 HttpClient,即使它们需要不同证书

5.1 创建构建器

先创建一个构建器类组合自定义管理器:

public class RoutingSslContextBuilder {

    private final RoutingKeyManager routingKeyManager;
    private final RoutingTrustManager routingTrustManager;

    public RoutingSslContextBuilder() {
        routingKeyManager = new RoutingKeyManager();
        routingTrustManager = new RoutingTrustManager();
    }

    public static RoutingSslContextBuilder create() {
        return new RoutingSslContextBuilder();
    }

    // ...
}

构建实例时,为每个主机与证书组合调用此方法。这会为每个需访问的服务器加载密钥和信任管理器

public RoutingSslContextBuilder trust(String host, String certsDir, String password) {
    routingTrustManager.put(host, CertUtils.loadTrustManager(
      Paths.get(certsDir, "trust." + host + ".p12"), password));
    routingKeyManager.put(host, CertUtils.loadKeyManager(
      Paths.get(certsDir, "client." + host + ".p12"), password));
    return this;
}

最后用自定义管理器初始化 SSL 上下文,SecureRandom 参数设为 null 使用默认实现

public SSLContext build() throws NoSuchAlgorithmException, KeyManagementException {
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(
      new KeyManager[] { routingKeyManager }, 
      new TrustManager[] { routingTrustManager }, 
      null);

    return context;
}

5.2 使用 Java 原生 HttpClient 测试

因不再依赖 Apache HttpComponents,改用 Java 核心库发起请求。先构建 SSL 上下文和 HttpClient

@Test
void whenBuildingCustomSslContext_thenCorrectCertificateUsedForEachConnection() {
    SSLContext context = RoutingSslContextBuilder.create()
      .trust("api.service1", CERTS_DIR, PASSWORD)
      .trust("api.service2", CERTS_DIR, PASSWORD)
      .build();

    HttpClient client = HttpClient.newBuilder()
      .sslContext(context)
      .build();

    // ...
}

发起第一次请求

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create("https://api.service1:10443/test"))
  .GET()
  .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

assertEquals("ok from server 1", response.body());

发起第二次请求

request = HttpRequest.newBuilder()
  .uri(URI.create("https://api.service2:20443/test"))
  .GET()
  .build();

response = client.send(request, HttpResponse.BodyHandlers.ofString());

assertEquals("ok from server 2", response.body());

结果符合预期:使用相同 SSL 上下文成功向需要不同证书的服务器发起安全请求。

6. 总结

本文演示了在 Java 中与不同 HTTPS 接口交互时使用多个客户端证书的方法。先通过 Apache HttpComponents 实现多客户端方案,再基于 Java 核心库构建自定义方案,使用自定义 KeyManagerTrustManager 实现。该方案特别适用于需要动态选择 TLS 凭据的场景,如服务网格、多租户客户端或 API 网关。

完整源代码请参考 GitHub 项目


原始标题:Using a Different Client Certificate per Connection in Java | Baeldung