微信支付相關-Spring RestTemplate和javax SSLContext

環境: java8+Spring

起因: 4月寫微信支付的後臺中間接口時,申請退款請求需要帶上商戶證書。微信官方給的java demo用的是apache的HttpClient,但因爲實際server用的是Spring…所以就考慮怎麼在Spring的RestTemplate裏引入商戶憑證,結果發現牽扯出來很多東西…這裏整理了一些。

思路:

  1. RestTemplate部分
    Spring用的不久,稍微仔細點地看了下代碼、才發現RestTemplate誠如其名就是封裝好的模板。它衆多的xxForObject、xxForEntity方法,內部流程其實很簡明:

    /* RestTemplate#doExecute */
    // ClientHttpRequestFactory#createRequest,生成一個具體請求實例
    ClientHttpRequest request = createRequest(url, method);
    if (requestCallback != null) {
        // 請求headers和body處理,用到了HttpMessageConverter
        requestCallback.doWithRequest(request); 
    }
    //ClientHttpRequest#execute 執行請求
    response = request.execute(); 
    // response error處理
    handleResponse(url, method, response);

    於是只看ClientHttpRequestFactory,查找下它的實現類:
    ClientHttpRequestFactory Hierarchy
    簡單檢查了下import、只有基於HttpComponents、Netty和OkHttp的涉及到了ssl。幾個request factory都可以通過constuctor注入相應client實例,所以就回到了HttpComponents/Netty/OkHttp怎麼加ssl……然後矛頭都指向了一個class:SSLContext

  2. 證書相關
    因爲之前讀書少在看微信demo時發現通篇的雙向認證、公鑰私鑰、KeyStore、PKCS12……幾乎都是陌生詞彙。網絡安全相關到目前才瞭解皮毛……還好只是寫個退款接口、搞起幾個相關的概念就寫完了:

    1. 首先必須先了解https簽名認證流程。SO上看到推薦Wikipedia Public-key cryptography裏面的Postal analogies(郵局比喻)、確實寫的好。其他就自行百度google了。這裏記錄的只有一個點就是證書和公鑰的區別:按Wikipedia的Public key certificate第一句就是“數字證書是用於證明公鑰所有者身份的電子文檔”、按SE的這個問題“X.509證書至少包含1)公鑰和2)公鑰所有者信息”,一開始沒搞清還是很影響理解的……

    2. 然後直接回來看SSLContext,簡單搜下例程知道它是先getInstance然後init, getInstance沒有深入看,總之先按demo用TLSv1協議;init參數有3個,均可爲null、null時用系統默認:

      public final void init(KeyManager[] var1, TrustManager[] var2, SecureRandom var3) 
      throws KeyManagementException       

      KeyManager 管理本地使用者的密鑰證書信息,微信商戶憑證就是由此引入;
      TrustManager 管理受信任的服務方的信息。這些信息可以每次向權威證書認證機構查詢、也可以自己看準了直接導入(比如12306讓乾的)。微信demo關於rootca.pem的說明文檔提到:“某些環境和工具已經內置了若干權威機構的根證書,無需引用該證書也可以正常進行驗證,這裏提供給您在未內置所必須根證書的環境中載入使用“,微信或騰訊必然在某家CA認證過,Java的話有默認的CA列表(cacerts文件,jdk目錄下,用.\bin\keytool.exe -list -keystore .\jre\lib\security\cacerts查看)。所以這個TrustManager可以直接設成null用系統默認的(就是demo裏寫的那樣)。
      SecureRandom 應該是https雙向認證成功後、對稱加密時用的隨機數生成器,這個也可以用默認不用考慮。

    3. 微信給的商戶證書是文件,要讀取成程序運行時的數據、所以就用到了FileInputStream和KeyStore。
      看javadoc,KeyStore和KeyStoreSpi作用是統一封裝存放了不同算法的證書密鑰,
      比如讀前面的cacerts是這樣:
      keystore-jks
      讀微信的apiclient_cert.p12文件時是這樣:
      keystore-wx
      具體值的含義和各種KeyStore的區別確實一下子搞不清、因爲暫時也用不到就先放着了……
      最後通過KeyManagerFactory的init方法,將需要的商戶憑證抽取出來:
      KeyManagerFactory
      微信商戶證書文件apiclient_cert.p12的格式爲PKCS12,按(還是)Wikipedia說的”It is commonly used to bundle a private key with its X.509 certificate”,上圖debug信息、和demo給的“導出的apiclient_cert.pem(證書文件)和apiclient_key.pem(私鑰文件)”驗證了這一說法。不過demo文檔還提到“服務器驗證客戶端的時候通過客戶端證書和簽名(既:apiclient_cert.p12 或者 apiclient_cert.pem和apiclient_key.pem)”,並不是很確定爲什麼這樣說。


綜上,一個非常簡單的test main:

語言: java
包依賴: 如圖
包依賴
說明:3個RestTemplate的factory配置,調試學習用
代碼:

