解析 WKWebView 的 Cookie 封锁

3,688 阅读14分钟

背景

为了能够让 PC 端的网站业务,更快的集成到移动端展示,直接把 PC 链接地址,挂到移动端是最快的,但直接挂 PC 地址面临的问题(用户没有登陆状态),不了解移动端的同学,这时候会抛出问题,“为什么我的 App 已经登陆了,打开 PC 地址的时候还需要再登陆一遍,Android 可以直接访问,iOS 不能。(后面,我会对该问题进行解答)”,后来,我们也针对该问题至少进行两次服务端联调进行解决,提供了(旧)Cookie 授权模式、(新)token 授权模式两种模式,旧版授权模式在这里不再赘述,我想讲一下新版授权模式,新版授权通过一个移动中间页,调用容器 API 获取容器中的 token 后,再自动跳转 sso 进行授权,成功后,最终跳转目标页面,目前公司 OA 用的是套机制,陆续使用了几年。但在,最近我们发现邮件中的 iframe 内嵌的 PC 页面打不开了,也是进行全方位的排查,这篇文章将全面介绍 iOS 容器 Cookie 设置为什么那么难?

认知前提

首先,先解答几个常见的问题,部分问题了解的同学可跳过。

PC 浏览器为什么能够直接访问

PC 浏览器在经过首次登陆后,后面直接复制链接就可以快速访问,无需授权,这里面的原理,主要是通过 Cookie 和服务端进行身份互换,浏览器和 Cookie 是如何交互的。在首次登陆成功后,发生 302 跳转,在 Response 响应头信息中,一般会带有 Set-Cookie 字段,字段中会带有业务系统相关的标识,例如:“sid=xxxxxxx”,这里的 sid 会和服务端的 Session 对象进行对应,在下次请求的请求头中,如果带有 Cookie 字段(sid=xxxxx),服务端会根据 sid 找到对应的 Session 对象,保持之前的会话状态,Session 你可以认为就是个 JSON 数据对象(包含登陆信息),存储在服务端,可以在 radius 数据库,或者其他种数据库中,找到 Session 就可以找到登陆信息,那么,业务服务端就会认为本次访问是已经授权过的,此时会跳转授权成功逻辑。

客户端拿到响应头中 Set-Cookie 字段后,PC 浏览器会根据 Cookie 存储规则,自动存储到浏览器的数据库中,以便之后的请求,统一带上 Cookie 字段,Set-Cookie 一般能设置有效期,也就是在有效期内,浏览器中直接访问业务地址,只要带上 Cookie 字段(sid=xxxxxx),业务地址就能直接访问成功。

1.png

为什么 App 登陆了,PC 网站还要登陆一遍

Native 和 Web

要理解这个问题,首先得区分什么是 Native(原生),什么是 Web(网页),我们通过识别能力的提供者来理解 Native 和 Web 的区别,例如:在 PC 浏览器中,肉眼可见的布局、并且能够让用户点击交互的内容等,这是 Web,调用系统通知,唤醒其他进程,历史记录查看,浏览器偏好设置,WebRTC等能力,这是 Native,反复的思考这些能力的提供者是谁,就能理解 Native,Web 的区别。

iOS Native

App 实际上是阉割版的浏览器,他有 Web 的渲染,JS 的执行能力,但又缺失大部分 Web 所需要的 Native 能力,因此,很多 PC 浏览器能够使用的 Native 功能,在移动端,如果不和 Native 对接,几乎无法支持,回到 Cookie 的主题上,iOS App 则主要代表浏览器的 Native 能力,而 App 全局的 Cookie 管理,都归功于下面这个单例类:

// NSHTTPCookieStorage 主要负责 Native 所有的 Cookie 管理
[NSHTTPCookieStorage sharedHTTPCookieStorage].cookies

iOS WebView 控件

