iOS Authentication Challenge

概述

在 iOS 中进行网络通信时,为了安全,可能会产生认证质询(Authentication Challenge),例如: HTTP Basic AuthenticationHTTPS Server Trust Authentication 。本文介绍的是使用 URLSession 发送网络请求时,应该如何处理这些认证质询,最后会对 iOS 最著名的两个网络框架 -- AFNetworkingAlamofire 中处理认证质询部分的代码进行阅读、分析。本文中使用的开发语言是 Swift。

处理质询的方法

当发送一个 URLSessionTask 请求时,服务器可能会发出一个或者多个认证质询。URLSessionTask 会尝试处理,如果不能处理,则会调用 URLSessionDelegate 的方法来处理。

URLSessionDelegate 处理认证质询的方法:

// 这个方法用于处理 session 范围内的质询,如:HTTPS Server Trust Authentication。一旦成功地处理了此类质询,从该 URLSession 创建的所有任务保持有效。
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    
}

// 这个方法用于处理任务特定的质询,例如:基于用户名/密码的质询。每个任务都可能发出自己的质询。当需要处理 session 范围内的质询时,但上面那个方法又没有实现的话,也会调用此方法来代替
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    
}

如果需要 URLSessionDelegate 的方法来处理认证质询,可是又没有实现相应的方法,则服务器可能会拒绝该请求,并且返回一个值为 401(禁止)的 HTTP 状态代码。

确定质询类型

URLAuthenticationChallenge

URLSessionDelegate 处理认证质询的方法都会接受一个 challenge: URLAuthenticationChallenge 参数,它提供了在处理认证质询时所需的信息。

class URLAuthenticationChallenge: NSObject {
    
    // 需要认证的区域
    var protectionSpace: URLProtectionSpace
    
    //  表示最后一次认证失败的 URLResponse 实例
    var failureResponse: URLResponse?
   
    // 之前认证失败的次数
    var previousFailureCount: Int
    
    // 建议的凭据,有可能是质询提供的默认凭据,也有可能是上次认证失败时使用的凭据
    var proposedCredential: URLCredential?
    
    // 上次认证失败的 Error 实例
    var error: Error?

    // 质询的发送者
    var sender: URLAuthenticationChallengeSender?
    
}

其中,它的核心是 protectionSpace: URLProtectionSpace 属性。

URLProtectionSpace

需要认证的区域,定义了认证质询的一系列信息,这些信息确定了应开发者应该如何响应质询,提供怎样的 URLCredential ,例如: host 、端口、质询的类型等。

class URLProtectionSpace : NSObject {
    
    // 质询的类型
    var authenticationMethod: String
    
    // 进行客户端证书认证时,可接受的证书颁发机构
    var distinguishedNames: [Data]?

    var host: String
  
    var port: Int
    
    var `protocol`: String?

    var proxyType: String?

    var realm: String?
    
    var receivesCredentialSecurely: Bool
    
    // 表示服务器的SSL事务状态
    var serverTrust: SecTrust?
}

其中,它的 authenticationMethod 属性表明了正在发出的质询的类型(例如: HTTP Basic AuthenticationHTTPS Server Trust Authentication )。使用此值来确定是否可以处理该质询和怎么处理质询。

NSURLAuthenticationMethod 常量

authenticationMethod 属性的值为以下常量之一,这些就是认证质询的类型。

/* session 范围内的认证质询 */

// 客户端证书认证
let NSURLAuthenticationMethodClientCertificate: String

// 协商使用 Kerberos 还是 NTLM 认证
let NSURLAuthenticationMethodNegotiate: String

// NTLM 认证
let NSURLAuthenticationMethodNTLM: String

// 服务器信任认证(证书验证)
let NSURLAuthenticationMethodServerTrust: String



/* 任务特定的认证质询 */

// 使用某种协议的默认认证方法
let NSURLAuthenticationMethodDefault: String

// HTML Form 认证,使用 URLSession 发送请求时不会发出此类型认证质询
let NSURLAuthenticationMethodHTMLForm: String

// HTTP Basic 认证
let NSURLAuthenticationMethodHTTPBasic: String

