用了這么久,你真的真的明白HttpClient的實現原理了嗎?
一、背景
HTTP是一個傳輸內容有可讀性的公開協議,客戶端與服務器端的數據完全通過明文傳輸。在這個背景之下,整個依賴于Http協議的互聯網數據都是透明的,這帶來了很大的數據安全隱患。想要解決這個問題有兩個思路:
- C/S端各自負責,即客戶端與服務端使用協商好的加密內容在Http上通信
- C/S端不負責加解密,加解密交給通信協議本身解決
第一種在現實中的應用范圍其實比想象中的要廣泛一些。雙方線下交換密鑰,客戶端在發送的數據采用的已經是密文了,這個密文通過透明的Http協議在互聯網上傳輸。
服務端在接收到請求后,按照約定的方式解密獲得明文。這種內容就算被劫持了也不要緊,因為第三方不知道他們的加解密方法。然而這種做法太特殊了,客戶端與服務端都需要關心這個加解密特殊邏輯。
第二種C/S端可以不關心上面的特殊邏輯,他們認為發送與接收的都是明文,因為加解密這一部分已經被協議本身處理掉了。
從結果上看這兩種方案似乎沒有什么區別,但是從軟件工程師的角度看區別非常巨大。因為第一種需要業務系統自己開發響應的加解密功能,并且線下要交互密鑰,第二種沒有開發量。
HTTPS是當前最流行的HTTP的安全形式,由NetScape公司首創。在HTTPS中,URL都是以https://開頭,而不是http://。使用了HTTPS時,所有的HTTP的請求與響應在發送到網絡上之前都進行了加密,這是通過在SSL層實現的。
二、加密方法
通過SSL層對明文數據進行加密,然后放到互聯網上傳輸,這解決了HTTP協議原本的數據安全性問題。一般來說,對數據加密的方法分為對稱加密與非對稱加密。
2.1 對稱加密
對稱加密是指加密與解密使用同樣的密鑰,常見的算法有DES與AES等,算法時間與密鑰長度相關。
對稱密鑰最大的缺點是需要維護大量的對稱密鑰,并且需要線下交換。加入一個網絡中有n個實體,則需要n(n-1)個密鑰。
2.2 非對稱加密
非對稱加密是指基于公私鑰(public/private key)的加密方法,常見算法有RSA,一般而言加密速度慢于對稱加密。
對稱加密比非對稱加密多了一個步驟,即要獲得服務端公鑰,而不是各自維護的密鑰。
整個加密算法建立在一定的數論基礎上運算,達到的效果是,加密結果不可逆。即只有通過私鑰(private key)才能解密得到經由公鑰(public key)加密的密文。
在這種算法下,整個網絡中的密鑰數量大大降低,每個人只需要維護一對公司鑰即可。即n個實體的網絡中,密鑰個數是2n。
其缺點是運行速度慢。
2.3 混合加密
周星馳電影《食神》中有一個場景,黑社會火并,爭論撒尿蝦與牛丸的底盤劃分問題。食神說:“真是麻煩,摻在一起做成撒尿牛丸那,笨蛋!”
對稱加密的優點是速度快,缺點是需要交換密鑰。非對稱加密的優點是不需要交互密鑰,缺點是速度慢。干脆摻在一起用好了。
混合加密正是HTTPS協議使用的加密方式。先通過非對稱加密交換對稱密鑰,后通過對稱密鑰進行數據傳輸。
由于數據傳輸的量遠遠大于建立連接初期交換密鑰時使用非對稱加密的數據量,所以非對稱加密帶來的性能影響基本可以忽略,同時又提高了效率。
三、HTTPS握手
可以看到,在原HTTP協議的基礎上,HTTPS加入了安全層處理:
- 客戶端與服務端交換證書并驗證身份,現實中服務端很少驗證客戶端的證書
- 協商加密協議的版本與算法,這里可能出現版本不匹配導致失敗
- 協商對稱密鑰,這個過程使用非對稱加密進行
- 將HTTP發送的明文使用3中的密鑰,2中的加密算法加密得到密文
- TCP層正常傳輸,對HTTPS無感知
四、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源碼繼續分析。
五、本文總結
- https協議是http的安全版本,做到了傳輸層數據的安全,但對服務器cpu有額外消耗
- https協議在協商密鑰的時候使用非對稱加密,密鑰協商結束后使用對稱加密
- 有些場景下,即使通過了https進行了加解密,業務系統也會對報文進行二次加密與簽名
- HttpClient在build的時候,連接池管理器注冊了兩個SslSocketFactory,用來匹配http或者https字符串
- https對應的socket建立原則是先建立,后驗證域名與證書一致性
- ssl層加解密由jdk自身完成,不需要httpClient進行額外操作