来源:https://www.cnblogs.com/kingszelda/p/9029735.htmlhtml
1、背景
HTTP是一个传输内容有可读性的公开协议,客户端与服务器端的数据彻底经过明文传输。在这个背景之下,整个依赖于Http协议的互联网数据都是透明的,这带来了很大的数据安全隐患。想要解决这个问题有两个思路:java
-
C/S端各自负责,即客户端与服务端使用协商好的加密内容在Http上通讯 -
C/S端不负责加解密,加解密交给通讯协议自己解决

第一种在现实中的应用范围其实比想象中的要普遍一些。双方线下交换密钥,客户端在发送的数据采用的已是密文了,这个密文经过透明的Http协议在互联网上传输。服务端在接收到请求后,按照约定的方式解密得到明文。这种内容就算被劫持了也没关系,由于第三方不知道他们的加解密方法。然而这种作法太特殊了,客户端与服务端都须要关心这个加解密特殊逻辑。web
第二种C/S端能够不关心上面的特殊逻辑,他们认为发送与接收的都是明文,由于加解密这一部分已经被协议自己处理掉了。算法
从结果上看这两种方案彷佛没有什么区别,可是从软件工程师的角度看区别很是巨大。由于第一种须要业务系统本身开发响应的加解密功能,而且线下要交互密钥,第二种没有开发量。安全
HTTPS是当前最流行的HTTP的安全形式,由NetScape公司独创。在HTTPS中,URL都是以https://开头,而不是http://。使用了HTTPS时,全部的HTTP的请求与响应在发送到网络上以前都进行了加密,这是经过在SSL层实现的。服务器

2、加密方法
经过SSL层对明文数据进行加密,而后放到互联网上传输,这解决了HTTP协议本来的数据安全性问题。通常来讲,对数据加密的方法分为对称加密与非对称加密。微信
2.1 对称加密
对称加密是指加密与解密使用一样的密钥,常见的算法有DES与AES等,算法时间与密钥长度相关。网络

对称密钥最大的缺点是须要维护大量的对称密钥,而且须要线下交换。加入一个网络中有n个实体,则须要n(n-1)个密钥。app
2.2 非对称加密
非对称加密是指基于公私钥(public/private key)的加密方法,常见算法有RSA,通常而言加密速度慢于对称加密。curl

对称加密比非对称加密多了一个步骤,即要得到服务端公钥,而不是各自维护的密钥。
整个加密算法创建在必定的数论基础上运算,达到的效果是,加密结果不可逆。即只有经过私钥(private key)才能解密获得经由公钥(public key)加密的密文。
在这种算法下,整个网络中的密钥数量大大下降,每一个人只须要维护一对公司钥便可。即n个实体的网络中,密钥个数是2n。
其缺点是运行速度慢。
2.3 混合加密
周星驰电影《食神》中有一个场景,黑社会火并,争论撒尿虾与牛丸的底盘划分问题。食神说:“真是麻烦,掺在一块儿作成撒尿牛丸那,笨蛋!”
对称加密的优势是速度快,缺点是须要交换密钥。非对称加密的优势是不须要交互密钥,缺点是速度慢。干脆掺在一块儿用好了。
混合加密正是HTTPS协议使用的加密方式。先经过非对称加密交换对称密钥,后经过对称密钥进行数据传输。
因为数据传输的量远远大于创建链接初期交换密钥时使用非对称加密的数据量,因此非对称加密带来的性能影响基本能够忽略,同时又提升了效率。
3、HTTPS握手