// HTTP Digest 认证
let NSURLAuthenticationMethodHTTPDigest: String

响应质询

URLSessionDelegate 处理认证质询的方法都接受一个 completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void 闭包参数,最终需要调用此闭包来响应质询,否则 URLSessionTask 请求会一直处于等待状态。

这个闭包接受两个参数,它们的类型分别为 URLSession.AuthChallengeDispositionURLCredential? ,需要根据 challenge.protectionSpace.authenticationMethod 的值,确定如何响应质询,并且提供对应的 URLCredential 实例。

URLSession.AuthChallengeDisposition

它是一个枚举类型,表示有以下几种方式来响应质询:

public enum AuthChallengeDisposition : Int {

    // 使用指定的凭据(credential)
    case useCredential 

    // 默认的质询处理,如果有提供凭据也会被忽略,如果没有实现 URLSessionDelegate 处理质询的方法则会使用这种方式
    case performDefaultHandling 
    
    // 取消认证质询,如果有提供凭据也会被忽略,会取消当前的 URLSessionTask 请求
    case cancelAuthenticationChallenge 

    // 拒绝质询,并且进行下一个认证质询,如果有提供凭据也会被忽略;大多数情况不会使用这种方式,无法为某个质询提供凭据,则通常应返回 performDefaultHandling
    case rejectProtectionSpace
}

URLCredential

要成功响应质询,还需要提供对应的凭据。有三种初始化方式,分别用于不同类型的质询类型。

// 使用给定的持久性设置、用户名和密码创建 URLCredential 实例。
public init(user: String, password: String, persistence: URLCredential.Persistence) {
    
}

// 用于客户端证书认证质询,当 challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate 时使用
// identity: 私钥和和证书的组合
// certArray: 大多数情况下传 nil
// persistence: 该参数会被忽略,传 .forSession 会比较合适
public init(identity: SecIdentity, certificates certArray: [Any]?, persistence: URLCredential.Persistence) {
    
}

// 用于服务器信任认证质询,当 challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust 时使用
// 从 challenge.protectionSpace.serverTrust 中获取 SecTrust 实例
// 使用该方法初始化 URLCredential 实例之前,需要对 SecTrust 实例进行评估
public init(trust: SecTrust) {
    
}

URLCredential.Persistence

用于表明 URLCredential 实例的持久化方式,只有基于用户名和密码创建的 URLCredential 实例才会被持久化到 keychain 里面

public enum Persistence : UInt {

    case none
    
    case forSession
    
    // 会存储在 iOS 的 keychain 里面
    case permanent

    // 会存储在 iOS 的 keychain 里面,并且会通过 iCloud 同步到其他 iOS 设备
    @available(iOS 6.0, *)
    case synchronizable
}

URLCredentialStorage

用于管理 URLCredential 的持久化。

基于用户名/密码的认证

HTTP BasicHTTP DigestNTLM 都是基于用户名/密码的认证,处理这种认证质询的方式如下:

func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    
    switch challenge.protectionSpace.authenticationMethod {
    case NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodHTTPDigest, NSURLAuthenticationMethodNTLM:
        let user = "user"
        let password = "password"
        let credential = URLCredential(user: user, password: password, persistence: .forSession)
        completionHandler(.useCredential, credential)
    default:
        completionHandler(.performDefaultHandling, nil)
    }
}

HTTPS Server Trust Authentication

当发送一个 HTTPS 请求时, URLSessionDelegate 将收到一个类型为 NSURLAuthenticationMethodServerTrust 的认证质询。其他类型的认证质询都是服务器对 App 进行认证,而这种类型则是 App 对服务器进行认证。

大多数情况下,对于这种类型的认证质询可以不实现 URLSessionDelegate 处理认证质询的方法, URLSessionTask 会使用默认的处理方式( performDefaultHandling )进行处理。但是如果是以下的情况,则需要手动进行处理:

  • 与使用自签名证书的服务器进行 HTTPS 连接。
  • 进行更严格的服务器信任评估来加强安全性,如:通过使用 SSL Pinning 来防止中间人攻击。

认证

对服务器的信任认证做法大致如下:

