天天看點

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

本文基于HttpClient4.5.4,對可信證書和自簽名證書的網站通路編碼,涉及https連接配接過程、證書、證書鍊、根證書、keystore、自簽名等概念,就不在本文中細說了。

  • HttpClient的HTTPS用戶端程式設計可信證書與自簽名證書
    • 簡介
      • 前世今生
      • 如何引入
    • 如何發送HTTPS請求
      • 問題一通路非可信證書網站會如何例如12306
      • 問題二在通路百度時SSL握手過程中百度網站的證書是如何被認為是可信證書的
      • 問題三伺服器就是要用自簽名證書如何解決
      • 問題四為什麼會有比對不到AlternativeName的錯誤
      • 問題五為什麼我設定了ConnectionManager後又不行了呢
    • 總結
      • httpsSSL證書校驗常見的幾點
      • 最終的完整代碼

簡介

前世今生

網上能搜到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.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.5.4</version>
    </dependency>
</dependencies>
           

如何發送HTTPS請求

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();
            }
        }
    }
}
           

運作結果如下

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

位址改為https://www.baidu.com會如何呢,仍然能得到200 OK響應:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

問題一:通路非可信證書網站會如何,例如12306?

通路位址改為https://www.12306.cn,運作效果如下,大家都知道12306證書非法吧哈哈:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

問題二:在通路百度時,SSL握手過程中百度網站的證書是如何被認為是可信證書的?

很多人會不假思索的回答:“因為百度網站使用了可信CA簽發的證書”。通過Chrome的開發者工具:F12->security可以看出來,百度的證書被浏覽器認定為可信的。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

而12306被認為不可信:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

但我們要知道,浏覽https網站的場景是由用戶端校驗伺服器是否可信的(這裡指一般的場景,當然還有雙向認證),單從伺服器如何如何并不能解釋用戶端的行為,真正的答案是windows系統預制了一批可信根證書,從Internet選項->内容->證書裡面可以看到,如下圖,有興趣的可以找找是否有baidu的根證書:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

類比浏覽器,HttpClient既然能通路百度成功,其必然也加載了可信根證書作為判斷依據,那麼這些根證書在哪,由誰去加載的?通過debug代碼,可以發現,java程式在預設的SSLSocketFactory中加載了104個證書(不同版本jdk證書數目可能有差别):

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

進一步在%JAVA_HOME%/jre/lib/security的目錄下找到了疑似的keystore檔案。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

keytool -list -keystore cacerts -storepass changeit看一下,裡面确實是這104個根證書。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

問題三:伺服器就是要用自簽名證書,如何解決?

在維持整個校驗過程不變的前提下,keystore中導入這個證書,将其認為可信即可。

第一步:将證書儲存到本地:

Chrome浏覽器F12-Security-View certificate打開證書資訊視窗-詳細資訊-複制到檔案将其儲存為X.509格式。

證書視窗中我們可以看到證書鍊,儲存鍊裡面的任一個證書都可以。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

第二步:将證書導入keystore(導入jdk中的caserts檔案或者生成一個新的keystore檔案)

一般不建議随意修改jdk中的檔案,咱們生成一個新的keystore,并修改代碼加載它。

keytool -import -keystore my.keystore -storepass  -file root.cer -alias root
           

上文的代碼也需要一些修改,給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(可選名稱)

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

問題四:為什麼會有比對不到AlternativeName的錯誤?

我們來看看源碼中這段校驗邏輯:伺服器證書資訊中有AlternativeName就用它和通路的位址比較,沒有就用CN和通路的位址比較。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

這倆東東分别對應證書(以百度的證書為例)中這兩段資訊:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

而12306的證書沒有可選名稱,隻有CN,且CN值為kyfw.12306.cn,咱們通路的是12306.cn,自然比對不到了。

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

這有點尴尬了,12306的證書除了是自簽名以外,證書頒給的域名還不對,怎麼辦呢,我們可以在構造SSLConnectionSocketFactory時重寫域名校驗邏輯,簡單起見就直接校驗通過傳回true了(注意這是不得已而為之的辦法,違反了證書的安全機制)

SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, new HostnameVerifier() {
                public boolean verify(String s, SSLSession sslSession) {
                    // 我們可以重寫域名校驗邏輯,這裡直接傳回成功
                    return true;
                }
            });
           
HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

問題五:為什麼我設定了ConnectionManager後又不行了呢

一般代碼中,我們會給HttpClient設定連接配接池:

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal();
connectionManager.setDefaultMaxPerRoute();

httpClient = HttpClients.custom()
            .setSSLSocketFactory(sslConnectionSocketFactory)
            .setConnectionManager(connectionManager)
            .build();
           

結果辛苦調通的12306又通路不了了,回到了最初的問題:unable to find valid certification path to requested target。

閱讀一番源碼後,直接将原因告訴大家:

HttpClient實作HTTPS用戶端程式設計---可信證書與自簽名證書HttpClient的HTTPS用戶端程式設計—可信證書與自簽名證書

如上圖,HttpClient在connect()時,擷取到的SSLSocketFactory,是new PoolingHttpClientConnectionManager()時預設構造的,并不是我們.setSSLSocketFactory(sslConnectionSocketFactory)設定的那一個。除非我們在new PoolingHttpClientConnectionManager就注冊好傳給它的構造函數。

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.getSocketFactory())
                    .register("https", sslConnectionSocketFactory)
                    .build());
           

總結

https(SSL)證書校驗常見的幾點

  • 證書是否為可信CA簽發
  • 證書中的AlternativeNames或CN是否與我們通路的位址相同
  • 證書是否過期/是否已被撤銷(見CRL)

最終的完整代碼

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
            // 至少在和中,如果給HttpClient對象設定ConnectionManager,我們必須在PoolingHttpClientConnectionManager的構造方法中傳入Registry,
            // 并将https對應的工廠設定為我們自己的SSLConnectionSocketFactory對象,因為在DefaultHttpClientConnectionOperator.connect()中,邏輯是從這裡找SSLConnectionSocketFactory的。
            PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.getSocketFactory())
                    .register("https", sslConnectionSocketFactory)
                    .build());
            connectionManager.setMaxTotal();
            connectionManager.setDefaultMaxPerRoute();

            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();
            }
        }
    }
}