在 Java 中為每個連線使用不同的客戶端憑證
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 元件
讓我們先研究如何使用 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
X509ExtendedKeyManager
和X509ExtendedTrustManager
是 Java SSL 套件中的抽象類,我們可以擴展它們,從而完全控制 SSL 握手期間憑證金鑰和信任庫的載入和使用方式。我們將使用它們來建立一個RoutingSslContextBuilder
類,該類別可以根據主機名稱選擇正確的憑證。
4.1. 載入KeyStore
讓我們從載入金鑰和信任管理器的實用程式開始。我們將使用此方法將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 上取得。