iOS端WebView " IP直连 " 如何处理 Cookie

本文主要介绍防DNS污染方案在WebView场景下所遇到的一些问题及解决方案。

重要

当前最佳实践文档只针对结合使用时,如何使用HTTPDNS解析出的IP,关于HTTPDNS本身的解析服务,请先查看iOS SDK 开发手册

WKWebView无法使用NSURLProtocol拦截请求

针对该问题方案如下: 

  • 换用UIWebView 

  • 换用UIWebView方案不做赘述,说明下使用私有API进行注册拦截的方法 :

      // 注册自己的 protocol
      [NSURLProtocol registerClass:[CustomProtocol class]];
      // 创建 WKWebview
      WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
      WKWebView * wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0,   [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height) configuration:config];
      [wkWebView loadRequest:webViewReq];
      [self.view addSubview:wkWebView];
      //注册 scheme
      Class cls = NSClassFromString(@"WKBrowsingContextController");
      SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
      if ([cls respondsToSelector:sel]) {
          // 通过 http 和 https 的请求,同理可通过其他的 Scheme 但是要满足 ULR Loading System
          [cls performSelector:sel withObject:@"http"];
          [cls performSelector:sel withObject:@"https"];
      }

    使用私有API的另一风险是兼容性问题,比如上面的 browsingContextController就只能在iOS 8.4以后才能用,反注册scheme的方法 unregisterSchemeForCustomProtocol :也是在iOS 8.4 以后才被添加进来的,要支持iOS 8.0 ~ 8.3机型的话,只能通过动态生成字符串的方式拿到 WKBrowsingContextController,而且还不能反注册,不过这些问题都不大。至于向后兼容,这个也不用太担心,因为iOS发布新版本之前都会有开发者预览版的,那个时候再测一下也不迟。对于本文的例子来说,如果将来哪个iOS版本移除了这个API,那很可能是因为官方提供了完整的解决方案,到那时候自然也不需要本文介绍的方法了 。

    重要

    避免执行太晚,如果在 - (void)viewDidLoad中注册,可能会因为注册太晚,引发问题。建议在 +load方法中执行 。然后同样会遇到iOS端HTTPS(含SNI)业务场景:IP直连方案说明里提到的各种NSURLProtocol相关的问题,可以参照里面的方法解决。

WebView中的Cookie处理业务场景“IP直连”方案说明

本章节将讨论类似这样的问题:

  • WKWebView对于Cookie的管理一直是它的短板,那么iOS11是否有改进,如果有,如何利用这样的改进?

  • 采用IP直连方案后,服务端返回的Cookie里的Domain字段也会使用IP。如果IP是动态的,就有可能导致一些问题:由于许多H5业务都依赖于Cookie作登录态校验 ,而WKWebView上请求不会自动携带 Cookie。

WKWebView使用NSURLProtocol拦截请求无法获取Cookie信息

iOS 11推出了新的APIWKHTTPCookieStore可以用来拦截WKWebView的Cookie信息。

用法示例如下:

   WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
   // get cookies
    [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
        NSLog(@"All cookies %@",cookies);
    }];

    // set cookie
    NSMutableDictionary *dict = [NSMutableDictionary dictionary];
    dict[NSHTTPCookieName] = @"userid";
    dict[NSHTTPCookieValue] = @"123";
    dict[NSHTTPCookieDomain] = @"xxxx.com";
    dict[NSHTTPCookiePath] = @"/";

    NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:dict];
    [cookieStroe setCookie:cookie completionHandler:^{
        NSLog(@"set cookie");
    }];

    // delete cookie
    [cookieStroe deleteCookie:cookie completionHandler:^{
        NSLog(@"delete cookie");
    }];