func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    // 判断认证质询的类型,判断是否存在服务器信任实例 serverTrust
    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
        let serverTrust = challenge.protectionSpace.serverTrust else {
            // 否则使用默认处理
            completionHandler(.performDefaultHandling, nil)
            return
    }
    // 自定义方法,对服务器信任实例 serverTrust 进行评估
    if evaluate(trust, forHost: challenge.protectionSpace.host) {
        // 评估通过则创建 URLCredential 实例,告诉系统接受服务器的凭据
        let credential = URLCredential(trust: serverTrust)
        completionHandler(.useCredential, credential)
    } else {
        // 否则取消这次认证,告诉系统拒绝服务器的凭据
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

Trust Services

对服务器的信任认证中最核心的步骤是对服务器信任实例 serverTrust 进行评估,也就是以上代码中的 evaluate(trust, forHost: challenge.protectionSpace.host)

这需要涉及到苹果的 Security 框架,它是一个比较底层的框架,用于保护 App 管理的数据,并控制对 App 的访问,一般开发很少会接触到。这里面的类都不能跟普通的类一样直接进行操作,例如:没有 gettersetter 方法,而是使用类似 C 语言风格的函数进行操作,这些函数的名字都是以对应的类名开头,例如:对 SecTrust 实例进行评估的函数 SecTrustEvaluateWithError(_:_:)

对服务器信任实例 serverTrust 进行评估需要用到的是 Certificate, Key, and Trust Services 部分

SecTrust

class SecTrust

用于评估信任的类,主要包含了:

  • 一个需要评估的证书,可能还有它所在的证书链中的中间证书和根证书(锚点证书)
  • 一个或者多个评估策略

注意:可以从 SecTrust 实例中获取证书和公钥,但前提是已经对它进行了评估并且评估通过。评估通过后,SecTrust 实例中会存在一条正确的证书链。

SecPolicy

class SecPolicy

评估策略,Security 框架提供了以下策略:

// 返回默认 X.509 策略的策略对象,只验证证书是否符合 X.509 标准
func SecPolicyCreateBasicX509() -> SecPolicy

// 返回用于评估 SSL 证书链的策略对象
// 第一个参数:server,如果传 true,则代表是在客户端上验证 SSL 服务器证书
// 第二个参数:hostname,如果传非 nil,则代表会验证 hostname
func SecPolicyCreateSSL(Bool, CFString?) -> SecPolicy

// 返回用于检查证书吊销的策略对象,通常不需要自己创建吊销策略,除非希望重写默认系统行为,例如强制使用特定方法或完全禁用吊销检查。
func SecPolicyCreateRevocation(CFOptionFlags) -> SecPolicy?

SecCertificate

class SecCertificate

X.509 标准证书类

SecIdentity

私钥和证书的组合

证书概念

还需要了解一些跟评估相关的证书概念

根证书

数字证书是由数字证书认证机构(Certificate authority,即 CA)来负责签发和管理。首先,CA 组织结构中,最顶层的就是根 CA,根 CA 下可以授权给多个二级 CA,而二级 CA 又可以授权多个三级 CA,所以 CA 的组织结构是一个树结构。根 CA 颁发的自签名证书就是根证书,一般操作系统中都嵌入了一些默认受信任的根证书。

证书链

由于证书是从根 CA 开始不断向下级授权签发的,所以证书链就是由某个证书和它各个上级证书组成的链条。一条完整的证书链是由最底层的证书开始,最终以根 CA 证书结束。但在这里的证书链指的是从某个需要评估的证书开始,最终以锚点证书结束,例如:需要评估的证书 - 中间证书 - 中间证书 - 锚点证书。

锚点证书

锚点证书通常是操作系统中嵌入的固有受信任的根证书之一。但在这里指的是评估 SecTrust 实例时,用于评估的证书链里面最顶层的证书,它可能是根证书,也可能是证书链中的某一个。评估时会在 SecTrustSetAnchorCertificates(_:_:) 函数指定的证书数组中查找锚点证书,或者使用系统提供的默认集合。

评估

challenge.protectionSpace.serverTrust 得到 SecTrust 实例,通过以下函数来评估它是否有效

// iOS 12 以下的系统使用这个函数
func SecTrustEvaluate(_ trust: SecTrust, 
                    _ result: UnsafeMutablePointer<SecTrustResultType>) -> OSStatus

// iOS 12 及以上的系统推荐使用这个函数
func SecTrustEvaluateWithError(_ trust: SecTrust, 
                             _ error: UnsafeMutablePointer<CFError?>?) -> Bool

评估的步骤如下:

  • 验证证书的签名。 SecTrust 实例中存在一个需要评估的证书,评估函数会根据评估策略创建对应的证书链,然后从需要评估的证书开始,直到锚点证书,依次验证证书链中各个证书的签名,如果中途某个证书不通过验证,或者某个证书已经设置了非默认信任设置(信任或者不信任),则会提前结束,返回一个成功或者失败的结果。
  • 根据评估策略评估证书。会根据策略来验证需要评估的证书中某些信息。

注意:

  • 苹果的官方文档多处指出调用评估函数会补全用于评估的证书链:
    • 评估函数会在用户的 keychain(或 iOS 中的应用程序 keychain )中搜索中间证书,还有可能通过网络来下载中间证书。
    • 评估函数会在由 SecTrustSetAnchorCertificates(_:_:) 函数指定的证书数组中查找锚点证书,或者使用系统提供的默认集合。
    • 因为评估函数可能会通过网络来下载中间证书,或者搜索证书扩展信息,所以不能在主线程执行这个函数,并且需要保证线程安全
  • 但是我测试后发现,实际上在设置评估策略时,就会补全证书链,并设置在 SecTrust 实例中,例如调用以下函数
    • SecTrustCreateWithCertificates(_:_:_:)
    • SecTrustSetPolicies(_:_:)

评估结果

if #available(iOS 12, macOS 10.14, tvOS 12, watchOS 5, *) {
    var error: CFError?
    let evaluationSucceeded = SecTrustEvaluateWithError(serverTrust, &error)
    if evaluationSucceeded {
        // 评估通过
    } else {
        // 评估不通过,error 包含了错误信息
    }
} else {
    var result = SecTrustResultType.invalid
    let status = SecTrustEvaluate(serverTrust, &result)
    if status == errSecSuccess && (result == .unspecified || result == .proceed) {
        // 评估通过
    } else {
        // 评估不通过,result 和 status 包含了错误信息
    }
}

