天天看點

記錄NoHttp和Glide對HTTPS支援的過程

最近項目請求換了HTTPS協定,要在原先NoHttp架構和Glide圖檔加載架構中加入HTTPS支援,以下是記錄遇到的種種坑,包括Android 4.x系統對TLS的支援存在版本差異,導緻通路異常的問題,和Glide如何加載HTTPS圖檔位址

将pem證書轉換成cer證書

背景同僚提供的是pem字尾證書檔案,還需要用openssl工具轉成cer證書。

OpenSSL下載下傳位址:http://slproweb.com/products/Win32OpenSSL.html

下載下傳安裝完成後,将安裝目錄中的bin目錄加入環境變量PATH,在指令行中輸入:

轉換完成後,将cer證書檔案放入assets目錄。

解決I/O error during system call, Connection reset by peer異常

其實NoHttp作者已經在部落格中寫明了NoHttp如何使用HTTPS,參見:Android如何使用Https

我們隻需要加入SSLContextUtil類,參見:Github:SSLContextUtil.java,并且将證書名改為assets目錄中的:

public class SSLContextUtil {

    /**
     * 拿到https證書, SSLContext (NoHttp已經修補了系統的SecureRandom的bug)。
     */
    @SuppressLint("TrulyRandom")
    public static SSLContext getSSLContext() {
        SSLContext sslContext = null;
        try {
            sslContext = SSLContext.getInstance("TLS");
            //擷取Assets中的證書
            InputStream inputStream = FxApp.getInstance().getAssets().open("test.cer");

            CertificateFactory cerFactory = CertificateFactory.getInstance("X.509");
            Certificate cer = cerFactory.generateCertificate(inputStream);

            KeyStore keyStore = KeyStore.getInstance("PKCS12");
            keyStore.load(null, null);
            keyStore.setCertificateEntry("trust", cer);

            KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
            keyManagerFactory.init(keyStore, null);

            TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init(keyStore);

            sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom());
        } catch (Exception e) {
            e.printStackTrace();
        }

        return sslContext;
    }

    /**
     * 如果不需要https證書.(NoHttp已經修補了系統的SecureRandom的bug)。
     */
    public static SSLContext getDefaultSLLContext() {
        SSLContext sslContext = null;
        try {
            sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, new TrustManager[]{trustManagers}, new SecureRandom());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return sslContext;
    }

    public static X509TrustManager getX509TrustManager() {
        return trustManagers;
    }

    public static HostnameVerifier getHostnameVerifier() {
        return HOSTNAME_VERIFIER;
    }

    private static X509TrustManager trustManagers = new X509TrustManager() {

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {

        }

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            return new X509Certificate[];
        }
    };

    private static final HostnameVerifier HOSTNAME_VERIFIER = new HostnameVerifier() {
        public boolean verify(String hostname, SSLSession session) {
            return true;
        }
    };
}
           

NoHttp請求時隻需加入幾句代碼就可以搞定:

Request<String> request = NoHttp.createStringRequest(url, RequestMethod.POST);
// 注意這裡設定SSLSokcetFactory的代碼是相同的
SSLContext sslContext = SSLContextUtil.getSSLContext();
if (sslContext != null) {
    SSLSocketFactory socketFactory = sslContext.getSocketFactory();
    httpsRequest.setSSLSocketFactory(socketFactory);
    requestQueue.add(, request, httpListener);//  添加到請求隊列,等待接受結果
}
           

在4.x以上手機跑是沒問題的,但是偏偏在4.x系統中就會報以下異常:

javax.net.ssl.SSLException: Read error: ssl=: I/O error during system call, Connection reset by peer
    at com.android.org.conscrypt.NativeCrypto.SSL_read(Native Method)
    at com.android.org.conscrypt.OpenSSLSocketImpl$SSLInputStream.read(OpenSSLSocketImpl.java:)
    at org.apache.http.impl.io.AbstractSessionInputBuffer.fillBuffer(AbstractSessionInputBuffer.java:)
    at org.apache.http.impl.io.AbstractSessionInputBuffer.read(AbstractSessionInputBuffer.java:)
    at org.apache.http.impl.io.ChunkedInputStream.read(ChunkedInputStream.java:)
    at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:)
    at java.io.FilterInputStream.read(FilterInputStream.java:)
           

網上查了許多資料發現,不同Android系統版本對HTTPS所依賴的TLS版本支援不同:

  • TLS v1.0從Android API 1+就被預設打開
  • TLS v1.1和TLS v1.2隻有在API 20+才會被預設打開(Android 5.0 API Level為21)
  • 也就是說低于Android 5.0的版本預設是關閉對TLS v1.1和TLS v1.2的支援

可以在SSL Server Test網站中測試我們的HTTPS位址對SSL支援的版本

記錄NoHttp和Glide對HTTPS支援的過程
記錄NoHttp和Glide對HTTPS支援的過程

可以看到測試位址在Android版本2.3.7~4.3中是Server closed connection,而Android 5.0+則是正常

解決方法
  • 讓伺服器配置相容支援TLS1.0、TLS1.1、TLS1.2,這樣用戶端就不需要做任何處理,完美相容
  • 讓Android 4.x打開對TLS v1.1、TLS v1.2的支援

對于第二種方法,由于公司項目NoHttp請求底層都用OkHttp,可以自定義一個SSLSocketFactory,在NoHttp每個請求中都配置SSLSocketFactory:

/**
 * 自定義SSLSocketFactory ,實作對TLSv1.1、TLSv1.2的支援
 */
public class NoSSLv3SocketFactory extends SSLSocketFactory {

    private static final String[] TLS_SUPPORT_VERSION = {"TLSv1.1", "TLSv1.2"};

    final SSLSocketFactory delegate;

    public NoSSLv3SocketFactory(SSLSocketFactory base) {
        this.delegate = base;
    }

    @Override
    public String[] getDefaultCipherSuites() {
        return delegate.getDefaultCipherSuites();
    }

    @Override
    public String[] getSupportedCipherSuites() {
        return delegate.getSupportedCipherSuites();
    }

    @Override
    public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
        return patch(delegate.createSocket(s, host, port, autoClose));
    }

    @Override
    public Socket createSocket(String host, int port) throws IOException, UnknownHostException {
        return patch(delegate.createSocket(host, port));
    }

    @Override
    public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {
        return patch(delegate.createSocket(host, port, localHost, localPort));
    }

    @Override
    public Socket createSocket(InetAddress host, int port) throws IOException {
        return patch(delegate.createSocket(host, port));
    }

    @Override
    public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
        return patch(delegate.createSocket(address, port, localAddress, localPort));
    }

    private Socket patch(Socket s) {
        if (s instanceof SSLSocket) {
            ((SSLSocket) s).setEnabledProtocols(TLS_SUPPORT_VERSION);
        }
        return s;
    }
}
           
/**
 * 登入
 * @param what               請求碼
 * @param username           手機号碼
 * @param passwordMd5        MD5加密後的密碼
 * @param onResponseListener
 */
public void login(int what, String username, String passwordMd5, OnResponseListener onResponseListener) {
    Request<LoginResponse> request = new JavaBeanRequest(url, RequestMethod.POST, LoginResponse.class);
    request.add("username", username);
    request.add("password", passwordMd5);
    addRequestQueue(request, what, onResponseListener);
}

/**
 * 讓每次NoHttp請求都支援HTTPS協定
 * @param request            請求對象
 * @param what               請求碼
 * @param onResponseListener 回調接口
 */
