1. 程式人生 > >HttpClient實現HTTPS客戶端程式設計---可信證書與自簽名證書

HttpClient實現HTTPS客戶端程式設計---可信證書與自簽名證書

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

本文基於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.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(); } } } }

執行結果如下
這裡寫圖片描述

地址改為https://www.baidu.com會如何呢,仍然能得到200 OK響應:
這裡寫圖片描述

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

訪問地址改為https://www.12306.cn,執行效果如下,大家都知道12306證書非法吧哈哈:
這裡寫圖片描述

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

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

而12306被認為不可信:
這裡寫圖片描述

但我們要知道,瀏覽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的錯誤?

我們來看看原始碼中這段校驗邏輯:伺服器證書資訊中有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;
                }
            });

這裡寫圖片描述

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

一般程式碼中,我們會給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.<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
            // 至少在4.5.34.5.4中,如果給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(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();
            }
        }
    }
}