SSL Pinning

使用某些抓包软件可以对网络请求进行抓包,就算是 HTTPS 的请求都可以抓包成功。但是同时也会发现某些 App 发送的网络请求会抓包失败。因为这些 App 内使用了一项叫 SSL Pinning 的技术。抓包软件对网络请求进行抓包主要是利用“中间人攻击”的技术,而 SSL Pinning 技术则是可以防止“中间人攻击”。

SSL Pinning 具体有两种做法:

  • Certificate Pinning:证书固定。将指定的证书集成在 App 里面,在进行服务器信任评估前,使用该证书作为锚点证书,再进行服务器信任评估,这样就可以限制了只有在指定的证书链上的证书才能通过评估,而且还可以限制只能是某些域名的证书。缺点是集成在 App 里面的证书会过期,如果证书过期,只能通过强制更新 App 才能保证正常进行网络访问。
  • Public Key Pinning:公钥固定。将指定的公钥集成在 App 里面,在进行服务器信任评估后,还会提取服务器返回的证书内的公钥,然后跟指定的公钥进行匹配。优点是公钥不像证书一样会过期。缺点是操作公钥会相对麻烦,而且违反了密钥轮换策略。

了解怎么手动进行服务器信任评估后,就可以轻松实现 SSL Pinning

关于 SSL Pinning 的选择,苹果的文档上有提出

Create a Long-Term Server Authentication Strategy

If you determine that you need to evaluate server trust manually in some or all cases, plan for what your app will do if you need to change your server credentials. Keep the following guidelines in mind:

  • Compare the server’s credentials against a public key, instead of storing a single certificate in your app bundle. This will allow you to reissue a certificate for the same key and update the server, rather than needing to update the app.
  • Compare the issuing certificate authority’s (CA’s) keys, rather than using the leaf key. This way, you can deploy certificates containing new keys signed by the same CA.
  • Use a set of keys or CAs, so you can rotate server credentials more gracefully.

