天天看点

记录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,尽量不要修改此配置!!