在 iOS8 之前,App 使用的 WebView 控件主要以 UIWebView 为主,该控件和 Native 共享同一个 Cookie 管理,也就是上文中提到的单例类,因此 Native 中大部分网络请求返回的 Set-Cookie,都能够被 UIWebView 继承(注意:同域下),很可惜 UIWebView 就只有 Cookie 上这个优点,随着 Web 技术的持续发展,UIWebView 承担着多方的压力,也同时带来了各种问题:内存泄漏(暴涨)、渲染慢、安全性极低等,终于在 iOS8 开始,Apple 推出了新版的 WebView 控件 WKWebView,而 UIWebView 的生命也停止在了 2020年。

2.png

很显然 WKWebView 在内存、渲染、安全等能力表现上都远超 WKWebView,唯独一件事不如 UIWebView,那就是对 Cookie 的管理。我们知道 UIWebView 的 Cookie 管理和 Native 共享,WKWebView 的缓存、Cookie等信息,则使用独立的管理类:

// WKWebsiteDataStore 主要负责 WKWebView 层面的资源缓存、Cookie等信息
[WKWebsiteDataStore defaultDataStore]

即使知道这个 API,也是在 iOS9 之后,因为这个 API 是 iOS9 提供的,在 iOS8 上根本无法访问 Cookie,而且 WKWebsiteDataStore 并没有提供任何可以写入 Cookie 的方法,该类还不仅负责 Cookie 的数据,还有网页的其他元素(包括:协商缓存),可见 Apple 有多不想我们去碰 Cookie,就这样,WKWebView 被骂声、吐槽声包围,直到 iOS11 Apple 良心发现,提供新的类,

/*!
 A WKHTTPCookieStore object allows managing the HTTP cookies associated with a particular WKWebsiteDataStore.
 */
WK_EXTERN API_AVAILABLE(macos(10.13), ios(11.0))
@interface WKHTTPCookieStore : NSObject

正统 WKWebView 管理 Cookie 的类,并且支持方法 getAllCookiessetCookiedeleteCookie等 API,事实上这些 API 经过测试后,也极不稳定,同步 Cookie 地时机也很不准确,事实上,在到 iOS11 之前,我们已经提供多种授权策略,自动登陆 PC,所以对 API 的需求没有那么大。

WKWebView 还有个特点,如果你在加载生命周期的最开始,自己改造 Request 在请求头中,塞入 Cookie 信息后,那么,后续 Response 中 Set-Cookie 的值不再被自动设置。

回到这个阶段的问题,为什么APP登陆了 Web 网页还要登陆一次?,因为,在 iOS 中,Native 层和 Web 层的 Cookie 不再共享,Web 层的 Cookie 独立管理,因此, Web 得再重新登陆一遍。

我们是如何应对的?

在 2018 年左右,我们希望在手机的邮件系统中,能够正常打开 PC 端网页,所以也开是投入对 WKWebView 的 Cookie 的研究,最初,我们通过拦截 PC 端特殊跳转地址,并且在 URL 的 path 尾巴处,拼接 :jsessionid=xxxx 的形式进行授权,在和 PC 端联调后,成功实现该方法,也就是 Cookie 授权模式。

在后来,我们进行了改造,推出了 token 授权模式,token 授权的原理,是在 SSO 校验跳转页面的时候,匹配请求头信息中的 UserAgent 字段,如果是来自移动端的页面,则会跳转到移动授权的中间页面,中间页会调用 API 获取容器中的 token,然后调用登陆接口进行登陆,最后跳转目标页面,所有的交互由 Web 自助解决,我们没有干涉整个过程,Set-Cookie、Cookie 都正常工作,并且,通过调用 WKWebView 的 WKProcessPool 属性,了解到可以通过单例设置,WKProcessPool 可以共享线程池,这也意味着,只要有一张页面授权成功了,那么其他页面也会带有对应的 Cookie,无需再次授权,使用体验大幅提升,这也是我们移动办公目前使用的方案。