能够看到,在原HTTP协议的基础上,HTTPS加入了安全层处理:
-
客户端与服务端交换证书并验证身份,现实中服务端不多验证客户端的证书 -
协商加密协议的版本与算法,这里可能出现版本不匹配致使失败 -
协商对称密钥,这个过程使用非对称加密进行 -
将HTTP发送的明文使用3中的密钥,2中的加密算法加密获得密文 -
TCP层正常传输,对HTTPS无感知
4、HttpClient对HTTPS协议的支持
4.1 得到SSL链接工厂以及域名校验器
做为一名软件工程师,咱们关心的是“HTTPS协议”在代码上是怎么实现的呢?探索HttpClient源码的奥秘,一切都要从HttpClientBuilder开始。
public CloseableHttpClient build() {
//省略部分代码
HttpClientConnectionManager connManagerCopy = this.connManager;
//若是指定了链接池管理器则使用指定的,不然新建一个默认的
if (connManagerCopy == null) {
LayeredConnectionSocketFactory sslSocketFactoryCopy = this.sslSocketFactory;
if (sslSocketFactoryCopy == null) {
//若是开启了使用环境变量,https版本与密码控件从环境变量中读取
final String[] supportedProtocols = systemProperties ? split(
System.getProperty("https.protocols")) : null;
final String[] supportedCipherSuites = systemProperties ? split(
System.getProperty("https.cipherSuites")) : null;
//若是没有指定,使用默认的域名验证器,会根据ssl会话中服务端返回的证书来验证与域名是否匹配
HostnameVerifier hostnameVerifierCopy = this.hostnameVerifier;
if (hostnameVerifierCopy == null) {
hostnameVerifierCopy = new DefaultHostnameVerifier(publicSuffixMatcherCopy);
}
//若是制定了SslContext则生成定制的SSL链接工厂,不然使用默认的链接工厂
if (sslContext != null) {
sslSocketFactoryCopy = new SSLConnectionSocketFactory(
sslContext, supportedProtocols, supportedCipherSuites, hostnameVerifierCopy);
} else {
if (systemProperties) {
sslSocketFactoryCopy = new SSLConnectionSocketFactory(
(SSLSocketFactory) SSLSocketFactory.getDefault(),
supportedProtocols, supportedCipherSuites, hostnameVerifierCopy);
} else {
sslSocketFactoryCopy = new SSLConnectionSocketFactory(
SSLContexts.createDefault(),
hostnameVerifierCopy);
}
}
}
//将Ssl链接工厂注册到链接池管理器中,当须要产生Https链接的时候,会根据上面的SSL链接工厂生产SSL链接
@SuppressWarnings("resource")
final PoolingHttpClientConnectionManager poolingmgr = new PoolingHttpClientConnectionManager(
RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", sslSocketFactoryCopy)
.build(),
null,
null,
dnsResolver,
connTimeToLive,
connTimeToLiveTimeUnit != null ? connTimeToLiveTimeUnit : TimeUnit.MILLISECONDS);
//省略部分代码
}
}
上面的代码将一个Ssl链接工厂SSLConnectionSocketFactory建立,并注册到了链接池管理器中,供以后生产Ssl链接使用。链接池的问题参考:http://www.cnblogs.com/kingszelda/p/8988505.html
这里在配置SSLConnectionSocketFactory时用到了几个关键的组件,域名验证器HostnameVerifier以及上下文SSLContext。
其中HostnameVerifier用来验证服务端证书与域名是否匹配,有多种实现,DefaultHostnameVerifier采用的是默认的校验规则,替代了以前版本中的BrowserCompatHostnameVerifier与StrictHostnameVerifier。NoopHostnameVerifier替代了AllowAllHostnameVerifier,采用的是不验证域名的策略。
注意,这里有一些区别,BrowserCompatHostnameVerifier能够匹配多级子域名,"*.foo.com"能够匹配"a.b.foo.com"。StrictHostnameVerifier不能匹配多级子域名,只能到"a.foo.com"。
而4.4以后的HttpClient使用了新的DefaultHostnameVerifier替换了上面的两种策略,只保留了一种严格策略及StrictHostnameVerifier。由于严格策略是IE6与JDK自己的策略,非严格策略是curl与firefox的策略。即默认的HttpClient实现是不支持多级子域名匹配策略的。
SSLContext存放的是和密钥有关的关键信息,这部分与业务直接相关,很是重要,这个放在后面单独分析。
4.2 如何得到SSL链接
如何从链接池中得到一个链接,这个过程以前的文章中有分析过,这里不作分析,参考链接:http://www.cnblogs.com/kingszelda/p/8988505.html。
在从链接池中得到一个链接后,若是这个链接不处于establish状态,就须要先创建链接。
DefaultHttpClientConnectionOperator部分的代码为:
public void connect(
final ManagedHttpClientConnection conn,
final HttpHost host,
final InetSocketAddress localAddress,
final int connectTimeout,
final SocketConfig socketConfig,
final HttpContext context) throws IOException {
//以前在HttpClientBuilder中register了http与https不一样的链接池实现,这里lookup得到Https的实现,即SSLConnectionSocketFactory
final Lookup<ConnectionSocketFactory> registry = getSocketFactoryRegistry(context);
final ConnectionSocketFactory sf = registry.lookup(host.getSchemeName());
if (sf == null) {
throw new UnsupportedSchemeException(host.getSchemeName() +
" protocol is not supported");
}
//若是是ip形式的地址能够直接使用,不然使用dns解析器解析获得域名对应的ip
final InetAddress[] addresses = host.getAddress() != null ?
new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
final int port = this.schemePortResolver.resolve(host);
//一个域名可能对应多个Ip,按照顺序尝试链接
for (int i = 0; i < addresses.length; i++) {
final InetAddress address = addresses[i];
final boolean last = i == addresses.length - 1;
//这里只是生成一个socket,还并无链接
Socket sock = sf.createSocket(context);
//设置一些tcp层的参数
sock.setSoTimeout(socketConfig.getSoTimeout());
sock.setReuseAddress(socketConfig.isSoReuseAddress());
sock.setTcpNoDelay(socketConfig.isTcpNoDelay());
sock.setKeepAlive(socketConfig.isSoKeepAlive());
if (socketConfig.getRcvBufSize() > 0) {
sock.setReceiveBufferSize(socketConfig.getRcvBufSize());
}
if (socketConfig.getSndBufSize() > 0) {
sock.setSendBufferSize(socketConfig.getSndBufSize());
}
final int linger = socketConfig.getSoLinger();
if (linger >= 0) {
sock.setSoLinger(true, linger);
}
conn.bind(sock);
final InetSocketAddress remoteAddress = new InetSocketAddress(address, port);
if (this.log.isDebugEnabled()) {
this.log.debug("Connecting to " + remoteAddress);
}
try {
//经过SSLConnectionSocketFactory创建链接并绑定到conn上
sock = sf.connectSocket(
connectTimeout, sock, host, remoteAddress, localAddress, context);
conn.bind(sock);
if (this.log.isDebugEnabled()) {
this.log.debug("Connection established " + conn);
}
return;
}
//省略一些代码
}
}
在上面的代码中,咱们看到了是创建SSL链接以前的准备工做,这是通用流程,普通HTTP链接也同样。SSL链接的特殊流程体如今哪里呢?
SSLConnectionSocketFactory部分源码以下:
@Override
public Socket connectSocket(
final int connectTimeout,
final Socket socket,
final HttpHost host,
final InetSocketAddress remoteAddress,
final InetSocketAddress localAddress,
final HttpContext context) throws IOException {
Args.notNull(host, "HTTP host");
Args.notNull(remoteAddress, "Remote address");
final Socket sock = socket != null ? socket : createSocket(context);
if (localAddress != null) {
sock.bind(localAddress);
}
try {
if (connectTimeout > 0 && sock.getSoTimeout() == 0) {
sock.setSoTimeout(connectTimeout);
}
if (this.log.isDebugEnabled()) {
this.log.debug("Connecting socket to " + remoteAddress + " with timeout " + connectTimeout);
}
//创建链接
sock.connect(remoteAddress, connectTimeout);
} catch (final IOException ex) {
try {
sock.close();
} catch (final IOException ignore) {
}
throw ex;
}
// 若是当前是SslSocket则进行SSL握手与域名校验
if (sock instanceof SSLSocket) {
final SSLSocket sslsock = (SSLSocket) sock;
this.log.debug("Starting handshake");
sslsock.startHandshake();
verifyHostname(sslsock, host.getHostName());
return sock;
} else {
//若是不是SslSocket则将其包装为SslSocket
return createLayeredSocket(sock, host.getHostName(), remoteAddress.getPort(), context);
}
}
@Override
public Socket createLayeredSocket(
final Socket socket,
final String target,
final int port,
final HttpContext context) throws IOException {
//将普通socket包装为SslSocket,socketfactory是根据HttpClientBuilder中的SSLContext生成的,其中包含密钥信息
final SSLSocket sslsock = (SSLSocket) this.socketfactory.createSocket(
socket,
target,
port,
true);
//若是制定了SSL层协议版本与加密算法,则使用指定的,不然使用默认的
if (supportedProtocols != null) {
sslsock.setEnabledProtocols(supportedProtocols);
} else {
// If supported protocols are not explicitly set, remove all SSL protocol versions
final String[] allProtocols = sslsock.getEnabledProtocols();
final List<String> enabledProtocols = new ArrayList<String>(allProtocols.length);
for (final String protocol: allProtocols) {
if (!protocol.startsWith("SSL")) {
enabledProtocols.add(protocol);
}
}
if (!enabledProtocols.isEmpty()) {
sslsock.setEnabledProtocols(enabledProtocols.toArray(new String[enabledProtocols.size()]));
}
}
if (supportedCipherSuites != null) {
sslsock.setEnabledCipherSuites(supportedCipherSuites);
}
if (this.log.isDebugEnabled()) {
this.log.debug("Enabled protocols: " + Arrays.asList(sslsock.getEnabledProtocols()));
this.log.debug("Enabled cipher suites:" + Arrays.asList(sslsock.getEnabledCipherSuites()));
}
prepareSocket(sslsock);
this.log.debug("Starting handshake");
//Ssl链接握手
sslsock.startHandshake();
//握手成功后校验返回的证书与域名是否一致
verifyHostname(sslsock, target);
return sslsock;
}
能够看到,对于一个SSL通讯而言。首先是创建普通socket链接,而后进行ssl握手,以后验证证书与域名一致性。以后的操做就是经过SSLSocketImpl进行通讯,协议细节在SSLSocketImpl类中体现,但这部分代码jdk并无开源,感兴趣的能够下载相应的openJdk源码继续分析。
5、本文总结
-
https协议是http的安全版本,作到了传输层数据的安全,但对服务器cpu有额外消耗 -
https协议在协商密钥的时候使用非对称加密,密钥协商结束后使用对称加密 -
有些场景下,即便经过了https进行了加解密,业务系统也会对报文进行二次加密与签名 -
HttpClient在build的时候,链接池管理器注册了两个SslSocketFactory,用来匹配http或者https字符串 -
https对应的socket创建原则是先创建,后验证域名与证书一致性 -
ssl层加解密由jdk自身完成,不须要httpClient进行额外操做
本文分享自微信公众号 - 肥朝(feichao_java)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。