關(guān)于Https協(xié)議和HttpClient的實(shí)現(xiàn)詳解
一、背景
HTTP是一個(gè)傳輸內(nèi)容有可讀性的公開(kāi)協(xié)議,客戶(hù)端與服務(wù)器端的數(shù)據(jù)完全通過(guò)明文傳輸。在這個(gè)背景之下,整個(gè)依賴(lài)于Http協(xié)議的互聯(lián)網(wǎng)數(shù)據(jù)都是透明的,這帶來(lái)了很大的數(shù)據(jù)安全隱患。想要解決這個(gè)問(wèn)題有兩個(gè)思路:
- C/S端各自負(fù)責(zé),即客戶(hù)端與服務(wù)端使用協(xié)商好的加密內(nèi)容在Http上通信
- C/S端不負(fù)責(zé)加解密,加解密交給通信協(xié)議本身解決

第一種在現(xiàn)實(shí)中的應(yīng)用范圍其實(shí)比想象中的要廣泛一些。雙方線下交換密鑰,客戶(hù)端在發(fā)送的數(shù)據(jù)采用的已經(jīng)是密文了,這個(gè)密文通過(guò)透明的Http協(xié)議在互聯(lián)網(wǎng)上傳輸。服務(wù)端在接收到請(qǐng)求后,按照約定的方式解密獲得明文。這種內(nèi)容就算被劫持了也不要緊,因?yàn)榈谌讲恢浪麄兊募咏饷芊椒?。然而這種做法太特殊了,客戶(hù)端與服務(wù)端都需要關(guān)心這個(gè)加解密特殊邏輯。
第二種C/S端可以不關(guān)心上面的特殊邏輯,他們認(rèn)為發(fā)送與接收的都是明文,因?yàn)榧咏饷苓@一部分已經(jīng)被協(xié)議本身處理掉了。
從結(jié)果上看這兩種方案似乎沒(méi)有什么區(qū)別,但是從軟件工程師的角度看區(qū)別非常巨大。因?yàn)榈谝环N需要業(yè)務(wù)系統(tǒng)自己開(kāi)發(fā)響應(yīng)的加解密功能,并且線下要交互密鑰,第二種沒(méi)有開(kāi)發(fā)量。
HTTPS是當(dāng)前最流行的HTTP的安全形式,由NetScape公司首創(chuàng)。在HTTPS中,URL都是以https://開(kāi)頭,而不是http://。使用了HTTPS時(shí),所有的HTTP的請(qǐng)求與響應(yīng)在發(fā)送到網(wǎng)絡(luò)上之前都進(jìn)行了加密,這是通過(guò)在SSL層實(shí)現(xiàn)的。
二、加密方法
通過(guò)SSL層對(duì)明文數(shù)據(jù)進(jìn)行加密,然后放到互聯(lián)網(wǎng)上傳輸,這解決了HTTP協(xié)議原本的數(shù)據(jù)安全性問(wèn)題。一般來(lái)說(shuō),對(duì)數(shù)據(jù)加密的方法分為對(duì)稱(chēng)加密與非對(duì)稱(chēng)加密。
2.1 對(duì)稱(chēng)加密
對(duì)稱(chēng)加密是指加密與解密使用同樣的密鑰,常見(jiàn)的算法有DES與AES等,算法時(shí)間與密鑰長(zhǎng)度相關(guān)。

對(duì)稱(chēng)密鑰最大的缺點(diǎn)是需要維護(hù)大量的對(duì)稱(chēng)密鑰,并且需要線下交換。加入一個(gè)網(wǎng)絡(luò)中有n個(gè)實(shí)體,則需要n(n-1)個(gè)密鑰。
2.2 非對(duì)稱(chēng)加密
非對(duì)稱(chēng)加密是指基于公私鑰(public/private key)的加密方法,常見(jiàn)算法有RSA,一般而言加密速度慢于對(duì)稱(chēng)加密。