在 token 授权模式推出后,也陆续经历各种波折,例如:

  1. 由于开启 API 鉴权,导致移动授权中间页需要鉴权才能调用 getToken API。
  2. 由于 iframe 页面,容器不支持 iframe 下,js 和 native 通信,导致 getToken 无法回调到 iframe 子页面内部。
  3. 由于 iframe 跨域页面,容器不支持 window.top API,导致 js 和 native 通信失败,getToken 无法调用问题。

上述所有问题,我们一一攻克,甚至还诞生了 iframe 跨域通信解决方案。这一方案我们用到 2021 年,直到最近,我们收到 iframe 跨域页面打不开的问题,也就是本篇文章所要讲的问题。

问题

最近,我们又收到 iframe PC 页面打不开、白屏现象,原因是 Set-Cookie 没有自动带上 Cookie 信息导致的,所有该排查的点,我们都排查遍了,依旧没有找到解决方向,所以,也是做了高强度的测试验证。

问题复现

第 1 步:环境搭建

首先,我们来复现问题,搭建复现环境,这里我是用 nodejs、以及 Express 模拟服务端,增加如下接口:

// 中间件,所有接口到来后,都需要判断 cookie 中是否有值,如果有则继续执行,如果没有则重定向 /authorizewel
app.use("*", async (req, res, next) => {
    let cookie = req.headers["cookie"] || "";
    if ((cookie.length <= 0) && (req.baseUrl != "/authorizewel")) {
        res.redirect("/authorizewel")
        return;
    }
    next();
});

// authorizewel 授权地址,重定向目标页面
app.get("/authorizewel", (req, res) => {
    res.cookie("name",'zhangsan',{httpOnly: false, path:"/"}; 
    res.redirect("/static/welcome.html");
});

并且增加,/static/welcomeb.html/static/welcome.html 页面,在 welcomeb.html 我么写 iframe 标签加载 welcomeb.html.

welcomeb.html

<meta name="viewport" content="width=device-width, initial-scale=1" />
<iframe sandbox="allow-storage-access-by-user-activation allow-scripts allow-same-origin" src="http://172.20.242.189:3001/static/welcome.html?222" style="width:100%;height:80%;" ></iframe>

welcome.html

<script src="js/ejs/ejs.js"></script>
<script src="js/jquery.js"></script>
<script src="js/ejs/ejs.native.js"></script>
<div>hello world!22</div>
<script type="text/javascript">
    window.onload = function(){
        $.ajax({
            url:"/test?"+(new Date().getTime()),
            xhrFields: {withCredentials: true},
        });
    }
</script>

第 2 步:模拟跨域

因为要模拟跨域,所以需要通过路由器的设置,映射内网 ip、端口,最外层地址通过路由 IP 访问。

第 3 步:开启抓包

一切准备就绪,我们利用 Charles 进行数据抓包,得倒了如下信息:

3.png

这里无限的重定向,和反馈的问题一致,无限重定向的原因,就是因为,在跨域条件下,Set-Cookie 无法设置上去导致。

第 4 步:调研分析

事实上,我经常会去点击系统 Safrai 的设置界面,我在 iOS14 (现在升级到 iOS15)的时候,就注意到了设置界面多了几个选项:

5.png

一定和这些选项有关,Safrai 的偏好设置,事实上在代码层面也有,主要和下面这个类对应,但 Apple 并不会把所有的 API 告诉你:

// WebView 设置
WKWebViewConfiguration
// WebView 偏好设置
WKPreferences

经过调研后发现,Apple 在 iOS14 之后,开始启用一项技术 —————— “智能预防追中(Intelligent Tracking Prevention)”,简称 ITP,

详细介绍:webkit.org/blog/7675/i…

5.png

简单理解,启用这项技术之后,iframe 在跨域的情况下,Cookie 设置全部失效,实际上,我看这篇文档的时候,也就是今年是 iOS15 即将发布的时候,根据 Apple 最新的隐私政策,ITP 还增加了“Cookie 阻塞锁存模式”。

政策地址:webkit.org/blog/7675/i…

6.png

好吧,看样子,Apple 是把 iframe 下 Set-Cookie 的路给堵死了,经过实际测试:

版本ifame 是否可以设置 Cookie
iOS 9可以
iOS 12可以
iOS 13可以
iOS 14不可以
iOS 15不可以

第 4 步:解决之路

由于知道什么原因导致的,所以一切围绕“How to disable ITP in WKWeb View" 这个主题进行,一番搜索之后,倒是找到了一个配置方案:

7.png

可以设置 NSCrossWebsiteTrackingUsageDescription 配置信息,允许跨站追踪,还是很兴奋地,结果非常失望,并没有起到任何作用,确实在软件的配置界面多了个按钮,但没有任何作用,甚至还误导了我的思考方向:

8.png

这个按钮,在 iOS14 上默认关闭,需要手动开下,在 iOS15 上默认开启,他娘的死活关不了,我用的 iOS15-beta 版本,是不是 Bug 就不得而知了,但有一点可以确认,这个按钮毫无用处。

在反复测试、验证这个配置项的过程中,我还得出了个结论,iOS14 通过设置

// 设置 cookie 的策略,永远接受
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookieAcceptPolicy:(NSHTTPCookieAcceptPolicyAlways)]