private void addRequestQueue(Request<?> request, int what, OnResponseListener onResponseListener) {
    SSLContext sslContext = SSLContextUtil.getSSLContext();
    if (sslContext != null) {
        //支援HTTPS,解決Android 4.x中HTTPS不支援TLS v1.1、TLS v1.2的BUG
        SSLSocketFactory NoSSLv3Factory = new NoSSLv3SocketFactory(sslContext.getSocketFactory());
        request.setSSLSocketFactory(NoSSLv3Factory);
        //加入隊列
        mRequestQueue.add(what, request, onResponseListener);
    }
}
           

這樣一來,NoHttp對HTTPS的支援就搞定了。

解決Glide不能加載HTTPS圖檔位址問題

解決完上面所說的NoHttp通路HTTPS接口問題後,在Android 5.0以上手機運作時發現Glide是可以加載HTTPS圖檔的,但是在Android 4.x中運作圖檔就無法被加載了,下面記錄下解決的方法:

1.首先,讓Glide使用OkHttp作為網絡底層架構(Glide網絡底層預設使用HttpURLConnection,而HttpURLConnection在Android 4.4以上才是使用了OkHttp),自定義一個GlideModule并且在AndroidManifest.xml中添加一個meta-data:

/**
 * A {@link GlideModule} implementation to replace Glide's default
 * {@link java.net.HttpURLConnection} based {@link com.bumptech.glide.load.model.ModelLoader} with an OkHttp based
 * {@link com.bumptech.glide.load.model.ModelLoader}.
 *
 * <p>
 *     If you're using gradle, you can include this module simply by depending on the aar, the module will be merged
 *     in by manifest merger. For other build systems or for more more information, see
 *     {@link GlideModule}.
 * </p>
 */
public class OkHttpGlideModule implements GlideModule {
    @Override
    public void applyOptions(Context context, GlideBuilder builder) {
        // Do nothing.
    }

    @Override
    public void registerComponents(Context context, Glide glide) {
        glide.register(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory());
    }
}
           
/**
 * A simple model loader for fetching media over http/https using OkHttp.
 */
public class OkHttpUrlLoader implements ModelLoader<GlideUrl, InputStream> {

    /**
     * The default factory for {@link OkHttpUrlLoader}s.
     */
    public static class Factory implements ModelLoaderFactory<GlideUrl, InputStream> {
        private static volatile OkHttpClient internalClient;
        private OkHttpClient client;

        private static OkHttpClient getInternalClient() {
            if (internalClient == null) {
                synchronized (Factory.class) {
                    if (internalClient == null) {
                        //讓Glide支援HTTPS協定
                        SSLContext sslContext = SSLContextUtil.getSSLContext();
                        internalClient = new OkHttpClient.Builder()
                                .sslSocketFactory(new NoSSLv3SocketFactory(sslContext.getSocketFactory()), SSLContextUtil.getX509TrustManager())
                                .hostnameVerifier(SSLContextUtil.getHostnameVerifier())
                                .build();
                    }
                }
            }
            return internalClient;
        }

        /**
         * Constructor for a new Factory that runs requests using a static singleton client.
         */
        public Factory() {
            this(getInternalClient());
        }

        /**
         * Constructor for a new Factory that runs requests using given client.
         */
        public Factory(OkHttpClient client) {
            this.client = client;
        }

        @Override
        public ModelLoader<GlideUrl, InputStream> build(Context context, GenericLoaderFactory factories) {
            return new OkHttpUrlLoader(client);
        }

        @Override
        public void teardown() {
            // Do nothing, this instance doesn't own the client.
        }
    }

    private final OkHttpClient client;

    public OkHttpUrlLoader(OkHttpClient client) {
        this.client = client;
    }

    @Override
    public DataFetcher<InputStream> getResourceFetcher(GlideUrl model, int width, int height) {
        return new OkHttpStreamFetcher(client, model);
    }
}
           
/**
 * Fetches an {@link InputStream} using the okhttp library.
 */
public class OkHttpStreamFetcher implements DataFetcher<InputStream> {
    private final OkHttpClient client;
    private final GlideUrl url;
    private InputStream stream;
    private ResponseBody responseBody;