利用iOS 11 API WKHTTPCookieStore解决WKWebView首次请求不携带Cookie的问题

  • 问题说明:由于许多H5业务都依赖于Cookie作登录态校验,而WKWebView上请求不会自动携带 Cookie。比如,如果你在Native层面做了登录操作,获取了Cookie信息,也使用 NSHTTPCookieStorage存到了本地,但是使用WKWebView打开对应网页时,网页依然处于未登录状态。如果是登录也在WebView里做的,就不会有这个问题。

  • iOS 11的API可以解决该问题,只要是存在WKHTTPCookieStore里的cookie,WKWebView每次请求都会携带,存在NSHTTPCookieStorage的cookie,并不会每次都携带。于是会发生首次WKWebView 请求不携带Cookie的问题。

  • 解决方法:

    在执行 -[WKWebView loadReques:]前将 NSHTTPCookieStorage中的内容复制到 WKHTTPCookieStore中,以此来达到 WKWebView Cookie 注入的目的。示例代码如下:

        [self copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:^{
                NSURL *url = [NSURL URLWithString:@"https://www.v2ex.com"];
                NSURLRequest *request = [NSURLRequest requestWithURL:url];
                [_webView loadRequest:request];
            }];

    - (void)copyNSHTTPCookieStorageToWKHTTPCookieStoreWithCompletionHandler:(nullable void (^)())theCompletionHandler; {
        NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
        WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
        if (cookies.count == 0) {
            !theCompletionHandler ?: theCompletionHandler();
            return;
        }
        for (NSHTTPCookie *cookie in cookies) {
            [cookieStroe setCookie:cookie completionHandler:^{
                if ([[cookies lastObject] isEqual:cookie]) {
                    !theCompletionHandler ?: theCompletionHandler();
                    return;
                }
            }];
        }
    }

    这个是iOS 11的API ,针对iOS 11之前的系统 ,需要另外处理。

利用 iOS 11之前的API解决WKWebView首次请求不携带Cookie的问题

通过让所有WKWebView共享同一个WKProcessPool实例,可以实现多个WKWebView之间共享Cookie(session Cookie and persistent Cookie)数据。不过WKWebView WKProcessPool实例在app杀进程重启后会被重置,导致WKProcessPool中的Cookie、session Cookie数据丢失,目前也无法实现 WKProcessPool实例本地化保存。可以采取cookie放入Header的方法来做。

 WKWebView * webView = [WKWebView new]; 
 NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; 
 [request addValue:@"skey=skeyValue" forHTTPHeaderField:@"Cookie"]; 
 [webView loadRequest:request];

其中对于skey=skeyValue这个cookie值的获取,也可以统一通过domain获取,获取的方法,可以参照下面的工具类:

HTTPDNSCookieManager.h

#ifndef HTTPDNSCookieManager_h
#define HTTPDNSCookieManager_h

// URL匹配Cookie规则
typedef BOOL (^HTTPDNSCookieFilter)(NSHTTPCookie *, NSURL *);

@interface HTTPDNSCookieManager : NSObject

+ (instancetype)sharedInstance;

/**
 指定URL匹配Cookie策略

 @param filter 匹配器
 */
- (void)setCookieFilter:(HTTPDNSCookieFilter)filter;

/**
 处理HTTP Response携带的Cookie并存储

 @param headerFields HTTP Header Fields
 @param URL 根据匹配策略获取查找URL关联的Cookie
 @return 返回添加到存储的Cookie
 */
- (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL;

/**
 匹配本地Cookie存储,获取对应URL的request cookie字符串

 @param URL 根据匹配策略指定查找URL关联的Cookie
 @return 返回对应URL的request Cookie字符串
 */
- (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL;

/**
 删除存储cookie

 @param URL 根据匹配策略查找URL关联的cookie
 @return 返回成功删除cookie数
 */
- (NSInteger)deleteCookieForURL:(NSURL *)URL;

@end

#endif /* HTTPDNSCookieManager_h */

HTTPDNSCookieManager.m
#import <Foundation/Foundation.h>
#import "HTTPDNSCookieManager.h"

@implementation HTTPDNSCookieManager
{
    HTTPDNSCookieFilter cookieFilter;
}

- (instancetype)init {
    if (self = [super init]) {
        /**
            此处设置的Cookie和URL匹配策略比较简单,检查URL.host是否包含Cookie的domain字段
            通过调用setCookieFilter接口设定Cookie匹配策略,
            比如可以设定Cookie的domain字段和URL.host的后缀匹配 | URL是否符合Cookie的path设定
            细节匹配规则可参考RFC 2965 3.3节
         */
        cookieFilter = ^BOOL(NSHTTPCookie *cookie, NSURL *URL) {
            if ([URL.host containsString:cookie.domain]) {
                return YES;
            }
            return NO;
        };
    }
    return self;
}

+ (instancetype)sharedInstance {
    static id singletonInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (!singletonInstance) {
            singletonInstance = [[super allocWithZone:NULL] init];
        }
    });
    return singletonInstance;
}