對(duì)稱(chēng)加密比非對(duì)稱(chēng)加密多了一個(gè)步驟,即要獲得服務(wù)端公鑰,而不是各自維護(hù)的密鑰。
整個(gè)加密算法建立在一定的數(shù)論基礎(chǔ)上運(yùn)算,達(dá)到的效果是,加密結(jié)果不可逆。即只有通過(guò)私鑰(private key)才能解密得到經(jīng)由公鑰(public key)加密的密文。
在這種算法下,整個(gè)網(wǎng)絡(luò)中的密鑰數(shù)量大大降低,每個(gè)人只需要維護(hù)一對(duì)公司鑰即可。即n個(gè)實(shí)體的網(wǎng)絡(luò)中,密鑰個(gè)數(shù)是2n。
其缺點(diǎn)是運(yùn)行速度慢。
2.3 混合加密
周星馳電影《食神》中有一個(gè)場(chǎng)景,黑社會(huì)火并,爭(zhēng)論撒尿蝦與牛丸的底盤(pán)劃分問(wèn)題。食神說(shuō):“真是麻煩,摻在一起做成撒尿牛丸那,笨蛋!”
對(duì)稱(chēng)加密的優(yōu)點(diǎn)是速度快,缺點(diǎn)是需要交換密鑰。非對(duì)稱(chēng)加密的優(yōu)點(diǎn)是不需要交互密鑰,缺點(diǎn)是速度慢。干脆摻在一起用好了。
混合加密正是HTTPS協(xié)議使用的加密方式。先通過(guò)非對(duì)稱(chēng)加密交換對(duì)稱(chēng)密鑰,后通過(guò)對(duì)稱(chēng)密鑰進(jìn)行數(shù)據(jù)傳輸。
由于數(shù)據(jù)傳輸?shù)牧窟h(yuǎn)遠(yuǎn)大于建立連接初期交換密鑰時(shí)使用非對(duì)稱(chēng)加密的數(shù)據(jù)量,所以非對(duì)稱(chēng)加密帶來(lái)的性能影響基本可以忽略,同時(shí)又提高了效率。
三、HTTPS握手

