第5章:HttpsURLConnection 实战——在 Android 中发起 HTTPS 请求,处理证书验证

说实话,Android 开发里最让人头疼的,就是网络请求的安全问题。我记得刚入行那会儿,有个同事直接把 HTTP 请求写死在代码里,结果被安全团队打回来重做。嗯,今天我们就来聊聊 HttpsURLConnection,这个 Android 原生的 HTTPS 请求工具。

你可能会问:现在不都用 OkHttp 了吗?为什么还要学这个?我个人习惯是,理解底层原理比直接套框架更重要。HttpsURLConnection 是 Android 系统自带的,不依赖任何第三方库。你想想看,如果哪天项目需要精简依赖,或者你遇到一个奇怪的证书问题,不懂底层怎么排查?

5.1 基础用法:发起一个简单的 HTTPS 请求

先来个最简单的例子。用 HttpsURLConnection 发起 GET 请求,其实和 HttpURLConnection 差不多,只是协议变成了 HTTPS。

// 创建 URL 对象
URL url = new URL("https://api.example.com/data");
// 打开连接
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
// 设置请求方法
connection.setRequestMethod("GET");
// 设置超时时间
connection.setConnectTimeout(15000);
connection.setReadTimeout(15000);
// 获取响应码
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
    // 读取响应数据
    BufferedReader reader = new BufferedReader(
        new InputStreamReader(connection.getInputStream()));
    String line;
    StringBuilder response = new StringBuilder();
    while ((line = reader.readLine()) != null) {
        response.append(line);
    }
    reader.close();
    // 处理响应数据
    Log.d("HTTPS", "Response: " + response.toString());
}
// 关闭连接
connection.disconnect();

这段代码看着简单,但我得提醒你:网络请求不能在主线程执行。Android 4.0 以后,在主线程发起网络请求会直接抛 NetworkOnMainThreadException。我在项目中见过不少新手犯这个错,嗯,记得用 AsyncTask 或者线程池。

⚠️ 注意: 从 Android 9(API 28)开始,明文 HTTP 请求默认被禁止。如果你的目标版本是 28 以上,用 HTTP 会直接报错。所以,直接用 HTTPS 是更省心的选择。

5.2 证书验证:系统默认的信任机制

HttpsURLConnection 默认会验证服务器证书。它使用 Android 系统内置的 CA(证书颁发机构)列表。说白了,只要你的服务器证书是由受信任的 CA 签发的,比如 DigiCert、Let's Encrypt,那什么都不用做,直接请求就行。

为什么会这样?因为 Android 系统维护了一个信任锚点(Trust Anchor)列表。当 HttpsURLConnection 发起连接时,它会自动执行以下验证流程:

  1. 检查证书链:服务器返回的证书链是否完整
  2. 验证签名:每个证书的签名是否有效
  3. 检查有效期:证书是否在有效期内
  4. 验证主机名:证书中的域名是否匹配请求的域名

如果以上任何一步失败,就会抛出 SSLHandshakeException。我在项目中遇到过一个问题:测试环境的证书过期了,结果整个团队排查了半天才发现。所以,证书有效期一定要监控好。

5.3 自定义证书验证:信任自签名证书

在实际开发中,我们经常需要连接测试服务器,而这些服务器往往使用自签名证书。系统默认是不信任自签名证书的,这时候就需要我们自己处理证书验证。

有两种常见做法:

5.3.1 方式一:信任所有证书(不推荐用于生产)

这种方式我称之为「暴力解法」。它绕过了所有证书验证,任何 HTTPS 连接都能通过。嗯,只适合本地调试,千万别用到线上。

// 创建一个信任所有证书的 TrustManager
TrustManager[] trustAllCerts = new TrustManager[]{
    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[0];
        }
    }
};

// 初始化 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());

// 应用到 HttpsURLConnection
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
// 注意:还需要设置主机名验证器
connection.setHostnameVerifier((hostname, session) -> true);
⚠️ 警告: 信任所有证书意味着你的应用容易受到中间人攻击(MITM)。我曾经在一个外包项目中看到有人把这段代码直接用在生产环境,结果被安全审计打了个大大的红叉。记住:永远不要在生产环境信任所有证书

5.3.2 方式二:信任特定证书(推荐做法)

这才是正确的做法。我们把自签名证书打包到 APK 中,然后只信任这个证书。这样既保证了安全性,又能连接测试服务器。

// 从 assets 目录加载证书
CertificateFactory cf = CertificateFactory.getInstance("X.509");
InputStream certInput = context.getAssets().open("my_cert.crt");
Certificate certificate = cf.generateCertificate(certInput);
certInput.close();

// 创建 KeyStore 并添加证书
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("my_cert", certificate);

// 创建 TrustManager
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
    TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);

// 初始化 SSLContext
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);

// 应用到 HttpsURLConnection
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());