+ (id)allocWithZone:(struct _NSZone *)zone {
    return [self sharedInstance];
}

- (id)copyWithZone:(struct _NSZone *)zone {
    return self;
}

- (void)setCookieFilter:(HTTPDNSCookieFilter)filter {
    if (filter != nil) {
        cookieFilter = filter;
    }
}

- (NSArray<NSHTTPCookie *> *)handleHeaderFields:(NSDictionary *)headerFields forURL:(NSURL *)URL {
    NSArray *cookieArray = [NSHTTPCookie cookiesWithResponseHeaderFields:headerFields forURL:URL];
    if (cookieArray != nil) {
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        for (NSHTTPCookie *cookie in cookieArray) {
            if (cookieFilter(cookie, URL)) {
                NSLog(@"Add a cookie: %@", cookie);
                [cookieStorage setCookie:cookie];
            }
        }
    }
    return cookieArray;
}

- (NSString *)getRequestCookieHeaderForURL:(NSURL *)URL {
    NSArray *cookieArray = [self searchAppropriateCookies:URL];
    if (cookieArray != nil && cookieArray.count > 0) {
        NSDictionary *cookieDic = [NSHTTPCookie requestHeaderFieldsWithCookies:cookieArray];
        if ([cookieDic objectForKey:@"Cookie"]) {
            return cookieDic[@"Cookie"];
        }
    }
    return nil;
}

- (NSArray *)searchAppropriateCookies:(NSURL *)URL {
    NSMutableArray *cookieArray = [NSMutableArray array];
    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    for (NSHTTPCookie *cookie in [cookieStorage cookies]) {
        if (cookieFilter(cookie, URL)) {
            NSLog(@"Search an appropriate cookie: %@", cookie);
            [cookieArray addObject:cookie];
        }
    }
    return cookieArray;
}

- (NSInteger)deleteCookieForURL:(NSURL *)URL {
    int delCount = 0;
    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    for (NSHTTPCookie *cookie in [cookieStorage cookies]) {
        if (cookieFilter(cookie, URL)) {
            NSLog(@"Delete a cookie: %@", cookie);
            [cookieStorage deleteCookie:cookie];
            delCount++;
        }
    }
    return delCount;
}

@end

发送请求使用方法示例:

 WKWebView * webView = [WKWebView new]; 
 NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"http://xxx.com/login"]]; 
NSString *value = [[HTTPDNSCookieManager sharedInstance] getRequestCookieHeaderForURL:url];
[request setValue:value forHTTPHeaderField:@"Cookie"];
 [webView loadRequest:request];

接收处理请求:

    NSURLSessionTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        if (!error) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
            // 解析 HTTP Response Header,存储cookie
            [[HTTPDNSCookieManager sharedInstance] handleHeaderFields:[httpResponse allHeaderFields] forURL:url];
        }
    }];
    [task resume];

通过document.cookie设置Cookie解决后续页面(同域)Ajax、iframe请求的Cookie问题。