    public OkHttpStreamFetcher(OkHttpClient client, GlideUrl url) {
        this.client = client;
        this.url = url;
    }

    @Override
    public InputStream loadData(Priority priority) throws Exception {
        Request.Builder requestBuilder = new Request.Builder()
                .url(url.toStringUrl());

        for (Map.Entry<String, String> headerEntry : url.getHeaders().entrySet()) {
            String key = headerEntry.getKey();
            requestBuilder.addHeader(key, headerEntry.getValue());
        }

        Request request = requestBuilder.build();

        Response response = client.newCall(request).execute();
        responseBody = response.body();
        if (!response.isSuccessful()) {
            throw new IOException("Request failed with code: " + response.code());
        }

        long contentLength = responseBody.contentLength();
        stream = ContentLengthInputStream.obtain(responseBody.byteStream(), contentLength);
        return stream;
    }

    @Override
    public void cleanup() {
        if (stream != null) {
            try {
                stream.close();
            } catch (IOException e) {
                // Ignored
            }
        }
        if (responseBody != null) {
            responseBody.close();
        }
    }

    @Override
    public String getId() {
        return url.getCacheKey();
    }

    @Override
    public void cancel() {
        // TODO: call cancel on the client when this method is called on a background thread. See #257
    }
}
           

以上三個類都是在compile ‘com.github.bumptech.glide:okhttp3-integration:[email protected]’中抽取出來再進行修改的,下面是AndroidManifest.xml的使用:

<!-- 讓Glide在5.0以下可以加載Https圖檔 -->
<meta-data
    android:name="com.juku.driving_school.common.glide.OkHttpGlideModule"
    android:value="GlideModule" />
           

到這裡就解決了HTTPS相關的一些問題,寫這篇博文也不過是為了記錄下自己踩過的坑,避免其他人踩坑時能夠快速出坑,能夠解決這些也是托了下面這些大神的福,深表感謝:

  • Android HTTPS、TLS版本支援相關解決方案
  • NoHttp:Android如何使用Https
  • Android Https相關完全解析 當OkHttp遇到Https
  • glide 內建okhttp3 解決https自簽名證書問題

2017-03-01更新:

在Android 5.x以上手機加載HTTPS位址的WebView,圖檔會加載失敗,找了很久才發現公司雖然将位址轉成了HTTPS,但内嵌圖檔位址依然還是HTTP,而Android 5.x以上是不允許從一個安全站點(HTTPS)加載一個不安全站點(HTTP)的内容,是以圖檔無法正常顯示,在Logcat中也可以看到如下錯誤:

此時我們隻需要将WebView設定為混合模式即可:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    /** 在webview裡面加載https url的時候,如果裡面需要加載http的資源或者重定向的時候,webview會block頁面加載 */
    settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
           

其中參數:

- MIXED_CONTENT_NEVER_ALLOW:WebView不允許從一個安全站點(HTTPS)加載非安全站點内容(HTTP),預設設定,非必要時推薦此預設設定!

- MIXED_CONTENT_ALWAYS_ALLOW:始終允許在安全站點中加載非安全站點内容,這就是我們上面設定的

- MIXED_CONTENT_COMPATIBILITY_MODE:在這種模式下,當涉及到混合式内容時,WebView會嘗試去相容最新Web浏覽器的風格。一些不安全的内容(Http)能被加載到一個安全的站點上(Https),而其他類型的内容将會被阻塞。這些内容的類型是被允許加載還是被阻塞可能會随着版本的不同而改變,并沒有明确的定義。這種模式主要用于在App裡面不能控制内容的渲染,但是又希望在一個安全的環境下運作。

注意:在實際開發中,雖然我們的伺服器已經更新為HTTPS,但子頁面中引用的内容有可能還是HTTP,是以請根據需要設定,如果伺服器可以做到HTTPS,盡量不要修改此配置!!