这段代码的核心逻辑是:我们告诉系统「除了这个证书,其他的都不信」。这样既安全又灵活。我个人习惯把证书放在 app/src/main/assets 目录下,方便管理。

5.4 主机名验证:别让域名被冒充

证书验证通过后,还有一道关卡:主机名验证。它检查服务器证书中的域名是否和你请求的域名一致。默认情况下,HttpsURLConnection 使用 DefaultHostnameVerifier,它会做严格的匹配。

如果你需要自定义主机名验证,比如允许通配符或者 IP 地址,可以实现 HostnameVerifier 接口:

HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setHostnameVerifier(new HostnameVerifier() {
    @Override
    public boolean verify(String hostname, SSLSession session) {
        // 自定义验证逻辑
        // 比如:允许特定 IP 地址
        if ("192.168.1.100".equals(hostname)) {
            return true;
        }
        // 或者使用默认验证
        return HostnameVerifier.getDefault().verify(hostname, session);
    }
});
💡 提示: 我曾经遇到一个坑:服务器使用了 CDN,导致证书中的域名和实际请求的域名不一致。这时候不要直接关闭主机名验证,而是应该和运维沟通,确保证书配置正确。绕过验证只是治标不治本。

5.5 完整实战:带证书验证的 HTTPS 请求

把上面的知识点串起来,写一个完整的工具类。这个类支持加载自定义证书,同时保留了系统默认的验证逻辑。

public class HttpsUtils {
    
    /**
     * 获取支持自定义证书的 HttpsURLConnection
     * @param url 请求地址
     * @param certInputStream 证书输入流,为 null 则使用系统默认
     * @return HttpsURLConnection
     */
    public static HttpsURLConnection getConnection(URL url, InputStream certInputStream) 
        throws Exception {
        
        HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
        
        if (certInputStream != null) {
            // 加载自定义证书
            CertificateFactory cf = CertificateFactory.getInstance("X.509");
            Certificate certificate = cf.generateCertificate(certInputStream);
            
            // 创建 KeyStore
            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            keyStore.load(null, null);
            keyStore.setCertificateEntry("custom_cert", certificate);
            
            // 创建 TrustManager
            TrustManagerFactory tmf = TrustManagerFactory.getInstance(
                TrustManagerFactory.getDefaultAlgorithm());
            tmf.init(keyStore);
            
            // 初始化 SSLContext
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, tmf.getTrustManagers(), null);
            
            connection.setSSLSocketFactory(sslContext.getSocketFactory());
        }
        
        // 设置默认超时
        connection.setConnectTimeout(15000);
        connection.setReadTimeout(15000);
        
        return connection;
    }
}

使用方式也很简单:

// 从 assets 加载证书
InputStream certStream = context.getAssets().open("server.crt");
URL url = new URL("https://test.example.com/api");
HttpsURLConnection connection = HttpsUtils.getConnection(url, certStream);
// 后续操作和普通 HTTP 请求一样
int responseCode = connection.getResponseCode();
// ...

5.6 常见问题与避坑指南

做 HTTPS 开发,总会遇到各种奇怪的问题。我整理了几个高频问题:

问题 原因 解决方案
javax.net.ssl.SSLHandshakeException 证书验证失败 检查证书是否过期、是否由受信任 CA 签发、证书链是否完整
javax.net.ssl.SSLPeerUnverifiedException 主机名不匹配 检查证书中的域名是否和请求 URL 一致
java.security.cert.CertPathValidatorException 证书链验证失败 确保服务器配置了完整的证书链,包括中间证书
javax.net.ssl.SSLException: Connection closed by peer TLS 版本不兼容 Android 4.x 默认使用 TLSv1.0,建议服务器支持 TLSv1.2

🔑 核心要点:

  • HttpsURLConnection 是 Android 原生的 HTTPS 请求工具,不依赖第三方库
  • 系统默认信任 CA 签发的证书,自签名证书需要自定义 TrustManager
  • 信任所有证书只适合调试,生产环境必须使用特定证书验证
  • 主机名验证是安全的重要一环,不要轻易关闭
  • 证书过期、TLS 版本不兼容是常见问题,提前做好监控

最后说一句:HTTPS 安全通信不是一锤子买卖。证书会过期,TLS 版本会更新,安全策略会变化。我建议你在项目中建立一个证书监控机制,定期检查证书状态。嗯,这样心里才踏实。

HttpsURLConnection 证书验证流程 发起 HTTPS 请求 SSL/TLS 握手开始 服务器返回证书 → 客户端验证 验证通过 主机名验证 ✅ 安全连接建立 验证失败 抛出异常 连接中断 证书过期 证书链不完整 主机名不匹配
💡 我的建议: 如果你刚开始接触 HTTPS 开发,先从系统默认的证书验证开始。等理解了整个流程,再尝试自定义证书。一口吃不成胖子,安全开发更是如此。
公众号:蓝海资料掘金营,微信deep3321