简单来说就是推荐使用 Public Key Pinning ,而且是把多个比较高级别的 CA 公钥集成在 App 里面,这样服务器就可以在部署的证书的时候有更多的选择,更加的灵活。

代码分析

以上已经详细地介绍了 iOS Authentication Challenge 的处理,接下来结合 iOS 最著名的两个网络框架 AFNetworkingAlamofire ,了解实际场景中的应用,分析代码实现的细节。其中由于 Alamofire 是使用更先进的开发语言 -- Swift 实现的,处理会更加详细和先进,所以是分析的重点。

Alamofire

Alamofire 中的认证质询处理会更加具体详细、完善,同时使用了新的 API ,以下是 Alamofire 5.0.0-rc.3 版本中的代码。

主要涉及 SessionDelegate.swiftServerTrustManager.swift 两个文件

typealias ChallengeEvaluation = (disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?, error: AFError?)

// URLSessionDelegate 的方法
// 如果没有实现用于处理 session 范围内的认证质询的方法,会调用这个方法作为代替
// 所以为了避免重复,实际上只需要实现这个方法就可以处理所有情况
open func urlSession(_ session: URLSession,
                     task: URLSessionTask,
                     didReceive challenge: URLAuthenticationChallenge,
                     completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    eventMonitor?.urlSession(session, task: task, didReceive: challenge)

    let evaluation: ChallengeEvaluation
    // 判断认证质询,主要分为两种情况
    switch challenge.protectionSpace.authenticationMethod {
    case NSURLAuthenticationMethodServerTrust:
        // 服务器信任认证质询,也就是 HTTPS 证书认证
        evaluation = attemptServerTrustAuthentication(with: challenge)
    case NSURLAuthenticationMethodHTTPBasic, NSURLAuthenticationMethodHTTPDigest, NSURLAuthenticationMethodNTLM,
         NSURLAuthenticationMethodNegotiate, NSURLAuthenticationMethodClientCertificate:
        // 其他类型认证质询
        evaluation = attemptCredentialAuthentication(for: challenge, belongingTo: task)
    default:
        evaluation = (.performDefaultHandling, nil, nil)
    }
    // 如果存在错误,则通过回调告诉外界
    if let error = evaluation.error {
        stateProvider?.request(for: task)?.didFailTask(task, earlyWithError: error)
    }
    // 响应质询
    completionHandler(evaluation.disposition, evaluation.credential)
}

服务器信任认证

// 处理服务器信任认证质询的方法
func attemptServerTrustAuthentication(with challenge: URLAuthenticationChallenge) -> ChallengeEvaluation {
    let host = challenge.protectionSpace.host

    guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
        let trust = challenge.protectionSpace.serverTrust
    else {
        return (.performDefaultHandling, nil, nil)
    }

    do {
        // evaluator 是一个用于评估 serverTrust 的实例,它遵循了 ServerTrustEvaluating 协议
        guard let evaluator = try stateProvider?.serverTrustManager?.serverTrustEvaluator(forHost: host) else {
            return (.performDefaultHandling, nil, nil)
        }
        // 最终是调用 evaluator 的 valuate(_:forHost:) 方法来进行评估
        try evaluator.evaluate(trust, forHost: host)
        // 如果没有抛出错误,则创建 URLCredential 实例,告诉系统接受服务器的凭据
        return (.useCredential, URLCredential(trust: trust), nil)
    } catch {
        // 否则取消这次认证质询,同时返回一个 error
        return (.cancelAuthenticationChallenge, nil, error.asAFError(or: .serverTrustEvaluationFailed(reason: .customEvaluationFailed(error: error))))
    }
}