WKUserContentController* userContentController = [WKUserContentController new]; 
 WKUserScript * cookieScript = [[WKUserScript alloc] initWithSource: @"document.cookie = 'skey=skeyValue';" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; 
 [userContentController addUserScript:cookieScript];

Cookie包含动态IP导致登录失效问题

关于Cookie失效的问题,假如客户端登录session存在Cookie,此时这个域名配置了多个IP,使用域名访问会读对应域名的Cookie,使用IP访问则去读对应IP的Cookie,假如前后两次使用同一个域名配置的不同IP访问,会导致Cookie的登录session失效。

如果App里面的WebView页面需要用到系统Cookie存的登录session,之前App所有本地网络请求使用域名访问,是可以共用Cookie的登录session的,但现在本地网络请求使用HTTPDNS后改用IP访问,导致还使用域名访问的WebView读不到系统Cookie存的登录session了(系统Cookie对应IP了)。IP直连后,服务端返回Cookie包含动态IP导致登录失效。

使用IP访问后,服务端返回的cookie也是IP。导致可能使用对应的域名访问,无法使用本地Cookie,或者使用隶属于同一个域名的不同IP去访问,Cookie也对不上,导致登录失效。

我这边的思路是这样的:

  • 应该得干预Cookie的存储,基于域名。

  • 根源上,API域名返回单IP。

第二种思路将失去DNS调度特性,故不考虑。第一种思路更为可行。

基于iOS11 API WKHTTPCookieStore来解决WKWebView的Cookie管理问题

当每次服务端返回Cookie后,在存储前都进行下改造,使用域名替换下IP。之后虽然每次网络请求都是使用IP访问,但是host我们都手动改为了域名,这样本地存储的Cookie也就能对得上了。

代码演示:

在网络请求成功后,或者加载网页成功后,主动将本地的domain字段为IP的Cookie替换IP为host域名地址。

- (void)updateWKHTTPCookieStoreDomainFromIP:(NSString *)IP toHost:(NSString *)host {
    WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
    [cookieStroe getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull cookies) {
        [[cookies copy] enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([cookie.domain isEqualToString:IP]) {
                NSMutableDictionary<NSHTTPCookiePropertyKey, id> *dict = [NSMutableDictionary dictionaryWithDictionary:cookie.properties];
                dict[NSHTTPCookieDomain] = host;
                NSHTTPCookie *newCookie = [NSHTTPCookie cookieWithProperties:[dict copy]];
                [cookieStroe setCookie:newCookie completionHandler:^{
                    [self logCookies];
                    [cookieStroe deleteCookie:cookie
                            completionHandler:^{
                                [self logCookies];
                            }];
                }];
            }
        }];
    }];
}

iOS 11中也提供了对应的API供我们来处理替换Cookie的时机,那就是下面的API:

@protocol WKHTTPCookieStoreObserver <NSObject>
@optional
- (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore;
@end

//WKHTTPCookieStore
/*! @abstract Adds a WKHTTPCookieStoreObserver object with the cookie store.
 @param observer The observer object to add.
 @discussion The observer is not retained by the receiver. It is your responsibility
 to unregister the observer before it becomes invalid.
 */
- (void)addObserver:(id<WKHTTPCookieStoreObserver>)observer;

/*! @abstract Removes a WKHTTPCookieStoreObserver object from the cookie store.
 @param observer The observer to remove.
 */
- (void)removeObserver:(id<WKHTTPCookieStoreObserver>)observer;

用法如下:

@interface WebViewController ()<WKHTTPCookieStoreObserver>
- (void)viewDidLoad {
    [super viewDidLoad];
    [NSURLProtocol registerClass:[WebViewURLProtocol class]];
    NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
    [cookieStorage setCookieAcceptPolicy:NSHTTPCookieAcceptPolicyAlways];
    WKHTTPCookieStore *cookieStroe = self.webView.configuration.websiteDataStore.httpCookieStore;
    [cookieStroe addObserver:self];

    [self.view addSubview:self.webView];
    //... ...
}

#pragma mark -
#pragma mark - WKHTTPCookieStoreObserver Delegate Method

- (void)cookiesDidChangeInCookieStore:(WKHTTPCookieStore *)cookieStore {
    [self updateWKHTTPCookieStoreDomainFromIP:CYLIP toHost:CYLHOST];
}

-updateWKHTTPCookieStoreDomainFromIP方法的实现,在上文已经给出。

这个方案需要客户端维护一个IP —> HOST的映射关系,需要能从IP反向查找到HOST,这个维护成本还时挺高的。下面介绍下,更通用的方法,也是iOS11之前的处理方法:

iOS 11之前的处理方法:NSURLProtocal拦截后,手动管理Cookie的存储:

步骤:做IP替换时将原始URL保存到Header中

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    NSMutableURLRequest *mutableReq = [request mutableCopy];
    NSString *originalUrl = mutableReq.URL.absoluteString;
    NSURL *url = [NSURL URLWithString:originalUrl];
    // 异步接口获取IP地址
    NSString *ip = [[HttpDnsService sharedInstance] getIpByHostAsync:url.host];
    if (ip) {
        NSRange hostFirstRange = [originalUrl rangeOfString:url.host];
        if (NSNotFound != hostFirstRange.location) {
            NSString *newUrl = [originalUrl stringByReplacingCharactersInRange:hostFirstRange withString:ip];
            mutableReq.URL = [NSURL URLWithString:newUrl];
            [mutableReq setValue:url.host forHTTPHeaderField:@"host"];
            // 添加originalUrl保存原始URL
            [mutableReq addValue:originalUrl forHTTPHeaderField:@"originalUrl"];
        }
    }
    NSURLRequest *postRequestIncludeBody = [mutableReq cyl_getPostRequestIncludeBody];
    return postRequestIncludeBody;
}

