iOS 中使用自签名证书实现 HTTPS 双向认证

iOS 中使用自签名证书实现 HTTPS 双向认证_第1张图片

最近项目中,需要使用自签名的 HTTPS 证书实现双向认证。网上的资料很多,但是存在各种各样的问题,与 iOS 版本、ATS 配置 等多方面因素有关。弄好之后先整一份记下来。完整的内容涉及到的内容比较多,还是要全面查阅文档,本文只记录最终的结果,和部分遇到的问题。
  本文中的代码,在 iOS 8.x 和 iOS 9.x 的模拟器中测试通过,iOS 10.x 模拟器和真机中测试通过。

一、背景知识

对于 HTTPS 认证,不管是单向还是双向,在客户端连接到服务端时,会触发客户端的 Authroization Challenge(没找到太合适的翻译,暂且理解为授权质询)回调,在处理 Authroization Challenge 之后,得到两个值:(不知道怎么翻译,随便写下)

  • NSURLSessionAuthChallengeDisposition 处置方式
    • NSURLSessionAuthChallengeUseCredential
      使用指定的凭证,凭证可能为空
    • NSURLSessionAuthChallengePerformDefaultHandling
      忽略凭证,使用默认的质询处理器
    • NSURLSessionAuthChallengeCancelAuthenticationChallenge
      整个请求将被取消; 凭证参数被忽略。
    • NSURLSessionAuthChallengeRejectProtectionSpace
      这个挑战被拒绝,并且应该尝试下一个 Authentication Protection Space;凭证参数被忽略。
  • NSURLCredential * 凭证
    可以根据回调时传入的信息,自己调用相关 API 获取凭证,也可以自己伪造

将得到的两个值,作为回调函数的结果回传给系统,以完成 Authroization Challenge。

以上过程仅是对 iOS 认证过程的分析,不过个人认为,网络模型是一致的,在不同技术中即便在实现细节上有所差异,但总体思路还是大同小异的。

二、服务端认证

对于采用通过 CA 购买的正式证书,只要没有特别要求,手机端不需要对 Authroization Challenge 做任何处理,就可以直接连接。
  如果是自签名证书,就需要做一些处理工作。iOS 8 及其之前的版本比较简单,而且目前 iOS 8 在市面上的保有量已经很少,不做细致讨论。iOS 9+ 之后引入了 ATS,带来的问题比较多所以从代码到配置上都要做相应调整。

1、白名单方式

步骤一(修改配置 Info.plist):
(1)针对域名配置

修改 Info.plist,将要访问的域名配置为 NSExceptionAllowsInsecureHTTPLoads,允许不安全的 HTTP 访问:





    NSAppTransportSecurity
    
        NSExceptionDomains
        
            yourdomain.com
            
                NSIncludesSubdomains
                
                NSExceptionAllowsInsecureHTTPLoads
                
            
        
    


这里对于域名的配置,类似是个白名单的方式,在白名单中的域名,配置为允许不安全的 HTTPS 连接。不安全的证书原因很多,常见的可能是如下原因导致的:

  • 其根证书不被操作系统信任,如:自签证书
  • 证书过期或被吊销
  • 证书域名与实际域名不匹配



  配置中的 NSIncludesSubdomains 部分,建议把域名写为顶级域名,然后把 NSIncludesSubdomains 置 为 true包含子域名
  如果是特定的完整域名,如:www.yourdomain.com 则把 NSIncludesSubdomains 置 为 false。后面的说法,几次验证的效果不同。
如果没有特别要求,建议使用第一种做法,写一级域名,然后包含其子域。

(2)最简单粗暴的方法




    NSAppTransportSecurity
    
        NSAllowsArbitraryLoads
        
    


这种方式:

  • 允许非安全的 HTTP 请求,iOS 9+ 默认是不允许 HTTP 连接的
  • 对于所有域名都可以使用自签名证书,不再需要逐个指定域名
步骤二(修改代码):

修改代码:

// 安全策略
// 同浏览器行为,以操作系统规则对服务器证书
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
// 不校验域名,如果需要校验域名,需要采用内置证书的方式
policy.validatesDomainName = NO;
// 允许无效证书
policy.allowInvalidCertificates = YES;

// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;