Alamofire 内部提供了几个遵循了 ServerTrustEvaluating 协议的类,用于不同的评估方式,方便开发者使用,分别是:

  • DefaultTrustEvaluator:默认的评估方式,使用SecPolicyCreateSSL(_:_:)策略进行评估,可以选择是否验证host
  • RevocationTrustEvaluator:在 DefaultTrustEvaluator 基础上,增加用检查证书撤销的策略进行评估
  • PinnedCertificatesTrustEvaluator:在 DefaultTrustEvaluator 基础上,增加 Certificate Pinning 检查
  • PublicKeysTrustEvaluator:在 DefaultTrustEvaluator 基础上,增加 Public Key Pinning 检查
  • CompositeTrustEvaluator:组合评估,使用多种处理方式进行评估
  • DisabledEvaluator:只用于开发调试的类,使用它进行评估永远会通过

可以看出这几种处理方式都是在默认的基础上,增加一些额外的评估,以下只分析PinnedCertificatesTrustEvaluator的做法,对其他类的做法有兴趣的读者可以自行阅读源码

// PinnedCertificatesTrustEvaluator 中的评估方法
public func evaluate(_ trust: SecTrust, forHost host: String) throws {
    guard !certificates.isEmpty else {
        throw AFError.serverTrustEvaluationFailed(reason: .noCertificatesFound)
    }

    // 因为进行了 Certificate Pinning,所以首先需要设置锚点证书
    if acceptSelfSignedCertificates {
        try trust.af.setAnchorCertificates(certificates)
    }
    
    // 进行默认的评估
    if performDefaultValidation {
        try trust.af.performDefaultValidation(forHost: host)
    }

    // 验证 host
    if validateHost {
        try trust.af.performValidation(forHost: host)
    }

    // 如果代码能运行到这里,代表评估通过;在手动设置了锚点证书后再使用评估函数并且通过了评估,此时从 SecTrust 实例取出的是一条包含了需要的评估证书直到锚点证书的证书链,将它们转为 Data 集合
    let serverCertificatesData = Set(trust.af.certificateData)
    // 将集成在 App 里的证书转为 Data 集合
    let pinnedCertificatesData = Set(certificates.af.data)
    // 判断两个集合是否有交集,这是为了进行一步加强安全性
    let pinnedCertificatesInServerData = !serverCertificatesData.isDisjoint(with: pinnedCertificatesData)
    if !pinnedCertificatesInServerData {
        // 否则抛出错误
        throw AFError.serverTrustEvaluationFailed(reason: .certificatePinningFailed(host: host, trust: trust, pinnedCertificates: certificates, serverCertificates: trust.af.certificates))
    }
}

如果 Alamofire 内置的几种评估方式不能满足开发者,则可以自定义遵守 ServerTrustEvaluating 协议的类,自行处理。

其他类型认证

// 处理其他类型认证质询的方法
func attemptCredentialAuthentication(for challenge: URLAuthenticationChallenge,
                                     belongingTo task: URLSessionTask) -> ChallengeEvaluation {
    // 之前有过失败,则返回
    guard challenge.previousFailureCount == 0 else {
        return (.rejectProtectionSpace, nil, nil)
    }
    // 这里是直接取出外界事先准备好的 URLCredential 实例
    guard let credential = stateProvider?.credential(for: task, in: challenge.protectionSpace) else {
        // 如果没有,则使用系统默认的处理
        return (.performDefaultHandling, nil, nil)
    }

    return (.useCredential, credential, nil)
}

Alamofire 对其他类型的认证质询的处理比较简单,因为这些类型的处理不确定性比较大,所以 Alamofire 直接把认证质询转移给外界的调用者进行处理

AFNetworking

AFNetworking 中的认证质询处理相对于 Alamofire 会没有那么全面,代码也简单很多,但是足以处理大多数的情况。以下是 AFNetworking 3.2.1 版本中的代码。

主要涉及 AFURLSessionManager.mAFSecurityPolicy.m 两个文件。

它里面实现了 URLSessionDelegate 两个用于处理认证质询的方法 ,两个方法里面的代码几乎是一样的,所以下面只选择其中一个来分析