import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import okhttp3.OkHttpClient;
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.ssl.SSLContexts;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.client.Netty4ClientHttpRequestFactory;
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;

import javax.net.ssl.*;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.SecureRandom;

public class RestTemplateExample {

    private static final String REFUND_URL = "https://api.mch.weixin.qq.com/secapi/pay/refund";

    public static void main(String[] args) throws Exception {
        final String certFile = args[0]; // cert file location
        final String passwd = args[1]; // mch_id
        KeyStore keyStore = loadFrom("PKCS12", certFile, passwd);
        // httpComponent
        ClientHttpRequestFactory factory = createHttpComponentFactory(keyStore, passwd);
        testGet(factory);
        // okhttp
        factory = createOkHttp3Factory(keyStore, passwd);
        testGet(factory);
        // netty
        factory = createNettyFactory(keyStore, passwd);
        testGet(factory);
        ((DisposableBean) factory).destroy();
        System.out.println("end");
    }

    private static void testGet(ClientHttpRequestFactory factory) {
        RestTemplate restTemplate = new RestTemplate(factory);
        System.out.println("using " + restTemplate.getRequestFactory().getClass());
        restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8));
        ResponseEntity<String> getRes = restTemplate.getForEntity(REFUND_URL, String.class);
        System.out.println(getRes.getBody());
    }

    private static KeyStore loadFrom(String type, String fileName, String passwd) throws Exception {
        KeyStore keyStore = KeyStore.getInstance(type);
        try (FileInputStream fileIn = new FileInputStream(fileName)) {
            keyStore.load(fileIn, passwd.toCharArray());
        }
        System.out.println("keystore entries: " + keyStore.size());
        return keyStore;
    }

    private static ClientHttpRequestFactory createOkHttp3Factory(KeyStore keyStore, String passwd) throws Exception {
        KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        keyManagerFactory.init(keyStore, passwd.toCharArray());
        SSLContext context = SSLContext.getInstance("TLSV1");
        context.init(keyManagerFactory.getKeyManagers(), null, null);
        OkHttpClient okHttpClient = new OkHttpClient.Builder()
            .sslSocketFactory(context.getSocketFactory(), getDefaultX509TrustManager())
            .build();
        return new OkHttp3ClientHttpRequestFactory(okHttpClient);
    }

    /**
     * @see OkHttpClient.Builder#sslSocketFactory(SSLSocketFactory)
     * @see OkHttpClient.Builder#sslSocketFactory(SSLSocketFactory, X509TrustManager)
     * @see sun.security.ssl.SSLContextImpl#engineInit(KeyManager[], TrustManager[], SecureRandom)
     */
    private static X509TrustManager getDefaultX509TrustManager() throws Exception {
        TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        factory.init((KeyStore) null);
        return (X509TrustManager) factory.getTrustManagers()[0];
    }

    /**
     * @see <a href="https://hc.apache.org/httpcomponents-client-ga/httpclient/examples/org/apache/http/examples/client/ClientCustomSSL.java">
     *     HttpClient custom ssl example</a>
     */
    private static ClientHttpRequestFactory createHttpComponentFactory(KeyStore keyStore, String passwd) throws Exception {
        SSLContext sslcontext = SSLContexts.custom()
            .loadKeyMaterial(keyStore, passwd.toCharArray()).build();
        SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(
            sslcontext, new String[]{"TLSv1"}, null,
            SSLConnectionSocketFactory.getDefaultHostnameVerifier());
        CloseableHttpClient httpclient = HttpClients.custom()
            .setSSLSocketFactory(sslsf).build();
        return new HttpComponentsClientHttpRequestFactory(httpclient);
    }

    private static ClientHttpRequestFactory createNettyFactory(KeyStore keyStore, String passwd) throws Exception {
        SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();
        KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        keyManagerFactory.init(keyStore, passwd.toCharArray());
        SslContext sslContext = sslContextBuilder.keyManager(keyManagerFactory).build();
        Netty4ClientHttpRequestFactory factory = new Netty4ClientHttpRequestFactory();
        factory.setSslContext(sslContext);
        return factory;
    }


}

上面寫不下的註釋:
1. OkHttp部分,按它javadoc的意思,它需要一個X509TrustManager來處理cert chain,雖然SSLSocketFactory的實現類裏就包着一個、但因爲沒有public get方法、要拿只能靠反射;爲了不用反射使源碼變得難看,就只好請開發者在client端調用時傳一個進來;即使這樣也還是很難看、且自行導入的CA列表也可能不安全、所以javadoc裏也不建議這麼做……
2. HttpClient包下不少ssl相關的class都被deprecate了,參考的apache官方示例(就是微信例程用法…)稍微改了下。
3. Netty factory會按url重用BootStrap和新建Channel連接,但EventLoopGroup線程池可以通用;實際用Spring的話框架會自動搜索和關閉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章