// 重要!!!设置缓存策略,避免缓存
// AFNetworking 的 GET 方法缓存非常明显,一旦成功一次,后面就会直接使用缓存的结果,即便网络访问失败,也能返回成功数据,会对判断造成误导,所以一定要加上这一句!!!
[mgr.requestSerializer setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
// 发送请求
[mgr GET:...];

重要:
  白名单方式最简单,但是这样做只建立安全连接,但不会对服务端证书做校验,比如:不会校验证书与域名的一致性。这样做的问题是无法防御“中间人攻击”

2、内置证书方式

(1)基本实现

白名单的方案不够安全,更为安全的做法:采用内置证书的方式,将用于校验的证书内置在客户端,不信任除此之外的证书。内置的证书,可以是服务端证书,或者是用于颁发服务端证书的 CA 的证书。具体要看证书具体的签发方式。
  内置的方式,是将证书转为 DER 格式,然后以 .cer 为扩展名,作为资源放到工程中,AFNetworking 就可以自动识别了。
  同时,代码要做如下调整:

// 安全策略
// 改为 AFSSLPinningModeCertificate
AFSecurityPolicy *policy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
// 指定验证域名。如果访问的域名与证书域名不一致,则不能通过
// 如果需要做域名校验,必须使用 Pinned 方式。白名单方式,不集成证书,无法校验域名
policy.validatesDomainName = YES;
// 对于自签证书,使用这个选项
policy.allowInvalidCertificates = YES;
// cerData1、cerData2 为 NSData,内容为 DER 格式证书
// 证书可以是 CA 证书,也可以是服务端部署的证书,这一步可选,AFN 可以自动识别
policy.pinnedCertificates = [NSSet setWithObjects:cerData1, cerData2, nil];


// 为 SessionManager 配置安全策略
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.securityPolicy = policy;

[mgr.requestSerializer setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
// 发送请求
[mgr GET:...];
☆☆☆ 默认校验规则

上面说的两种方式,实际上都是使用了 AFNetworking 的默认校验规则,并且根据默认规则做了个简单实现。其规则是这样的:
AFNetworking 的 AFSecurityPolicy 类有如下方法:

- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain

涉及到三个因素:

  • securityPolicy.allowInvalidCertificates 是否允许无效证书,个人理解这里所说的无效证书,是指类似浏览器校验行为,操作系统不认的证书
  • securityPolicy.validatesDomainName 校验域名
  • securityPolicy.SSLPinningMode PinningMode

表面看来有如下规律:

  • 如果要使用自签名证书,必须指定 allowInvalidCertificates = YES;,否则不能通过;
  • 如果 allowInvalidCertificates == YES 并且 SSLPinningMode == AFSSLPinningModeNone,就是说只校验服务端证书,不管客户端内置证书,并指定为允许无效证书,则可以通过;
  • SSLPinningMode == AFSSLPinningModeCertificate 或 AFSSLPinningModePublicKey 则看本地内置证书与服务端证书是否匹配
  • 使用了 AFSSLPinningModeCertificateAFSSLPinningModePublicKey,会导致客户端没有内置证书的网站都不能访问,如:https://www.baidu.com

默认校验规则总结:
先约定个几个名词:

正规证书 <=> 操作系统认可 and (域名一致 or 不校验域名)
有效证书 <=> 正规证书 or 允许非正规证书
无效证书 <=> 操作系统不认可 and 不允许非正规证书

  • 先检查 SSLPinningMode,如果为 AFSSLPinningModeNone,检查证书是否为有效证书即为校验结果;
  • 如果 SSLPinningModeAFSSLPinningModeCertificateAFSSLPinningModePublicKey,检查证书,如果为无效证书,校验结果不通过;如果为有效证书,后续则根据本地集成证书与服务端证书一起校验结果,作为最终校验结果。
  • validatesDomainName,只是判定因素之一,虽然影响整体校验结果,但不影响校验逻辑。如:虽然证书合法,指定做域名校验,但是证书域名与访问域名不一致,结果是不通过。

这部分的校验,可以参见官方文档:Overriding TLS Chain Validation Correctly

(2)个性化处理:指定身份验证质询回调块

对于“标准场景”,达到可访问的目的,没有额外要求,上述代码已经可以了。但是对于需要额外处理的场景,如:失败的时候给出对应提示,需要使用如下方法,来指定用于处理授权质询的回调块:

// AFURLSessionManager 类
// 指定用于处理 身份验证质询 的回调块
– setSessionDidReceiveAuthenticationChallengeBlock:

这部分的实现详情可以参见 AFNetworking 中 AFURLSessionManager.m 文件里如下方法:

// AFURLSessionManager.m
// 处理身份验证质询
- URLSession:didReceiveChallenge:completionHandler:

在这个方法中,会先查看用户是否指定了自己的回调块,如果指定了就执行用户自己的回调块,否则执行默认实现。编写自己的回调方法时,可以参考默认实现。

注意:默认实现中,只实现了服务端验证。对于客户端验证部分,只做了如下处理:

*credential = nil;
disposition = NSURLSessionAuthChallengePerformDefaultHandling;

如果要做客户端认证,重写这部分代码即可,后文中会提到。

调试注意事项:

测试时有一点需要注意,如果使用 GET 方法,应保证每次都真实发送了请求,而不是使用缓存,避免影响测试效果。坑啊!

  • (推荐)客户端处理:Request 的 Header 中,指定 Cache-Controlno-cache
  • 处理 URL:为 URL 增加时间戳
  • 服务端处理:Response 的 Header 中,指定 Cache-Controlno-cache
  • iOS 端设置:指定缓存策略(不推荐)

3、UIWebView

(1)使用 AFNetworking

AFNetworking 提供了 UIWebView+AFNetworking Category,可以通过这个分类为 UIWebView 指定 sessionManager,并调用新增加的 - loadRequest:MIMEType:textEncodingName:progress:success:failure: 方法来进行加载。但是在 Cordova 这样的组件中,还会使用 UIWebView 默认的 - loadRequest: 方法,可以配合 Method Swizzling 解决该问题。不过这样的话还是有个问题,会导致 UIWebView 的历史丢失,无法执行“返回”操作,原因是没有使用 UIWebView 自己的方法去访问。

(2)使用 NSURLProtocol

目前对于网络认证相关的处理,效果最好、侵入性最小、对已有代码逻辑影响最小的,是 NSURLProtocol 方式。这里有个用于使 UIWebView 支持客户端认证的插件,对于服务端认证一样有效,参见 https://github.com/mwaylabs/cordova-plugin-client-certificate。
  题外话,NSURLProtocol 对于很多特定场景来说更为有效。比如:曾经有项目使用了 HTTP Basic Authorization 认证。如果不使用 NSURLProtocol 的方案,可能会导致以下两种情况不能通过认证:

  • 302 引发的跳转不能自动带上认证信息
  • Web 页面上的

你可能感兴趣的:(iOS 中使用自签名证书实现 HTTPS 双向认证)