這一篇主要介紹使用AFN如何訪問HTTPS網站以及這些做法的實現原理,還有介紹AFN的網路狀態監測部分AFNetworkReachabilityManager,這個模塊會和蘋果官方發Reachability框架做一個對比。 本文所有的代碼都運行在iOS9.2的模擬器上,並且在info.plist對A
這一篇主要介紹使用AFN如何訪問HTTPS網站以及這些做法的實現原理,還有介紹AFN的網路狀態監測部分AFNetworkReachabilityManager,這個模塊會和蘋果官方推薦的Reachability框架做一個對比。
本文所有的代碼都運行在iOS9.2的模擬器上,並且在info.plist對ATS做了適配:設置允許非法的載入Allow Arbitrary Loads為YES。
不要認為在info.plist添加NSAppTransportSecurity
> NSAllowsArbitraryLoads
為YES
就以為弄懂iOS9網路適配了,有關具體細節問題請看南峰子的這篇文章App Transport Security(ATS)
介於iOS有關HTTPS訪問的認證過程代碼並不是特別經常使用,本文會用大量的篇幅介紹HTTPS認證的過程,並會通過系統的NSURLSession完成一些認證相關的代碼,畢竟AFN就是使用了這些代碼來實現對HTTPS網站的訪問支持的。
HTTPS網站訪問過程中,瀏覽器幫你做了什麼
不同於普通的HTTP請求,當訪問一個HTTPS的網站時,瀏覽器會幫我們很多隱藏的工作,這其實是SSL通道建立的三次握手過程:
1.發起請求。
首先當輸入完https網址敲擊回車之後,瀏覽器首先向伺服器發送一個需要訪問的請求,這個請求中包含著瀏覽器SSL 協議的版本號,加密演算法的種類,產生的隨機數,以及其他伺服器和客戶端之間通訊所需要的各種信息。
2.服務端返回證書。
伺服器向客戶端傳送SSL 協議的版本號,加密演算法的種類,隨機數以及其他相關信息,同時伺服器還將向客戶端傳送自己的證書,這些信息被保存在客戶端被稱作'被保護空間'的地方。這裡最關鍵的就是證書信息。
3.瀏覽器驗證證書信息。
瀏覽器利用伺服器傳過來的信息驗證伺服器的合法性,伺服器的合法性包括:證書是否過期,發行伺服器證書的CA 是否可靠,發行者證書的公鑰能否正確解開伺服器證書的“發行者的數字簽名”,伺服器證書上的功能變數名稱是否和伺服器的實際功能變數名稱相匹配。
如果合法性驗證沒有通過,通訊將斷開;如果合法性驗證通過,將繼續進行第四步。
4.客戶端向伺服器發送“預主密碼”。
瀏覽器隨機產生一個用於後面通訊的“對稱密碼”,然後用伺服器的公鑰(伺服器的公鑰從步驟②中的伺服器的證書中獲得)對其加密,然後將加密後的“預主密碼”傳給伺服器。
4.1.如果伺服器要求客戶的身份認證(在握手過程中為可選),用戶不光要傳給伺服器“預主密碼”,還需建立一個隨機數然後對其進行數據簽名,將這個含有簽名的隨機數和客戶自己的證書也傳給伺服器。
4.2.如果不需要,則只將“預主密碼”傳給伺服器,並直接進行第6步。
5.服務端身份驗證(需要才進行)。
如果伺服器要求客戶的身份認證,伺服器必須檢驗客戶證書和簽名隨機數的合法性,具體的合法性驗證過程包括:客戶的證書使用日期是否有效,為客戶提供證書的CA 是否可靠,發行CA 的公鑰能否正確解開客戶證書的發行CA 的數字簽名,檢查客戶的證書是否在證書廢止列表(CRL)中。
檢驗如果沒有通過,通訊立刻中斷;
如果驗證通過,進行下一步。
6.瀏覽器、服務端各自生成通話密碼。
伺服器將用自己的私鑰解開加密的“預主密碼”,然後執行一系列步驟來產生主通訊密碼(客戶端也將通過同樣的方法產生相同的主通訊密碼)。
7.約定通話密碼。
伺服器和客戶端用相同的主通訊密碼即“通話密碼”,一個對稱密鑰用於SSL 協議的安全數據通訊的加解密通訊。同時在SSL 通訊過程中還要完成數據通訊的完整性,防止數據通訊中的任何變化。
8.瀏覽器通知伺服器已準備就緒。
客戶端向伺服器端發出信息,指明後面的數據通訊將使用的步驟⑦中的主密碼為對稱密鑰,同時通知伺服器客戶端的握手過程結束。
9.服務端通知瀏覽器已準備就緒。
伺服器向客戶端發出信息,指明後面的數據通訊將使用的步驟⑦中的主密碼為對稱密鑰,同時通知客戶端伺服器端的握手過程結束。
10.開始數據通訊。
SSL 的握手部分結束,SSL安全通道建立完成,開始進行數據通訊開始,通訊過程中客戶和伺服器開始使用相同的對稱密鑰。
如果以https://www.baidu.com為例,這時候已經表現為baidu的主頁打開了,但是SSL加密通道在下次請求的時候不用再次建立。
對於訪問的過程中,通常會在第3步出現問題,以12306的購票頁面為例:
當進行到第3步的時候,瀏覽器驗證為:發行伺服器證書的CA是不可靠的,可以在Chrome的地址欄中點擊被打了紅叉的鎖來查看這個頁面的證書頒發機構,
我們可以搜索到這個命名為'SRCA'的機構實際上是‘中鐵認證中心’也就是12306自己的認證系統,它是用了自己的認證系統給自己頒發了一個SSL加密證書,而Chrome怎麼會認可它呢。順便看了一下百度的證書:
這是一個由美國Symantec Trust Network組織頒發的證書,是一個比較權威的證書頒發機構,幾乎在所有的瀏覽器中都是認可的。而baidu使用的證書是這個機構的根證書的子證書,而之所以瀏覽器能認可它,是因為根證書通過webtrust國際認證,並已經內置到各大瀏覽器如谷歌,火狐,微軟等系統中。
那麼這畢竟只是瀏覽器預設的一種認證方式,畢竟我們還是需要訪問12306的,這裡就要改變一下第3步驗證的結果,在瀏覽器中,我們可以手動選擇信任,然後繼續向下進行。
這樣就能訪問這些網站了。
使用系統的NSURLSession模擬瀏覽器完成HTTPS的證書認證
與瀏覽器的驗證過程相似,iOS的HTTPS驗證過程也要走類似的步驟,不過不用擔心的是,很多過程我們也不需要處理,只需要處理好第3步就行了,當我們進行訪問一個HTTPS網站時,當走到第二步的時候,也就是伺服器返回證書時,需要我們在本地自己完成證書信任的過程,如果使用session創建的task進行網路訪問,這時候就會進入到- URLSession:didReceiveChallenge:completionHandler:
這個代理方法中,這時候已經完成了HTTPS訪問的第二步,session會讓我們在這個方法中完成第3步的過程。這個方法的參數有如下的解釋:
參數 | 解釋 |
---|---|
challenge | 一個包含了授權請求的對象 |
completionHandler | 你的代理方法一定會調用的一個handler. 它的參數是 disposition—描述challenge如何被處理的幾個常量中的一個 credential—如果disposition是NSURLSessionAuthChallengeUseCredential,credential是授權驗證時會被使用到的憑據,其他情況為NULL. |
challenge參數需要另外說明的是challenge
是一個NSURLAuthenticationChallenge
對象,代表著進行https請求進行時,服務端發送過來的質詢,當接收到質詢之後就要開始進行客戶端的驗證了。
這個對象中最重要的屬性就是protectionSpace
它代表著對需要驗證的受保護空間的驗證,是一個NSURLProtectionSpace
類型的對象。NSURLProtectionSpace對象包含請求的主機host、埠號port、代理類型proxyType、使用的協議protocol、服務端要求客戶端對其驗證的方法authenticationMethod等重要的信息,還有代表著伺服器SSL傳輸狀態的SecTrustRef
類型的屬性serverTrust,不過當且僅當authenticationMethod為NSURLAuthenticationMethodServerTrust這個屬性值才不為Nil.
這裡還要說明一下服務端指定的驗證方法的類型,驗證方法的類型有很多種,這裡不再一一列舉,我們通常會見到這樣幾種類型:
NSURLAuthenticationMethodHTTPBasic
NSURLAuthenticationMethodHTTPDigest
NSURLAuthenticationMethodNTLM
NSURLAuthenticationMethodClientCertificate
NSURLAuthenticationMethodServerTrust
其中HTTP Basic、HTTP Digest與NTLM認證都是基於用戶名/密碼的認證,ClientCertificate(客戶端證書)認證要求從客戶端上傳證書。客戶端需要按照服務端指定的認證方法進行認證,否則可能會按照錯誤處理。例如使用HTTP Basic方式,客戶端需要將用戶名和密碼信息放到憑據中,然後傳遞給服務端;如果使用的是ServerTrust方式,那麼客戶端就要將信任的憑據發給服務端。
一般在HTTPS訪問的第3步過程中,服務端要求的認證方法幾乎總是ServerTrust方式。有遇到過一些網路代理工具使用HTTP Digest的驗證方式,在瀏覽器端進行訪問的時候就彈出一個要求輸入賬號和密碼的彈窗。
對於completionHandler參數是一個最終處理憑據的回調,要求在創建好包含驗證信息的憑據之後必須調用,這樣才會將驗證的信息發送給服務端,也就意味著第3步的完成,開始進行第4步。
它的第一個參數是處理的選項,是一個枚舉類型:
typedef NS_ENUM(NSInteger, NSURLSessionAuthChallengeDisposition) {
NSURLSessionAuthChallengeUseCredential = 0, // 使用伺服器發回的憑據,不過可能為空
NSURLSessionAuthChallengePerformDefaultHandling = 1, // 預設的處理方法,憑據參數會被忽略
NSURLSessionAuthChallengeCancelAuthenticationChallenge = 2, //取消整個請求,忽略憑據參數
NSURLSessionAuthChallengeRejectProtectionSpace = 3, // 這次質詢被拒絕,下次再試 ,憑據參數被忽略
} NS_ENUM_AVAILABLE(NSURLSESSION_AVAILABLE, 7_0);
理清上面的思路之後,我們可以試一試使用系統的session訪問HTTPS網站了:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSURL *url = [NSURL URLWithString:@"https://www.baidu.com"];
[[self.session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
NSLog(@"%@", error);
return ;
}
NSLog(@"%@", response);
}] resume];
}
#pragma mark - NSURLSessionDelegate
- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler {
// 判斷伺服器的身份驗證的方法是否是:ServerTrust方式
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
// 創建一個新憑據,這個憑據指定了'握手'是被信任的
NSURLCredential *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
if (credential != nil) {
// 完成'處置',將信任憑據發給服務端
completionHandler(NSURLSessionAuthChallengeUseCredential, credential);
}
// 如果credential == nil 以下回調會自動完成
// completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, credential);
}
}
因為我們使用的是使用第2步中服務端傳回來的證書,所以即使是對付https://kyfw.12306.cn/otn/leftTicket/init這樣的流氓頁面也同樣是可以的。但是對於iOS9來說並不是這樣,必須設置了Allow Arbitrary Loads為YES才會達到預期效果。
對於AFN,無論實在iOS9之前還是iOS9之後,當訪問https://kyfw.12306.cn/otn/leftTicket/這個頁面的時候都會走不通,這是因為AFN對於自簽名的HTTPS網站有著特殊的驗證(有關驗證細節,請看本文下一部分),必須證書提前導入到項目中,將Chrome中的證書導入到項目中,請參見下圖:
將生成的證書文件kyfw.12306.cn.cer
加入到xcode項目中,使用AFN按照如下方式調用即可:
NSString *cerPath = [[NSBundle mainBundle] pathForResource:@"kyfw.12306.cn.cer" ofType:nil];
NSData *cerData = [NSData dataWithContentsOfFile:cerPath];
NSSet *set = [[NSSet alloc] initWithObjects:cerData, nil];
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
manager.responseSerializer = [AFHTTPResponseSerializer serializer];
manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate withPinnedCertificates:set];
manager.securityPolicy.allowInvalidCertificates = YES;
[manager GET:@"https://kyfw.12306.cn/otn/leftTicket/init" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"%@", [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding]);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
NSLog(@"%@",error);
}];
這樣便能正確的訪問自簽名的網站了。
AFN實現HTTPS訪問的細節
說了那麼多如何使用代碼訪問HTTPS網站,那麼AFN是如何實現的呢,AFURLSessionManager中實現了- URLSession:didReceiveChallenge:completionHandler:
代理方法:
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler
{
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
__block NSURLCredential *credential = nil;
if (self.taskDidReceiveAuthenticationChallenge) {
disposition = self.taskDidReceiveAuthenticationChallenge(session, task, challenge, &credential);
} else {
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
if ([self.securityPolicy evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:challenge.protectionSpace.host]) {
disposition = NSURLSessionAuthChallengeUseCredential;
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
} else {
disposition = NSURLSessionAuthChallengeRejectProtectionSpace;
}
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
}
if (completionHandler) {
completionHandler(disposition, credential);
}
}
它的思路上這樣的
如果主動通過manger的setTaskDidReceiveAuthenticationChallengeBlock:
方法傳遞了taskDidReceiveAuthenticationChallenge的值那麼,會按照傳入的block處理這次質詢,
如果沒有傳入就走AFN處理方式(else分支):
如果驗證方法為ServerTrust就會使用securityPolicy屬性的方法針對host評判serverTrust的合法性,如果成功了就會使用服務端傳來的證書進行處理,失敗了則會拒絕本次質詢。
如果驗證方法不是ServerTrust,則使用預設的處理方式(NSURLSessionAuthChallengePerformDefaultHandling)處理。
那麼,可以看出,這裡最關鍵的就是評判合法性的過程了,我們重點來看一下。評判合法性的方法被定義在AFSecurity類中,是這個類唯一的對象方法:
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust
forDomain:(NSString *)domain
{
if (domain && self.allowInvalidCertificates && self.validatesDomainName && (self.SSLPinningMode == AFSSLPinningModeNone || [self.pinnedCertificates count] == 0)) {
NSLog(@"In order to validate a domain name for self signed certificates, you MUST use pinning.");
return NO;
}
NSMutableArray *policies = [NSMutableArray array];
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) {
return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
} else if (!AFServerTrustIsValid(serverTrust) && !self.allowInvalidCertificates) {
return NO;
}
switch (self.SSLPinningMode) {
case AFSSLPinningModeNone:
default:
return NO;
case AFSSLPinningModeCertificate: {
NSMutableArray *pinnedCertificates = [NSMutableArray array];
for (NSData *certificateData in self.pinnedCertificates) {
[pinnedCertificates addObject:(__bridge_transfer id)SecCertificateCreateWithData(NULL, (__bridge CFDataRef)certificateData)];
}
SecTrustSetAnchorCertificates(serverTrust, (__bridge CFArrayRef)pinnedCertificates);
if (!AFServerTrustIsValid(serverTrust)) {
return NO;
}
for (NSData *trustChainCertificate in [serverCertificates reverseObjectEnumerator]) {
if ([self.pinnedCertificates containsObject:trustChainCertificate]) {
return YES;
}
}
return NO;
}
case AFSSLPinningModePublicKey: {
NSUInteger trustedPublicKeyCount = 0;
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;
}
}
return NO;
}
這段長度為60行的代碼實現了這樣的過程:
第一個if分支是對自簽名訪問設立條件:
domain不存在,或者
不允許無效證書,或者
不需要驗證功能變數名稱,或者
SSLPinningMode不是AFSSLPinningModeNone,而且必須上傳了證書文件。如果是走了這個分支,就要求如果想要實現自簽名的HTTPS訪問成功,必須設置pinnedCertificates,且不能使用defaultPolicy,因為不能SSLPinningMode屬性是readonly的,而defaultPolicy在創建的時候已經設置SSLPinningMode屬性為AFSSLPinningModeNone。(我們剛纔的實現方案就是在這條分支下完成的)
接下來是這樣一塊代碼:
NSMutableArray *policies = [NSMutableArray array];
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) {
return self.allowInvalidCertificates || AFServerTrustIsValid(serverTrust);
} else if (!AFServerTrustIsValid(serverTrust) && !self.allowInvalidCertificates) {
return NO;
}
它完成的工作是:
先用policies數組組裝驗證策略,在通過SecTrustSetPolicies函數給serverTrust設置驗證策略,不過AFN並沒有接收函數的返回值,查看是否設置成功,不知道是為什麼。
當SSLPinningMode為AFSSLPinningModeNone時,如果允許無效的證書(allowInvalidCertificates = YES)直接返回評測成功,如果不允許,按照剛纔的驗證策略驗證,返回的是驗證的結果。
當SSLPinningMode不是AFSSLPinningModeNone時,如果既沒有驗證成功又不允許無效證書,則直接返回評測失敗。
(這裡讓我想到了另一種訪問12306實現的方案:
manager.securityPolicy.validatesDomainName = NO;
manager.securityPolicy.allowInvalidCertificates = YES;
既不用使用證書,也不用自己創建securityPolicy。
)
接下來看一下那個長長的switch:
如果self.SSLPinningMode是AFSSLPinningModeCertificate:取出self.pinnedCertificates中的所有證書,通過SecTrustSetAnchorCertificates函數設置證書驗證策略,失敗則直接返回評測失敗,否則檢查本地的證書是否包含服務端的證書
,如果是返回評測成功,否則返回評測失敗。
如果self.SSLPinningMode是AFSSLPinningModePublicKey:取出服務端證書的所有公鑰,和self.pinnedPublicKeys中所有公鑰,遍歷檢查有沒有相等的兩項,有則返回評測成功。我嘗試給securityPolicy的pinnedPublicKeys賦值一個公鑰集合,但是它並沒有對外提供介面,self.pinnedPublicKeys是一個私有屬性,並且是計算型的,是從本地的證書self.pinnedCertificates中提取出來的。
有關AFSecurityPolicy最核心的部分基本上將完了,最後我們還是要總結一下,訪問可惡的12306的兩種方法:
// 方式一 兩句就可以
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
manager.responseSerializer = [AFHTTPResponseSerializer serializer];
manager.securityPolicy.validatesDomainName = NO; // 關鍵語句1
manager.securityPolicy.allowInvalidCertificates = YES; // 關鍵語句2
[manager GET:@"https://kyfw.12306.cn/otn/leftTicket/init" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"%@", responseObject);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
}];
// 方式二 需要將證書導入到項目中
// 準備:將證書的二進位讀取,放入set中
NSString *cerPath = [[NSBundle mainBundle] pathForResource:@"kyfw.12306.cn.cer" ofType:nil];
NSData *cerData = [NSData dataWithContentsOfFile:cerPath];
NSSet *set = [[NSSet alloc] initWithObjects:cerData, nil];
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
manager.responseSerializer = [AFHTTPResponseSerializer serializer];
manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate withPinnedCertificates:set]; // 關鍵語句1
manager.securityPolicy.allowInvalidCertificates = YES; // 關鍵語句2
[manager GET:@"https://kyfw.12306.cn/otn/leftTicket/init" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
NSLog(@"%@", responseObject);
} failure:^(NSURLSessionDataTask *task, NSError *error) {
}];
AFN的AFNetworkReachabilityManager和Reachability
有關AFNetworkReachabilityManager使用比較簡單,不做太多的解釋,只是羅列一些註意點。
AFN開啟必須開啟監控之後才能獲取到新的網路狀態,如果不開啟各種網路狀態都為不可到達,例如
AFNetworkReachabilityManager *reachabilityManager = [AFNetworkReachabilityManager sharedManager];
NSLog(@"%zd", reachabilityManager.isReachableViaWiFi); // 始終是0
NSLog(@"%zd", reachabilityManager.isReachable);
NSLog(@"%zd", reachabilityManager.isReachableViaWWAN);
即使開啟了網路監控,也無法再第一時間獲取到網路狀態,例如下麵的代碼執行之後,第一時間查看各種狀態依然不可達,這是因為它會在網路狀況改變時,非同步改變單例中存儲的狀態。
AFNetworkReachabilityManager *reachabilityManager = [AFNetworkReachabilityManager sharedManager];
[reachabilityManager startMonitoring]; // 從開啟監控 到得到下列值需要一定的時間
NSLog(@"%zd", reachabilityManager.isReachableViaWiFi); // 立刻調用為0 ,過一段時間後準確
NSLog(@"%zd", reachabilityManager.isReachable); // 立刻調用為0 ,過一段時間後準確
NSLog(@"%zd", reachabilityManager.isReachableViaWWAN); // 立刻調用為0 ,過一段時間後準確
其實我使用較多的還是Reachability框架,
Reachability具有獲取實時網路狀態的-currentReachabilityStatus
方法,不需要開啟監控,只要用實例調用即可。
Reachability同樣可以進行網路狀態改變的監控,可以用-startNotifier
方法開啟,但是沒法傳入回調。但是每當網路狀態改變的時候會發送一個kReachabilityChangedNotification
通知,可以接收這個通知完成回調。