可以看到,在原HTTP協(xié)議的基礎(chǔ)上,HTTPS加入了安全層處理:
- 客戶(hù)端與服務(wù)端交換證書(shū)并驗(yàn)證身份,現(xiàn)實(shí)中服務(wù)端很少驗(yàn)證客戶(hù)端的證書(shū)
- 協(xié)商加密協(xié)議的版本與算法,這里可能出現(xiàn)版本不匹配導(dǎo)致失敗
- 協(xié)商對(duì)稱(chēng)密鑰,這個(gè)過(guò)程使用非對(duì)稱(chēng)加密進(jìn)行
- 將HTTP發(fā)送的明文使用3中的密鑰,2中的加密算法加密得到密文
- TCP層正常傳輸,對(duì)HTTPS無(wú)感知
四、HttpClient對(duì)HTTPS協(xié)議的支持
4.1 獲得SSL連接工廠以及域名校驗(yàn)器
作為一名軟件工程師,我們關(guān)心的是“HTTPS協(xié)議”在代碼上是怎么實(shí)現(xiàn)的呢?探索HttpClient源碼的奧秘,一切都要從HttpClientBuilder開(kāi)始。
public CloseableHttpClient build() {
//省略部分代碼
HttpClientConnectionManager connManagerCopy = this.connManager;
//如果指定了連接池管理器則使用指定的,否則新建一個(gè)默認(rèn)的
if (connManagerCopy == null) {
LayeredConnectionSocketFactory sslSocketFactoryCopy = this.sslSocketFactory;
if (sslSocketFactoryCopy == null) {
//如果開(kāi)啟了使用環(huán)境變量,https版本與密碼控件從環(huán)境變量中讀取
final String[] supportedProtocols = systemProperties ? split(
System.getProperty("https.protocols")) : null;
final String[] supportedCipherSuites = systemProperties ? split(
System.getProperty("https.cipherSuites")) : null;
//如果沒(méi)有指定,使用默認(rèn)的域名驗(yàn)證器,會(huì)根據(jù)ssl會(huì)話中服務(wù)端返回的證書(shū)來(lái)驗(yàn)證與域名是否匹配
HostnameVerifier hostnameVerifierCopy = this.hostnameVerifier;
if (hostnameVerifierCopy == null) {
hostnameVerifierCopy = new DefaultHostnameVerifier(publicSuffixMatcherCopy);
}
//如果制定了SslContext則生成定制的SSL連接工廠,否則使用默認(rèn)的連接工廠
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連接工廠注冊(cè)到連接池管理器中,當(dāng)需要產(chǎn)生Https連接的時(shí)候,會(huì)根據(jù)上面的SSL連接工廠生產(chǎn)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);
//省略部分代碼
}
}
上面的代碼將一個(gè)Ssl連接工廠SSLConnectionSocketFactory創(chuàng)建,并注冊(cè)到了連接池管理器中,供之后生產(chǎn)Ssl連接使用。連接池的問(wèn)題參考:http://www.dbjr.com.cn/article/141015.htm
這里在配置SSLConnectionSocketFactory時(shí)用到了幾個(gè)關(guān)鍵的組件,域名驗(yàn)證器HostnameVerifier以及上下文SSLContext。
其中HostnameVerifier用來(lái)驗(yàn)證服務(wù)端證書(shū)與域名是否匹配,有多種實(shí)現(xiàn),DefaultHostnameVerifier采用的是默認(rèn)的校驗(yàn)規(guī)則,替代了之前版本中的BrowserCompatHostnameVerifier與StrictHostnameVerifier。NoopHostnameVerifier替代了AllowAllHostnameVerifier,采用的是不驗(yàn)證域名的策略。
注意,這里有一些區(qū)別,BrowserCompatHostnameVerifier可以匹配多級(jí)子域名,"*.foo.com"可以匹配"a.b.foo.com"。StrictHostnameVerifier不能匹配多級(jí)子域名,只能到"a.foo.com"。
而4.4之后的HttpClient使用了新的DefaultHostnameVerifier替換了上面的兩種策略,只保留了一種嚴(yán)格策略及StrictHostnameVerifier。因?yàn)閲?yán)格策略是IE6與JDK本身的策略,非嚴(yán)格策略是curl與firefox的策略。即默認(rèn)的HttpClient實(shí)現(xiàn)是不支持多級(jí)子域名匹配策略的。
SSLContext存放的是和密鑰有關(guān)的關(guān)鍵信息,這部分與業(yè)務(wù)直接相關(guān),非常重要,這個(gè)放在后面單獨(dú)分析。
4.2 如何獲得SSL連接
如何從連接池中獲得一個(gè)連接,這個(gè)過(guò)程之前的文章中有分析過(guò),這里不做分析,參考連接:http://www.dbjr.com.cn/article/141015.htm。
在從連接池中獲得一個(gè)連接后,如果這個(gè)連接不處于establish狀態(tài),就需要先建立連接。
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不同的連接池實(shí)現(xiàn),這里lookup獲得Https的實(shí)現(xiàn),即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解析器解析得到域名對(duì)應(yīng)的ip
final InetAddress[] addresses = host.getAddress() != null ?
new InetAddress[] { host.getAddress() } : this.dnsResolver.resolve(host.getHostName());
final int port = this.schemePortResolver.resolve(host);
//一個(gè)域名可能對(duì)應(yīng)多個(gè)Ip,按照順序嘗試連接
for (int i = 0; i < addresses.length; i++) {
final InetAddress address = addresses[i];
final boolean last = i == addresses.length - 1;
//這里只是生成一個(gè)socket,還并沒(méi)有連接
Socket sock = sf.createSocket(context);
//設(shè)置一些tcp層的參數(shù)
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 {
//通過(guò)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連接之前的準(zhǔn)備工作,這是通用流程,普通HTTP連接也一樣。SSL連接的特殊流程體現(xiàn)在哪里呢?
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;
}
// 如果當(dāng)前是SslSocket則進(jìn)行SSL握手與域名校驗(yàn)
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是根據(jù)HttpClientBuilder中的SSLContext生成的,其中包含密鑰信息
final SSLSocket sslsock = (SSLSocket) this.socketfactory.createSocket(
socket,
target,
port,
true);
//如果制定了SSL層協(xié)議版本與加密算法,則使用指定的,否則使用默認(rèn)的
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();
//握手成功后校驗(yàn)返回的證書(shū)與域名是否一致
verifyHostname(sslsock, target);
return sslsock;
}
可以看到,對(duì)于一個(gè)SSL通信而言。首先是建立普通socket連接,然后進(jìn)行ssl握手,之后驗(yàn)證證書(shū)與域名一致性。之后的操作就是通過(guò)SSLSocketImpl進(jìn)行通信,協(xié)議細(xì)節(jié)在SSLSocketImpl類(lèi)中體現(xiàn),但這部分代碼jdk并沒(méi)有開(kāi)源,感興趣的可以下載相應(yīng)的openJdk源碼繼續(xù)分析。
五、本文總結(jié)
- https協(xié)議是http的安全版本,做到了傳輸層數(shù)據(jù)的安全,但對(duì)服務(wù)器cpu有額外消耗
- https協(xié)議在協(xié)商密鑰的時(shí)候使用非對(duì)稱(chēng)加密,密鑰協(xié)商結(jié)束后使用對(duì)稱(chēng)加密
- 有些場(chǎng)景下,即使通過(guò)了https進(jìn)行了加解密,業(yè)務(wù)系統(tǒng)也會(huì)對(duì)報(bào)文進(jìn)行二次加密與簽名
- HttpClient在build的時(shí)候,連接池管理器注冊(cè)了兩個(gè)SslSocketFactory,用來(lái)匹配http或者h(yuǎn)ttps字符串
- https對(duì)應(yīng)的socket建立原則是先建立,后驗(yàn)證域名與證書(shū)一致性
- ssl層加解密由jdk自身完成,不需要httpClient進(jìn)行額外操作
好了,以上就是這篇文章的全部?jī)?nèi)容了,希望本文的內(nèi)容對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,如果有疑問(wèn)大家可以留言交流,謝謝大家對(duì)腳本之家的支持。
- 使用Feign配置請(qǐng)求頭以及支持Https協(xié)議
- Nexus使用nginx代理實(shí)現(xiàn)支持HTTPS協(xié)議
- Spring Boot項(xiàng)目如何同時(shí)支持HTTP和HTTPS協(xié)議的實(shí)現(xiàn)
- Spring Boot應(yīng)用程序同時(shí)支持HTTP和HTTPS協(xié)議的實(shí)現(xiàn)方法
- SpringBoot2.0如何啟用https協(xié)議
- startssl申請(qǐng)SSL證書(shū) 并且配置 iis 啟用https協(xié)議
- Java獲取http和https協(xié)議返回的json數(shù)據(jù)
- Linux下nginx配置https協(xié)議訪問(wèn)的方法
- iOS9蘋(píng)果將原h(huán)ttp協(xié)議改成了https協(xié)議的方法
- apache中使用mod_gnutls模塊實(shí)現(xiàn)多個(gè)SSL站點(diǎn)配置(多個(gè)HTTPS協(xié)議的虛擬主機(jī))
- https協(xié)議詳解
相關(guān)文章
Java設(shè)計(jì)模式之策略模式_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
策略模式是對(duì)算法的封裝,把一系列的算法分別封裝到對(duì)應(yīng)的類(lèi)中,并且這些類(lèi)實(shí)現(xiàn)相同的接口,相互之間可以替換。接下來(lái)通過(guò)本文給大家分享Java設(shè)計(jì)模式之策略模式,感興趣的朋友一起看看吧2017-08-08
Java連接合并2個(gè)數(shù)組(Array)的5種方法例子
最近在寫(xiě)代碼時(shí)遇到了需要合并兩個(gè)數(shù)組的需求,突然發(fā)現(xiàn)以前沒(méi)用過(guò),于是研究了一下合并數(shù)組的方式,這篇文章主要給大家介紹了關(guān)于Java連接合并2個(gè)數(shù)組(Array)的5種方法,需要的朋友可以參考下2023-12-12
web中拖拽排序和java后臺(tái)交互實(shí)現(xiàn)方法示例
這篇文章主要給大家介紹了關(guān)于web中拖拽排序和java后臺(tái)交互實(shí)現(xiàn)的相關(guān)資料,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2018-12-12
SpringBoot簡(jiǎn)單的SpringBoot后端實(shí)例
這篇文章主要介紹了SpringBoot簡(jiǎn)單的SpringBoot后端實(shí)例,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-03-03
java導(dǎo)出數(shù)據(jù)庫(kù)中Excel表格數(shù)據(jù)的方法
這篇文章主要為大家詳細(xì)介紹了java導(dǎo)出數(shù)據(jù)庫(kù)中Excel表格數(shù)據(jù)的方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08

