1. 概述
本文将演示如何配置 Java 的 SSLContext
,使其能根据目标服务器动态选择不同的客户端证书。我们先使用 Apache HttpComponents 实现一个简单方案,然后通过自定义路由 KeyManager
和 TrustManager
提供更灵活的解决方案。
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.p12
和server.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. 自定义 KeyManager
和 TrustManager
通过扩展 Java SSL 包中的抽象类 X509ExtendedKeyManager
和 X509ExtendedTrustManager
,可完全控制 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 加载 KeyManager
和 TrustManager
组合工具方法加载 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 核心库构建自定义方案,使用自定义 KeyManager
和 TrustManager
实现。该方案特别适用于需要动态选择 TLS 凭据的场景,如服务网格、多租户客户端或 API 网关。
完整源代码请参考 GitHub 项目。