本文基于HttpClient4.5.4,对可信证书和自签名证书的网站访问编码,涉及https连接过程、证书、证书链、根证书、keystore、自签名等概念,就不在本文中细说了。
网上能搜到httpclient使用的各种写法,基本是由于版本更迭导致的,前期为Apache Commons HttpClient,现在是Apache HttpComponents,从4.3.x版本开始类名和调用方式相较早期版本有了明显变化,下文中所有代码基于当前最新的4.5.4版本。
The Commons HttpClient project is now end of life, and is no longer being developed. It has been replaced by the Apache HttpComponents project in its HttpClient and HttpCore modules, which offer better performance and more flexibility. ——[来自官网]
<dependencies>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.5.4version>
dependency>
dependencies>
package org.fst.network;
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;
public class HttpGetTest {
public static void main(String[] args) {
CloseableHttpClient httpClient = HttpClients.createDefault();
try {
HttpGet httpGet = new HttpGet("http://www.baidu.com");
System.out.println("Executing request " + httpGet.getRequestLine());
CloseableHttpResponse response = httpClient.execute(httpGet);
System.out.println("----------------------------------------");
System.out.println(response.getStatusLine());
System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
httpClient.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
地址改为https://www.baidu.com会如何呢,仍然能得到200 OK响应:
访问地址改为https://www.12306.cn,运行效果如下,大家都知道12306证书非法吧哈哈:
很多人会不假思索的回答:“因为百度网站使用了可信CA签发的证书”。通过Chrome的开发者工具:F12->security可以看出来,百度的证书被浏览器认定为可信的。
但我们要知道,浏览https网站的场景是由客户端校验服务器是否可信的(这里指一般的场景,当然还有双向认证),单从服务器如何如何并不能解释客户端的行为,真正的答案是windows系统预制了一批可信根证书,从Internet选项->内容->证书里面可以看到,如下图,有兴趣的可以找找是否有baidu的根证书:
类比浏览器,HttpClient既然能访问百度成功,其必然也加载了可信根证书作为判断依据,那么这些根证书在哪,由谁去加载的?通过debug代码,可以发现,java程序在默认的SSLSocketFactory中加载了104个证书(不同版本jdk证书数目可能有差别):
进一步在%JAVA_HOME%/jre/lib/security的目录下找到了疑似的keystore文件。
keytool -list -keystore cacerts -storepass changeit看一下,里面确实是这104个根证书。
在维持整个校验过程不变的前提下,keystore中导入这个证书,将其认为可信即可。
第一步:将证书保存到本地:
Chrome浏览器F12-Security-View certificate打开证书信息窗口-详细信息-复制到文件将其保存为X.509格式。
证书窗口中我们可以看到证书链,保存链里面的任一个证书都可以。
第二步:将证书导入keystore(导入jdk中的caserts文件或者生成一个新的keystore文件)
一般不建议随意修改jdk中的文件,咱们生成一个新的keystore,并修改代码加载它。
keytool -import -keystore my.keystore -storepass 123456 -file 12306root.cer -alias 12306root
上文的代码也需要一些修改,给CloseableHttpClient对象设置自定义的SSLConnectionSocketFactory:
// 加载自定义的keystore
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(new File("D:/java/IdeaProjects/test/src/main/resources/certs/my.keystore"), "123456".toCharArray()).build();
SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext);
CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(sslConnectionSocketFactory).build();
HttpGet httpGet = new HttpGet("https://www.12306.cn");
System.out.println("Executing request " + httpGet.getRequestLine());
CloseableHttpResponse response = httpClient.execute(httpGet);
System.out.println("----------------------------------------");
System.out.println(response.getStatusLine());
System.out.println(EntityUtils.toString(response.getEntity()));
与问题一中的结果对比,异常变了,从未找到合法证书变为未匹配到subject alternative names(可选名称)
我们来看看源码中这段校验逻辑:服务器证书信息中有AlternativeName就用它和访问的地址比较,没有就用CN和访问的地址比较。
而12306的证书没有可选名称,只有CN,且CN值为kyfw.12306.cn,咱们访问的是12306.cn,自然匹配不到了。
这有点尴尬了,12306的证书除了是自签名以外,证书颁给的域名还不对,怎么办呢,我们可以在构造SSLConnectionSocketFactory时重写域名校验逻辑,简单起见就直接校验通过返回true了(注意这是不得已而为之的办法,违反了证书的安全机制)
SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new HostnameVerifier() {
public boolean verify(String s, SSLSession sslSession) {
// 我们可以重写域名校验逻辑,这里直接返回成功
return true;
}
});
一般代码中,我们会给HttpClient设置连接池:
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(20);
connectionManager.setDefaultMaxPerRoute(20);
httpClient = HttpClients.custom()
.setSSLSocketFactory(sslConnectionSocketFactory)
.setConnectionManager(connectionManager)
.build();
结果辛苦调通的12306又访问不了了,回到了最初的问题:unable to find valid certification path to requested target。
阅读一番源码后,直接将原因告诉大家:
如上图,HttpClient在connect()时,获取到的SSLSocketFactory,是new PoolingHttpClientConnectionManager()时默认构造的,并不是我们.setSSLSocketFactory(sslConnectionSocketFactory)设置的那一个。除非我们在new PoolingHttpClientConnectionManager就注册好传给它的构造函数。
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(RegistryBuilder.create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", sslConnectionSocketFactory)
.build());
package org.fst.network;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.ssl.SSLContexts;
import org.apache.http.util.EntityUtils;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import java.io.File;
public class HttpGetTest {
public static void main(String[] args) {
CloseableHttpClient httpClient = null;
try {
// 加载自定义的keystore
SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(new File("D:/java/IdeaProjects/test/src/main/resources/certs/my.keystore"), "123456".toCharArray()).build();
// 默认的域名校验类为DefaultHostnameVerifier,比对服务器证书的AlternativeName和CN两个属性。
// 如果服务器证书这两者不合法而我们又必须让其校验通过,则可以自己实现HostnameVerifier。
SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new HostnameVerifier() {
public boolean verify(String s, SSLSession sslSession) {
// 我们可以重写域名校验逻辑
return true;
}
});
// 一个httpClient对象对于https仅会选用一个SSLConnectionSocketFactory
// 至少在4.5.3和4.5.4中,如果给HttpClient对象设置ConnectionManager,我们必须在PoolingHttpClientConnectionManager的构造方法中传入Registry,
// 并将https对应的工厂设置为我们自己的SSLConnectionSocketFactory对象,因为在DefaultHttpClientConnectionOperator.connect()中,逻辑是从这里找SSLConnectionSocketFactory的。
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(RegistryBuilder.create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", sslConnectionSocketFactory)
.build());
connectionManager.setMaxTotal(20);
connectionManager.setDefaultMaxPerRoute(20);
httpClient = HttpClients.custom()
// 不在connectionManager中注册,仅在这里设置SSLConnectionSocketFactory是无效的,详见build()内部逻辑,在connectionManager不为null时,不会使用里的SSLConnectionSocketFactory
.setSSLSocketFactory(sslConnectionSocketFactory)
.setConnectionManager(connectionManager)
.build();
HttpGet httpGet = new HttpGet("https://www.12306.cn");
System.out.println("Executing request " + httpGet.getRequestLine());
CloseableHttpResponse response = httpClient.execute(httpGet);
System.out.println("----------------------------------------");
System.out.println(response.getStatusLine());
System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (null != httpClient)
{
httpClient.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}