上一次设置的 Cookie,会在下次 App 冷启动的时候,自动带上,也就是说两者并非没有关联,这可能是 iOS14 的 Bug,因为 iOS15 上这一特性完全没有了,这也引申出了最终的方案。

第 5 步:最终方案

通过上一步的思考,以及得出的结论,我决定把 WKWebView 所有的请求全部代理出来,用 Native 发送请求,这样就可以和 Native 共享缓存。但是怎么做呢?在我的潜意识里,WKWebView 的所有请求资源是不可代理,于是我瞄准了一个 iOS11 新提供的 API:

/* @abstract Sets the URL scheme handler object for the given URL scheme.
 @param urlSchemeHandler The object to register.
 @param scheme The URL scheme the object will handle.
 @discussion Each URL scheme can only have one URL scheme handler object registered.
 An exception will be thrown if you try to register an object for a particular URL scheme more than once.
 URL schemes are case insensitive. e.g. "myprotocol" and "MyProtocol" are equivalent.
 Valid URL schemes must start with an ASCII letter and can only contain ASCII letters, numbers, the '+' character,
 the '-' character, and the '.' character.
 An exception will be thrown if you try to register a URL scheme handler for an invalid URL scheme.
 An exception will be thrown if you try to register a URL scheme handler for a URL scheme that WebKit handles internally.
 You can use +[WKWebView handlesURLScheme:] to check the availability of a given URL scheme.
 */
- (void)setURLSchemeHandler:(nullable id <WKURLSchemeHandler>)urlSchemeHandler forURLScheme:(NSString *)urlScheme API_AVAILABLE(macos(10.13), ios(11.0));

这个 API,本来是用来给拦截自定义协议的地址,并且通过本地加载资源模式的策略,提高 WKWebView 加载速度的,这个 API 还有个特点,就是你设置 forURLScheme:@"http"forURLScheme:@"https" 他都会报错,报错如下:

 *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''http' is a URL scheme that WKWebView handles natively'

意思是,http、https 都系统处理过的,所以形成了一个印象,默认的 Web 协议都不可以设置,根据 is a URL scheme that WKWebView handles natively 这段话我去翻了苹果源码,找到了如下内容:

9.png

我发现苹果是通过 [WKWevbView handlesURLScheme:] 静态方法的返回值去判断,是否可以代理,基于苹果的<objc/runtime.h>库,我动态交换了苹果的 handlesURLScheme:方法,并且判断,http、https 自动返回 NO(仔细看,一开始我以为是 YES,但发现 YES 是直接报错,我还以为苹果不让动态交换),其他逻辑还找原路执行,代码如下:

@implementation WKWebView (EJSCustomHandler)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method originalMethod = class_getClassMethod(self.class, @selector(handlesURLScheme:));
        Method swizzledMethod = class_getClassMethod(self.class, @selector(ejs_handlesURLScheme:));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

+ (BOOL)ejs_handlesURLScheme:(NSString *)urlScheme {
    if ([urlScheme hasPrefix:@"http"] || [urlScheme hasPrefix:@"https"]) {
        return NO;
    }
    return [self ejs_handlesURLScheme:urlScheme];
}

@end

再次执行,惊奇的发现,Web 中的所有资源请求全部走 Native 调用,到这里感觉自己做了一件不得了的事情。(当然,在事后查资料的时候,发现,也有人想到了- -!)接下来的事情就好办了,我只需要实现 Native 网络请求的调用就行了,第 1 版完成后,就速度前往自己搭建的环境测试了下。

10.png

成功了,并且 Cookie 也自动跳转了,也没有无限 302,OK!,直接项目上运行,期望总伴随着失望,首次在项目运行,还是白屏,很郁闷,理论上都是对的,经过抓包分析如下:

11.png

实时上,页面正常跳转了,但在移动授权中间页面的时候,部分资源请求发生了 404,和最早之前出问题的资源抓包对比后发现,是主机路径不对,这就很奇怪,为什么在 WKWebView 中可以正常,却在 Native 请求中发生问题,两者有什么区别,后来发现原来是自己没有处理 302 跳转导致的,再次翻开 Apple 源码,找关键字 == 302:

12.png

原来在源码中,发生 302 跳转时,苹果是模拟一段页面跳转返回 WKWebView,似乎资源路径找到了 Location 主机,有了前缀路径就对了,照搬这块逻辑:

  if (resp.statusCode == 302) {
        
        // 如果发生 302 网络请求,模拟页面跳转返回 html 内容,让浏览器在 WebView 上通过 JS API 进行跳转,这样跳转可以保持 302 定向 URL 的主机地址继承,否则会造成找不到主机地址,后续资源的会默认原始地址主机地址进行访问,造成 404 问题
        NSString *Location = [resp.allHeaderFields objectForKey:@"Location"];
        NSData *rspData = [[NSString stringWithFormat:@"<script>location = '%@';</script>", Location] dataUsingEncoding:NSUTF8StringEncoding];
        NSMutableDictionary *headers = [resp.allHeaderFields mutableCopy];
        [headers setObject:@"text/html; charset=utf-8" forKey:@"Content-Type"];
        [headers setObject:@(rspData.length).stringValue forKey:@"Content-Length"];
        NSHTTPURLResponse *newResp = [[NSHTTPURLResponse alloc] initWithURL:resp.URL statusCode:resp.statusCode HTTPVersion:@"HTTP/1.1" headerFields:headers];
        [urlSchemeTask didReceiveResponse:newResp];
        [urlSchemeTask didReceiveData:rspData];
        [urlSchemeTask didFinish];
        
        completionHandler(NSURLSessionResponseCancel);
        return ;
    } else {

再次访问,这一次,完美跳转:

13.png

资源全部正常访问,iframe 打开成功:

14.png

当然后面,还有资源并发的问题,和被 NSURLProtocol 拦截后,如果不处理 302 状态码,得不到回调的问题,就不再详细描述,到这里基本临时解决方案有了。

结论

临时方案不能作为最终方案,因为,这也只是阉割版的 WebView 的接口代理,仅仅处理了 302 状态码,实际上我看到 Apple 源码还有更多状态码,像 307、308、303、206 等状态码,我都没有对接,这相当于实现了一套 WebView 的网络交互标准

15.png

为了一个 iframe 问题,对接一套交互标准,我觉的意义不大,所以该方案有网络请求失败的风险,所以,还是建议针对 iframe 内嵌的业务场景,基本在移动端都可以弃用。