// URLSessionDelegate 的方法
// 如果没有实现用于处理 session 范围内的认证质询的方法,会调用这个方法作为代替
// 所以为了避免重复,实际上只需要实现这个方法就可以处理所有情况
- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
 completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler
{
    NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    __block NSURLCredential *credential = nil;
    // taskDidReceiveAuthenticationChallenge 是一个外界传入的 block,这里的意思是如果外界有传入,则把认证质询转移给外界的调用者进行处理
    if (self.taskDidReceiveAuthenticationChallenge) {
        disposition = self.taskDidReceiveAuthenticationChallenge(session, task, challenge, &credential);
    } else {
        // 否则判断质询类型
        if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
            // 对服务器信任认证质询进行处理,也就是 HTTPS 证书认证
            if ([self.securityPolicy evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:challenge.protectionSpace.host]) {
                // 通过
                disposition = NSURLSessionAuthChallengeUseCredential;
                credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
            } else {
                // 否则取消认证质询
                disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
            }
        } else {
            // 如果是其他类型的认证质询,则使用系统默认的处理
            disposition = NSURLSessionAuthChallengePerformDefaultHandling;
        }
    }
    // 响应质询
    if (completionHandler) {
        completionHandler(disposition, credential);
    }

}

通过 AFSecurityPolicy 类里面的以下方法处理服务器信任认证质询

// 处理服务器信任认证质询的方法
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
                  forDomain:(NSString *)domain
{   
    // 这里的意思是如果需要验证 DomainName,又允许自签名的证书,可是又没有使用 SSL Pinning 或者没有提供证书,这样就会矛盾,所以直接返回 NO
    if (domain && self.allowInvalidCertificates && self.validatesDomainName && (self.SSLPinningMode == AFSSLPinningModeNone || [self.pinnedCertificates count] == 0)) {
        // https://developer.apple.com/library/mac/documentation/NetworkingInternet/Conceptual/NetworkingTopics/Articles/OverridingSSLChainValidationCorrectly.html
        //  According to the docs, you should only trust your provided certs for evaluation.
        //  Pinned certificates are added to the trust. Without pinned certificates,
        //  there is nothing to evaluate against.
        //
        //  From Apple Docs:
        //          "Do not implicitly trust self-signed certificates as anchors (kSecTrustOptionImplicitAnchors).
        //           Instead, add your own (self-signed) CA certificate to the list of trusted anchors."
        NSLog(@"In order to validate a domain name for self signed certificates, you MUST use pinning.");
        return NO;
    }

    NSMutableArray *policies = [NSMutableArray array];
    // 根据是否需要验证 DomainName(host) 来设置评估策略
    if (self.validatesDomainName) {
        [policies addObject:(__bridge_transfer id)SecPolicyCreateSSL(true, (__bridge CFStringRef)domain)];
    } else {
        [policies addObject:(__bridge_transfer id)SecPolicyCreateBasicX509()];
    }

    SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef)policies);
    
    if (self.SSLPinningMode == AFSSLPinningModeNone) {
        // 在没有使用 SSL Pinning 的情况下,如果 allowInvalidCertificates 为 YES,表示不对证书进行评估,可以直接通过,否则需要对证书进行评估
        return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
    } else if (!AFServerTrustIsValid(serverTrust) && !self.allowInvalidCertificates) {
        // 在使用 SSL Pinning 的情况下,如果证书评估不通过,而且不允许自签名证书,则直接返回 NO
        return NO;
    }

    switch (self.SSLPinningMode) {
        case AFSSLPinningModeCertificate: {
            NSMutableArray *pinnedCertificates = [NSMutableArray array];
            for (NSData *certificateData in self.pinnedCertificates) {
                [pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)];
            }
            // 因为进行了 Certificate Pinning,所以首先需要设置锚点证书
            SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates);
            // 进行评估
            if (!AFServerTrustIsValid(serverTrust)) {
                return NO;
            }

            // obtain the chain after being validated, which *should* contain the pinned certificate in the last position (if it's the Root CA)
            NSArray *serverCertificates = AFCertificateTrustChainForServerTrust(serverTrust);
            // 判断两个数组是否有交集,这是为了进行一步加强安全性
            for (NSData *trustChainCertificate in [serverCertificates reverseObjectEnumerator]) {
                if ([self.pinnedCertificates containsObject:trustChainCertificate]) {
                    return YES;
                }
            }
            
            return NO;
        }
        case AFSSLPinningModePublicKey: {
            NSUInteger trustedPublicKeyCount = 0;
            // 对 serverTrust 里面证书链的证书逐个进行评估,并且返回评估通过的证书对应的公钥数组
            NSArray *publicKeys = AFPublicKeyTrustChainForServerTrust(serverTrust);

            for (id trustChainPublicKey in publicKeys) {
                for (id pinnedPublicKey in self.pinnedPublicKeys) {
                    // 判断两个数组是否有交集,这是为了进行一步加强安全性
                    if (AFSecKeyIsEqualToKey((__bridge SecKeyRef)trustChainPublicKey, (__bridge SecKeyRef)pinnedPublicKey)) {
                        trustedPublicKeyCount += 1;
                    }
                }
            }
            return trustedPublicKeyCount > 0;
        }
            
        default:
            return NO;
    }
    
    return NO;
}