然后获取到数据后,手动管理Cookie :

- (void)handleCookiesFromResponse:(NSURLResponse *)response {
    NSString *originalURLString = [self.request valueForHTTPHeaderField:@"originalUrl"];
    if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
        NSDictionary<NSString *, NSString *> *allHeaderFields = httpResponse.allHeaderFields;
        if (originalURLString && originalURLString.length > 0) {
            NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:allHeaderFields forURL: [[NSURL alloc] initWithString:originalURLString]];
            if (cookies && cookies.count > 0) {
                NSURL *originalURL = [NSURL URLWithString:originalURLString];
                [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookies:cookies forURL:originalURL mainDocumentURL:nil];
            }
        }
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)newRequest completionHandler:(void (^)(NSURLRequest *))completionHandler {
    NSString *location = response.allHeaderFields[@"Location"];
    NSURL *url = [[NSURL alloc] initWithString:location];
    NSMutableURLRequest *mRequest = [newRequest mutableCopy];
    mRequest.URL = url;
    if (location && location.length > 0) {
        if ([[newRequest.HTTPMethod lowercaseString] isEqualToString:@"post"]) {
            // POST重定向为GET
            mRequest.HTTPMethod = @"GET";
            mRequest.HTTPBody = nil;
        }
        [mRequest setValue:nil forHTTPHeaderField:@"host"];
        // 在这里为 request 添加 cookie 信息。
        [self handleCookiesFromResponse:response];
        [XXXURLProtocol removePropertyForKey:XXXURLProtocolHandledKey inRequest:mRequest];
        completionHandler(mRequest);
    } else{
       completionHandler(mRequest);
    }
}

发送请求前,向请求中添加Cookie信息:

+ (void)handleCookieWithRequest:(NSMutableURLRequest *)request {
    NSString* originalURLString = [request valueForHTTPHeaderField:@"originalUrl"];
    if (!originalURLString || originalURLString.length == 0) {
        return;
    }
    NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies];
    if (cookies && cookies.count >0) {
        NSDictionary *cookieHeaders = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
        NSString *cookieString = [cookieHeaders objectForKey:@"Cookie"];
        [request addValue:cookieString forHTTPHeaderField:@"Cookie"];
    }
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    NSMutableURLRequest *mutableReq = [request mutableCopy];
//...
     [self handleCookieWithRequest:mutableReq];
    return [mutableReq copy];
}

相关的文章:

302重定向问题.

上面提到的Cookie方案无法解决302请求的Cookie问题,比如,第一个请求是http://www.example1.com,我们通过在request header里带上Cookie解决该请求的Cookie问题,接着页面302跳转到http://www.example2.com,这个时候http://www.example2.com这个请求就可能因为没有携带Cookie而无法访问。当然,由于每一次页面跳转前都会调用回调函数:

 - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

可以在该回调函数里拦截302请求,copy request,在request header中带上Cookie并重新 loadRequest。不过这种方法依然解决不了页面iframe跨域请求的Cookie问题,毕竟 -[WKWebView loadRequest:]只适合加载mainFrame请求。

相关参考

相关的库:

相关的文章:

可以参考的Demo: