Android开发使用https及Webview访问https页面

6 篇文章 0 订阅
3 篇文章 0 订阅

HTTPS简介

HTTPS(Hyper Text Transfer Protocol Secure),是一种基于SSL/TLS的HTTP,所有的HTTP数据都是在SSL/TLS协议封装之上进行传输的。HTTPS协议是在HTTP协议的基础上,添加了SSL/TLS握手以及数据加密传输,也属于应用层协议。所以,研究HTTPS协议原理,最终其实就是研究SSL/TLS协议。

SSL/TLS协议

不使用SSL/TLS的HTTP通信,就是不加密的通信,所有的信息明文传播,带来了三大风险:

窃听风险:第三方可以获知通信内容。

篡改风险:第三方可以修改通知内容。

冒充风险:第三方可以冒充他人身份参与通信。

SSL/TLS协议是为了解决这三大风险而设计的,希望达到:

所有信息都是加密传输,第三方无法窃听。

具有校验机制,一旦被篡改,通信双方都会立刻发现。

配备身份证书,防止身份被冒充。

HTTPS基本运行过程

SSL/TLS协议的基本思路是采用公钥加密法,也就是说,客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密,这就是非对称加密。但是这里需要了解两个问题的解决方案。

如何保证公钥不被篡改?
解决方法:将公钥放在数字证书中。只要证书是可信的,公钥就是可信的。

公钥加密计算量太大,如何减少耗用的时间?
解决方法:每一次对话(session),客户端和服务器端都生成一个“对话密钥”(session key),用它来加密信息。由于“对话密钥”是对称加密,所以运算速度非常快,而服务器公钥只用于加密“对话密钥”本身,这样就减少了加密运算的消耗时间。

因此,SSL/TLS协议的基本过程是这样的:

客户端向服务器端索要并验证公钥。

双方协商生成“对话密钥”。

双方采用“对话密钥”进行加密通信。

上面过程的前两步,又称为“握手阶段”。


客户端发起HTTPS请求

服务端的配置
采用HTTPS协议的服务器必须要有一套数字证书,可以是自己制作或者CA证书。区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用CA证书则不会弹出提示页面。这套证书其实就是一对公钥和私钥。公钥给别人加密使用,私钥给自己解密使用。

传送证书
这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等。

客户端解析证书
这部分工作是有客户端的TLS来完成的,首先会验证公钥是否有效,比如颁发机构,过期时间等,如果发现异常,则会弹出一个警告框,提示证书存在问题。如果证书没有问题,那么就生成一个随即值,然后用证书对该随机值进行加密。

传送加密信息
这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。

服务端解密信息
服务端用私钥解密后,得到了客户端传过来的随机值(私钥),然后把内容通过该值进行对称加密。所谓对称加密就是,将信息和私钥通过某种算法混合在一起,这样除非知道私钥,不然无法获取内容,而正好客户端和服务端都知道这个私钥,所以只要加密算法够彪悍,私钥够复杂,数据就够安全。

传输加密后的信息
这部分信息是服务端用私钥加密后的信息,可以在客户端被还原。

客户端解密信息
客户端用之前生成的私钥解密服务端传过来的信息,于是获取了解密后的内容。

整个握手过程第三方即使监听到了数据,也束手无策。

HTTP和HTTPS的区别

https协议需要到CA申请证书或自制证书。
http的信息是明文传输,https则是具有安全性的ssl加密。
http是直接与TCP进行数据传输,而https是经过一层SSL(OSI表示层),用的端口也不一样,前者是80(需要国内备案),后者是443。
http的连接很简单,是无状态的;HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。

Android实现HTTPS通信

CA认证的数字证书网站

以百度的https网址(https://m.baidu.com/)为例,示例源码如下:

public void startHttpsConnection() {
    HttpsURLConnection httpsURLConnection = null;
    BufferedReader reader = null;
    try {
        URL url = new URL("https://m.baidu.com/");
        httpsURLConnection = (HttpsURLConnection) url.openConnection();
        httpsURLConnection.setConnectTimeout(5000);
        httpsURLConnection.setDoInput(true);
        httpsURLConnection.setUseCaches(false);
        httpsURLConnection.connect();

        reader = new BufferedReader(new InputStreamReader(httpsURLConnection.getInputStream()));
        StringBuilder sBuilder = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sBuilder.append(line);
        }
        Log.e("TAG", "Wiki content=" + sBuilder.toString());
    } catch (MalformedURLException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (httpsURLConnection != null) {
            httpsURLConnection.disconnect();
        }

        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

由于百度是有CA授权的数字证书,所以这里我们就是简单的使用HttpsUrlConnection对其进行访问,就实现了HTTPS通信。

自签名的数字证书网站

由于CA认证是需要收费的,所以有些网站为了节约成本,采用自签名的数字证书,比如,注明的购票网站12306目前依然是这么干的。如果我们用上述代码访问自签名的网站会有什么问题呢? 访问自签名证书的网站,Android直接会throw SSLHandshakeException,原因就是12306的数字证书不被Android系统的信任。想解决这个问题,有如下几种方法。

让HttpsURLConnection信任所有的CA证书

1. 实现X509TrustManager接口,在接口实现中跳过客户端和服务器端认证。

public class TrustAllCertsManager implements X509TrustManager {
    @Override
    public void checkClientTrusted(X509Certificate[] chain, String authType)
            throws CertificateException {
        // Do nothing -> accept any certificates
    }

    @Override
    public void checkServerTrusted(X509Certificate[] chain, String authType)
            throws CertificateException {
        // Do nothing -> accept any certificates
    }

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

2. 实现HostnameVerifier接口,不进行url和服务器主机名的验证。

public class VerifyEverythingHostnameVerifier implements HostnameVerifier {
    @Override
    public boolean verify(String hostname, SSLSession session) {
        return true;
    }
}

3. 基于上面实现的TrustAllCertsManager修改HttpsURLConnection类的默认SSL socket factory。
TrustManager[] trustManager = new TrustManager[] {new TrustEverythingTrustManager()};
SSLContext sslContext = null;
try {
    sslContext = SSLContext.getInstance("SSL");
    sslContext.init(null, trustManager, new java.security.SecureRandom());
} catch (NoSuchAlgorithmException e) {
    // do nothing
}catch (KeyManagementException e) {
    // do nothing
}
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
4. 实例化HttpsUrlConnection,并设置HostnameVerifier为上面实现的VerifyEverythingHostnameVerifier

httpsURLConnection = (HttpsURLConnection) url.openConnection();
httpsURLConnection.setHostnameVerifier(new VerifyEverythingHostnameVerifier());
但是以上方法存在严重的安全漏洞,因为默认相信一切证书,其实是忽略了证书验证。比如注明的中间人攻击: 虽然上述方案使用了HTTPS,客户端和服务器端的通信内容得到了加密,嗅探程序无法得到传输的内容,但是无法抵挡“中间人攻击”。例如,在内网配置一个DNS,把目标服务器域名解析到本地的一个地址,然后在这个地址上使用一个中间服务器作为代理,它使用一个假的证书与客户端通讯,然后再由这个代理服务器作为客户端连接到实际的服务器,用真的证书与服务器通讯。这样所有的通讯内容都会经过这个代理,而客户端不会感知,这是由于客户端不校验服务器公钥证书导致的。

让HttpsURLConnection信任指定的CA证书

为了防止上面方案可能导致的“中间人攻击”,我们可以事先下载服务器端公钥证书,然后将公钥证书编译到Android应用中,由应用自己来验证证书。也就是我们来教会HttpsUrlConnection来认识特定的自签名网站。还是以12306网站为例。

1. 下载12306的服务器公钥证书

12306公钥证书下载地址:12306根证书下载地址

2. 将下载的证书放到应用的assets目录下.

app->src->main->assets->srca.cer
(ps:使用Android Studio的同学需要特别注意默认asserts目录的位置)。

3. 构造特定的TrustManager[]数组.

private TrustManager[] createTrustManager() {
    BufferedInputStream cerInputStream = null;
    try {
        // 获取客户端存放的服务器公钥证书
        cerInputStream = new BufferedInputStream(getAssets().open("srca.cer"));
        // 根据公钥证书生成Certificate对象
        CertificateFactory cf = CertificateFactory.getInstance("X.509");
        Certificate ca = cf.generateCertificate(cerInputStream);
        Log.e("TAG", "ca=" + ((X509Certificate) ca).getSubjectDN());

        // 生成包含当前CA证书的keystore
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
        keyStore.load(null, null);
        keyStore.setCertificateEntry("ca", ca);

        // 使用包含指定CA证书的keystore生成TrustManager[]数组
        String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
        TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
        tmf.init(keyStore);
        return tmf.getTrustManagers();
    } catch (CertificateException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (KeyStoreException e) {
        e.printStackTrace();
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
    } finally {
        if (cerInputStream != null) {
            try {
                cerInputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    return null;
}

4. 初始化SSLContext.

SSLContext sc = SSLContext.getInstance("SSL");
TrustManager[] trustManagers = createTrustManager();
if (trustManagers == null) {
    Log.e("TAG", "tmf create failed!");
    return;
}
sc.init(null, trustManagers, new SecureRandom());
URL url = new URL("https://kyfw.12306.cn/otn/login/init");
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());

Retrofit支持HTTPS

和普通http客户端请求支持https一样,步骤如下:

1 CertificateFactory 得到Context.getSocketFactory
2 添加证书源文件
3 绑定到okhttpClient
4设置okhttpClient到retrofit中

证书同样可以设置到okhttpclient中,我们可以把证书放到raw路径下

SLSocketFactory sslSocketFactory =getSSLSocketFactory_Certificate(context,"BKS", R.raw.XXX);
绑定证书:

protected static SSLSocketFactory getSSLSocketFactory(Context context, int[] certificates) {

if (context == null) {
    throw new NullPointerException("context == null");
}

CertificateFactory certificateFactory;
try {
    certificateFactory = CertificateFactory.getInstance("X.509");
    KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
    keyStore.load(null, null);

    for (int i = 0; i < certificates.length; i++) {
        InputStream certificate = context.getResources().openRawResource(certificates[i]);
        keyStore.setCertificateEntry(String.valueOf(i), certificateFactory.generateCertificate(certificate));

        if (certificate != null) {
            certificate.close();
        }
    }
    SSLContext sslContext = SSLContext.getInstance("TLS");
    TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    trustManagerFactory.init(keyStore);
    sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());
   return sslContext.getSocketFactory();

构建HostnameVerifier:

protected static HostnameVerifier getHostnameVerifier(final String[] hostUrls) {

    HostnameVerifier TRUSTED_VERIFIER = new HostnameVerifier() {

        public boolean verify(String hostname, SSLSession session) {
            boolean ret = false;
            for (String host : hostUrls) {
                if (host.equalsIgnoreCase(hostname)) {
                    ret = true;
                }
            }
            return ret;
        }
    };

return TRUSTED_VERIFIER;

设置setSocketFactory:

okhttpBuilder.socketFactory(HttpsFactroy.getSSLSocketFactory(context, certificates));
certificates 是你raw下证书源ID, int[] certificates = {R.raw.myssl}

设置setNameVerifier:

okhttpBuilder.hostnameVerifier(HttpsFactroy.getHostnameVerifier(hosts));

hosts是你的host数据 列如 String hosts[]`= {“https//:aaaa,com”, “https//:bbb.com”}

实现自定义 添加到Retrofit:

okHttpClient = okhttpBuilder.build(); 
  Retrofit retrofit = new Retrofit.Builder() .client(okHttpClient) .build();
如果信任所有https请求,
可以直接将OkHttpClient的HostnameVerifier设置为false

OkHttpClient client = new OkHttpClient();

    client.setHostnameVerifier(new HostnameVerifier() {
        @Override
        public boolean verify(String s, SSLSession sslSession) {
            return true;
        }
    });
    TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
        @Override
        public void checkClientTrusted(
                java.security.cert.X509Certificate[] x509Certificates,
                String s) throws java.security.cert.CertificateException {
        }

        @Override
        public void checkServerTrusted(
                java.security.cert.X509Certificate[] x509Certificates,
                String s) throws java.security.cert.CertificateException {
        }

        @Override
        public java.security.cert.X509Certificate[] getAcceptedIssuers() {
            return new java.security.cert.X509Certificate[] {};
        }
    } };
    try {
        SSLContext sc = SSLContext.getInstance("TLS");
        sc.init(null, trustAllCerts, new java.security.SecureRandom());
        client.setSslSocketFactory(sc.getSocketFactory());
    } catch (Exception e) {
        e.printStackTrace();
    }


         clent.protocols(Collections.singletonList(Protocol.HTTP_1_1))
         .build();

WebView访问https页面

服务器证书校验主要针对 WebView 的安全问题。
在 app 中需要通过 WebView 访问 url,因为服务器采用的自签名证书,而不是 ca 认证,使用 WebView 加载 url 的时候会显示为空白,出现无法加载网页的情况。
使用 ca 认证的证书,在 WebView 则可以直接显示出来,不需要特殊处理。
以往针对自签名证书的解决方案是继承 WebViewClient 重写 onReceivedSslError 方法,然后直接使用 handler.proceed(),该方案其实是忽略了证书,存在安全隐患。
安全的方案是当出现了证书问题的时候,读取 asserts 中保存的的根证书,然后与服务器校验,假如通过了,继续执行 handler.proceed(),否则执行 handler.cancel()。

简单的解决方案(不安全)

wv.setWebViewClient(new WebViewClient(){

@override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error){

//handler.cancel(); 默认的处理方式,WebView变成空白页
  handler.proceed();接受证书

//handleMessage(Message msg); 其他处理
}

// 这行代码一定加上否则效果不会出现  
 webView.getSettings().setJavaScriptEnabled(true);  

直接运行是页面是可以打开的。但是打好签名包之后,依旧打不开!
一路追踪之后,发现是那个方法被混淆了
proguard:mapping.txt
xx.xx.xxx
    xx.xx.xxx this$0 -> a
    void onReceivedSslError(android.webkit.WebView,android.webkit.SslErrorHandler,android.net.http.SslError) -> onReceivedSslError

所以还要必要在混淆文件proguard.cfg中,加入以下:
-keep public class android.net.http.SslError

-dontwarn android.webkit.WebView
-dontwarn android.net.http.SslError
-dontwarn Android.webkit.WebViewClient

还有一点要提到的是,如果手机添加了代理。也是打不开的。此时,需要将代理的证书导入到Android设备。以Fiddler为例:

Fiddler本质上是一个HTTPS代理服务器,其自己带的证书显然不会在Android设备的受信任证书列表里。

有些应用程序会查看服务器端的证书是否是由受信任的根证书签名的,如果不是就直接跳出。

所以,为了保险起见,我们要将Fiddler代理服务器的证书导到Android设备上。

导入的过程非常简单,打开设备自带的浏览器,在地址栏中输入代理服务器的IP和端口,例如本例中我们会输入192.169.11.8:8888,进入之后会看到一个Fiddler提供的页面。点击页面中的“FiddlerRoot certificate”链接,接着系统会弹出对话框。输入一个证书名称,然后直接点“确定”就好了。

但是,如果网页中含有图片,而图片采用的协议是http,那么图片将无法显示。这是因为,

Android webview 从Lollipop(5.0)开始webview默认不允许混合模式,https当中不能加载http资源,需要设置开启。

Mixed content using HTTP and HTTPS on WebViews are disabled by default starting Lollipop. Is possible that is not working on devices with Lollipop? If this is the case, you can change the default WebView setting on Lollipop using:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}

安全的解决方案

public class WebviewClient3 extends WebViewClient {
    private Context context;

    public WebviewClient3(Context context) {
        this.context = context;
    }

    @Override
    public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
        test12306(handler, view.getUrl());
    }

    // 以 12306 的证书为例,因为 12306 的证书是自签名的
    private void test12306(final SslErrorHandler handler, String url) {
        OkHttpClient.Builder builder;
        try {
            builder = setCertificates(new OkHttpClient.Builder(), context.getAssets().open(MainActivity.cer_protal_root));
        } catch (IOException e) {
            builder = new OkHttpClient.Builder();
        }
        Request request = new Request.Builder().url(url)
                .build();
        builder.build().newCall(request).enqueue(new Callback() {
            @Override
            public void onFailure(Call call, IOException e) {
                Log.e("12306 error", e.getMessage());
                handler.cancel();
            }

            @Override
            public void onResponse(Call call, Response response) throws IOException {
                Log.e("12306 ", response.body().string());
                handler.proceed();
            }
        });
    }

    private OkHttpClient.Builder setCertificates(OkHttpClient.Builder client, InputStream... certificates) {
        try {
            CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
            KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC");
            keyStore.load(null);
            int index = 0;
            for (InputStream certificate : certificates) {
                String certificateAlias = Integer.toString(index++);
                keyStore.setCertificateEntry(certificateAlias, certificateFactory.generateCertificate(certificate));

                try {
                    if (certificate != null)
                        certificate.close();
                } catch (IOException e) {
                }
            }
            SSLContext sslContext = SSLContext.getInstance("TLS");
            TrustManagerFactory trustManagerFactory =
                    TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
            trustManagerFactory.init(keyStore);
            sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom());
            SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
            X509TrustManager trustManager = Platform.get().trustManager(sslSocketFactory);
            client.sslSocketFactory(sslSocketFactory, trustManager);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return client;
    }
}

以上代码可以针对规范的自签名证书进行校验了。但是呢,我们的证书不规范,会出现 Hostname xxx not verified 的情况。这种情况需要对 Hostname 进行校验。需要在 client 上添加如下代码:

client.hostnameVerifier(new HostnameVerifier() {
        @Override
        public boolean verify(String hostname, SSLSession session) {
            String peerHost = session.getPeerHost();//服务器返回的域名
            try {
                X509Certificate[] peerCertificates = (X509Certificate[]) session.getPeerCertificates();
                for (X509Certificate c : peerCertificates) {
                    X500Principal subjectX500Principal = c.getSubjectX500Principal();
                    String name = new X500p(subjectX500Principal).getName();
                    String[] split = name.split(",");
                    for (String s : split) {
                        if (s.startsWith("CN")) {
                            if (s.contains(hostname) && s.contains(peerHost)) {
                                return true;
                            }
                        }
                    }
                }
            } catch (SSLPeerUnverifiedException e) {
                e.printStackTrace();
            }
            return false;
        }
    });


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值