如何使用 Java 的 HttpClient 请求 Https 服务

近期收到一些小伙伴问 Java 中如何调用 https 中双向认证的接口的问题,本文采用 okhttp 以及 Java11 中的 httpclient 来介绍如何使用 Java 代码调用 https 双向认证的接口。

前置工作

  1. 生成服务端、客户端证书,并将各自的公钥加入到对方的信任链中以供后续的使用;
  2. 搭建一个 https 协议的服务以供客户端调用。

制作证书

这里我们采用 jdk 提供的 keytool 命令来生成我们所需要的服务端、客户端证书,并且可以使用 keytool 命令来将各自的公钥加入到对方的信任连中。

生成服务端、客户端密钥库以及证书

1
2
3
4
# 生成服务端keystore,这里加拓展项的目的是为了说明域名验证的问题
keytool -genkey -validity 3650 -alias server -keyalg RSA -keystore server.keystore -ext san=dns:www.goku.edu,ip:127.0.0.1
# 生成客户端keystore,这里针对客户端的密钥库我们采用 PKCS12 的格式
keytool -genkey -validity 3650 -alias client -keyalg RSA -storetype PKCS12 -keystore client.p12

导出服务端、客户端证书中的公钥

1
2
3
4
# 导出服务端证书的公钥
keytool -export -keystore server.keystore -alias server -file server.cer -rfc
# 导出客户端证书的公钥
keytool -export -keystore client.p12 -storetype PKCS12 -alias client -file client.cer

将各自的公钥加入到对方的密钥库中

1
2
3
4
# 将服务端证书导入到客户端的信任库中
keytool -import -alias server -file server.cer -keystore client.p12
# 将客户端证书导入到服务端的信任库中
keytool -import -alias client -file client.cer -keystore server.keystore

查看密钥库

可以通过如下命令,来查看公钥是否加入到各自的密钥库中

1
2
keytool -list -v -keystore server.keystore
keytool -list -rfc -keystore client.p12

搭建 HTTPS 服务

由于搭建服务不是本文的重点,建议使用 SpringBoot 脚手架快速搭建一个服务,下面只列关键点。

编写一个简单的 HTTP 接口

代码如下:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/index")
public class IndexRestController {

@GetMapping("/hello")
public String hello() {
return "Hello World!";
}
}

完成之后,启动服务,使用浏览器打开对应的接口地址,如果正确输出Hello World!则说明接口正常。

开启 SpringBoot 的 SSL 双向认证

application.properties 中新增如下配置,即可开启 SSL 的双向认证

1
2
3
4
5
6
7
8
9
server.ssl.enabled=true
server.ssl.protocol=TLS
server.ssl.key-store=classpath:certs/server.keystore
server.ssl.key-store-password=111111
server.ssl.key-password=111111
server.ssl.key-alias=server
server.ssl.trust-store=classpath:certs/server.keystore
server.ssl.trust-store-password=111111
server.ssl.client-auth=NEED

配完之后,启动服务,再次使用浏览器打开上一步的接口地址,发现接口并不能正常使用,并且浏览器会提示”此站点的连接不安全”,如下图所示

使用 OkHttp

构建 OkHttpClient

构建 SSL 的双向认证,有两个要点

  1. 创建 SSLSocket
  2. 创建 HostnameVerifier

创建 SSLSocketFactory

在进行 HTTPS 的请求时,最重要的就是创建 SSLSocket ,在 Java 中可以通过 SSLSocketFactory 来创建 SSLSocket,故在构建 OkHttpClient 时成员变量 SSLSocketFactory 的创建就显得非常关键。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static SSLSocketFactory sslSocketFactory() {
try (InputStream inputStream = HttpsUtils.class.getResourceAsStream("/certs/client.p12")) {
// 加载 keyStore
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(inputStream, "111111".toCharArray());

// 创建密钥管理器
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, "111111".toCharArray());

// 创建信任链管理器
String defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(defaultAlgorithm);
tmf.init(keyStore);

// 初始化 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());

return sslContext.getSocketFactory();
} catch (NoSuchAlgorithmException | KeyManagementException | KeyStoreException | UnrecoverableKeyException | IOException | CertificateException e) {
throw new RuntimeException(e);
}
}

对于第二个参数所需的 X509TrustManager,我们采用 OkHttp 提供的默认实现okhttp3.internal.Util#platformTrustManager即可。

创建 HostnameVerifier

HostnameVerifier 是为了帮助我们验证我们请求的域名以及对应的 IP 是否一致。这里可以回顾一下创建证书时我们添加的拓展项-ext san=dns:www.goku.edu,ip:127.0.0.1。由于我们在本机测试,可以信任对应的域名,也可以通过修改 hosts 文件的方式来指定域名,也可以自定义 HostnameVerifier 的实现。这里以不对域名做校验为例子,代码如下:

1
2
3
private static HostnameVerifier hostnameVerifier() {
return (hostname, session) -> true;
}

在完成以上所有步骤之后,就可以构建我们的 OkHttpClient,并发送我们的 https 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws IOException {
// 创建 OkHttpClient
OkHttpClient httpClient = new OkHttpClient.Builder()
.hostnameVerifier(hostnameVerifier())
.sslSocketFactory(sslSocketFactory(), Util.platformTrustManager())
.build();
// 构造请求
Request request = new Request.Builder()
.url("https://localhost:8443/index/hello")
.build();
// 发送请求
Response response = httpClient.newCall(request).execute();
System.out.println(response.body().string());
}

执行代码,发现控制台正确打印Hello World!,说明我们对于 SSL 双向认证的 Https 请求已经发送成功,并成功的接收到响应了。

使用 Java11 的 HttpClient

熟悉 Java11 HttpClient 的开发者都知道 Java11 中的 HttpClient 跟 OkHttpClient 中的用法几乎一致。回归到 SSL 的双向认证,有以下两个不同点

  1. Java11 的 HttpClient 所需传的参数为 SSLContext,这个跟我们在使用 OkHttp 时创建 SSLSocketFactory 时初始化的 SSLContext 完全一致;
  2. Java11 的 HttpClient 并不支持自定义的 hostnameVerifier,只能通过配置项来关闭或打开域名验证。

其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public static void main(String[] args) throws IOException, InterruptedException, KeyStoreException,
CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException, KeyManagementException {
// 如需禁用 HostnameVerification
// System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true");

InputStream inputStream = HttpsUtils.class.getResourceAsStream("/certs/client.p12");
//创建KeyStore,用来存储信任证书
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(inputStream, "111111".toCharArray());

// 创建密钥管理器
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, "111111".toCharArray());

// 创建信任链管理器
String defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
TrustManagerFactory tmf = TrustManagerFactory.getInstance(defaultAlgorithm);
tmf.init(keyStore);

// 初始化 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());

// 创建 HttpClient
HttpClient httpClient = HttpClient.newBuilder()
.sslContext(sslContext)
.build();

// 创建一个自定义的HTTP请求对象
HttpRequest request = HttpRequest.newBuilder()
.GET()
.uri(URI.create("https://www.goku.edu:8443/index/hello"))
.header("Accept-Language", "zh-CN")
.timeout(Duration.ofMillis(5000))
.build();
// 客户端传递请求信息,且返回字符串形式的应答报文
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
}