Public Key Pinning 的评估方法

static NSArray * AFPublicKeyTrustChainForServerTrust(SecTrustRef serverTrust) {
    // 使用 X.509 策略
    SecPolicyRef policy = SecPolicyCreateBasicX509();
    CFIndex certificateCount = SecTrustGetCertificateCount(serverTrust);
    NSMutableArray *trustChain = [NSMutableArray arrayWithCapacity:(NSUInteger)certificateCount];
    for (CFIndex i = 0; i < certificateCount; i++) {
        // 从 serverTrust 取出证书
        SecCertificateRef certificate = SecTrustGetCertificateAtIndex(serverTrust, i);

        SecCertificateRef someCertificates[] = {certificate};
        CFArrayRef certificates = CFArrayCreate(NULL, (const void **)someCertificates, 1, NULL);

        SecTrustRef trust;
        // 创建新的 SecTrustRef 实例
        __Require_noErr_Quiet(SecTrustCreateWithCertificates(certificates, policy, &trust), _out);

        SecTrustResultType result;
        // 对新的 SecTrustRef 实例进行评估
        __Require_noErr_Quiet(SecTrustEvaluate(trust, &result), _out);
        
        // 如果评估通过,则取出公钥,加入到 trustChain 数组,最后返回
        [trustChain addObject:(__bridge_transfer id)SecTrustCopyPublicKey(trust)];

    _out:
        if (trust) {
            CFRelease(trust);
        }

        if (certificates) {
            CFRelease(certificates);
        }

        continue;
    }
    CFRelease(policy);

    return [NSArray arrayWithArray:trustChain];
}

对比

AFNetworkingAlamofire 对认证质询的处理跟本文前面部分所介绍的内容基本一致。从它们的源码来看,我个人觉得虽然 AFNetworking 的代码比较简单,但是逻辑有点混乱,并且由于存在 allowInvalidCertificates 的判断,所以逻辑就更复杂了,而 Alamofire 的代码更加细致,处理的方式更全面,逻辑也很清晰。造成这样的结果可能是因为 AFNetworking 太久没有更新,而 Alamofire 却一直在更新。所以如果有需要的话,我更推荐参考 Alamofire 的代码。

结语

认证质询(Authentication Challenge)是进行安全的网络通信中重要的一环。在开发中,我们一般会使用 AFNetworking 或者 Alamofire 搭建 App 的网络层,大多数情况下直接使用它们自带的功能对认证质询进行处理已经足够。但如果存在特殊情况,还是需要我们对这方面进行深入的了解,进行自定义的处理。本文尽量全面地对认证质询相关的知识进行介绍,但由于涉及到苹果的 Security 框架,相关 API 的使用说明比较分散,也存在比较多的细节,所以我没有对它们全部进行介绍,有需要的读者可以仔细阅读苹果官方文档,参考 Alamofire 的代码进行使用。

参考

Handling an Authentication Challenge

Certificate, Key, and Trust Services

HTTPS Server Trust Evaluation

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,681评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,710评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,623评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,202评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,232评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,368评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,795评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,461评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,647评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,476评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,525评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,226评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,785评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,857评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,090评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,647评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,215评论 2 341

推荐阅读更多精彩内容