本文适用人群:受到第三方 Cookie 影响,想要一些第三方 Cookie 解决方案的战友
本文可能会是我插图最多的文章之一,因为要解释清楚各种概念,可能需要引用很多张图片辅助消化。
尽管 Google 在 2022 / 2023 疯狂铺垫自己要禁用第三方 Cookie,大家马上改造,但实际上大部分人的工作还是在死线前后死亡冲刺。Google 官方宣称禁用第三方 Cookie 是为了「减少跨网站跟踪,同时确保让每个人都能免费访问在线内容和服务的功能」,但实际上好处还没感受到,却带来了一大堆麻烦。
Story Start
第三方 Cookie 是什么
在开始之前,首先简单介绍下第三方的定义,Google 的解释是:
第三方是指由与您正在访问的网站不同的网域提供的资源。 例如,网站 foo.com 可能会使用来自 google-analytics.com 的分析代码(通过 JavaScript)、来自 use.typekit.net 的字体(通过链接元素)以及来自 vimeo.com 的视频(在 iframe 中)。另请参阅第一方。
也就是说,跨站携带的 Cookie 就是第三方 Cookie。
那么下一个问题,跨站的定义是什么,或许你在面试题中会听到「跨域」、「跨站」,他们俩到底有什么区别:
- 跨域:域名、端口号、协议有一个不一样,就是跨域的
- 跨站:eTLD 相同的站点,不用管端口号,但需要注意协议
请注意:下文我们说的所谓的「同站」、「相同站点」都是基于这一定义衍生的名词。
我为什么要改造
实际上当你看到这篇文章才开始改造时已经晚了,因为 Chrome 已经开始进行 1% 的灰度了:
也就是说,已经有部分用户会受到影响了,每个人都身处漩涡之中,不过好处是,在最差的情况下,你可以选择引导用户关闭第三方 Cookie。
关于这个的教程可以参考:support.google.com/accounts/an...,本文不多做赘述。
同时需要注意,Safari 和 Chrome 的解决方案是不同的,这点在下文介绍解决方案时会再次说明,而 Safari 其实已经禁用第三方 Cookie 很久了,所以如果你没有考虑过,现在也不用想得太复杂。
所以解决方案是什么
在明确解决方案之前,首先我们需要知道有那些场景,Google 为不同的场景提供了不同的解决方案:
- 用户标识符:各种数据统计上报可能会用 Cookie 来做用户标识符(uid / fingerprint),通过数据上报进行各种统计(比如 PV、UV)
- iframe:过于典型,嵌入站点常规使用 Cookie 就会遇到
- 个性化投放:用于进行广告归因和行为分析并进行投放
- SSO 登录:跨站共享登录身份
- CSRF Token:防止 CSRF 攻击
而针对这些场景,Google 提供了一系列解决方案,包括但不限于:
- Topics API:用于解决用户个性化投放(场景 3)
- Attribution Reporting:用于衡量广告点击或观看何时促成转化,例如在广告客户网站上完成购买。(场景 3)
-
- Protected Audience API:设备端广告竞价,用于向再营销和自定义受众群体投放广告,无需进行跨网站第三方跟踪。(场景 3)
- FedCM:支持联合身份服务,可让用户登录网站和服务。(场景 4)
- 私密状态令牌:可通过跨网站交换有限的非身份信息来防范欺诈和反垃圾内容。(场景 5)
值得一提的是,根据我个人对于 FedCM 的理解,结合Federated Credential Management API 开发者指南,FedCM 像是身份提供方与浏览器的梦幻联动,因此更像是 Chrome Only 级别的解决方案。
而场景 1 用户标识符,参考 Google Analytics SDK 的实现,我们其实可以以前端的方式写入 Cookie,再用 querystring 拼接身份标识符来解决上报问题。
而上述介绍的 API 目前还可能受到兼容性的挑战,因此本文更多希望站在第三方 Cookie 本身来讨论问题。
因此本文的重点在对于 iframe(场景 2) 和 SSO (场景 4) 的解决方案,并且尽可能的考虑到方案的通用性。
解决方案
狠一点的话走代理、加绑同站域名当然也是一种方式,但大部分场景下没有办法这么简单粗暴。
LocalStorage / Querystring
最简单的 Cookie 平替就是 localstorage 或者 querystring。
比如上面我们曾经说过的 Google Analytics SDK,假设在某些场景下(比如 iframe)前端无法顺利的写入 Cookie,也可以将值写入 localstorage,在上报时读取 localstorage 并通过 querystring (或者 body)带入接口。
也就是说,我们用 localstorage 作为持久化手段;将 querystring 作为前后端通信手段,来等效的解决 cookie 的读写问题。
值得一提的是,localstorage 同样也是分区的,而不是全局共享的,例如我直接访问 www.codesky.me
写入 localstorage 的值,和我在 xsky.me
中嵌了个 iframe,iframe 内嵌入 www.codesky.me
写入的 localstorage 值是独立的,无法交叉读取。
Storage Access
本方案通用性较高,Chrome(Chromium)、Firefox、Safari 以及移动端都支持但考虑到几个 Chromium 内核浏览器都是 2023 年十月左右的版本加入的,所以建议增加兼容性代码。此外,Webview 不一定兼容,根据 MDN 的说法,2023-12-05 才加入。
如果你用过浏览器的其他 API,Storage Access 相比之下就很好理解,简单的来说就是先问「我能不能用」,如果获得了用户的授权,接下来申请的站点就能正常使用 Cookie 了,这一部分 Cookie 甚至可以是未分区的 Cookie,也就是可以在授权站点内达成形如「未禁用第三方 Cookie」所表现的效果。
Storage Access 使用上来说体验会有一些劣化,正如上面说的,他需要用户先进行交互才能正常调用 document.requestStorageAccess()
(简单的来说就是不能自动触发,需要用户点个按钮才能正常触发事件)。你还可以通过 hasStorageAccess()
来检测用户是否已经授权。这两个 API 都需要在 iframe 内部才能触发。
这一授权是站点到站点的,也就是即使你内嵌的是 www.codesky.me
,之后换成了 m.codesky.me
,授权仍然有效。
需要注意一点,只有 iframe 加上了以下属性才能成功触发事件:
- 必须授予 allow-storage-access-by-user-activation 权限才能访问 Storage Access API。
- 必须授予 allow-scripts 权限,才能使用 JavaScript 调用该 API。
- 必须授予 allow-same-origin 权限,才能允许访问同源 Cookie 和其他存储空间。
ini
<iframe sandbox="allow-storage-access-by-user-activation
allow-scripts
allow-same-origin"
src="..."></iframe>
当然,这并不意味着每次你都需要让用户点击按钮才能继续,在授权一次后接下来有一定时间的保质期,保质期内的规则是:
- 默认同意,不需要再次手动选择
- Chrome 和 Firefox 之后可以允许静默调用
document.requestStorageAccess()
,而 Safari 需要每次都进行互动(也就是每次访问都得点一下授权)
当然,接下来我们会将一个叫相关网站集的概念,有了它就可以很好的简化这一授权流程:
另外,刚刚我们其实也提到了,这两个 API 只能用于 iframe 中,Chrome 更进一步提供了顶级页面用的 API:requestStorageAccessFor()
,*如果跨网站请求包含 CORS 或跨域属性,则这些请求将包含 Cookie。这样可以一定程度上解决一些站点前后端跨站的问题 ,但问题是------这个 API 兼容性过于离谱(其实就是 Chrome 新加的),如果需要进一步了解,可以参考:Document: requestStorageAccessFor() method。
这个方案更多可以阅读:Storage Access API
相关网站集
刚刚我们也提到了相关网站集这个概念,相关网站集相当于你有一堆看上去毫不相干的网站(都是不同站),比如 codesky.me
和 xsky.me
,但实际上都是你的网站,你也希望他们之间能够互相共享,拿 Chrome 官方的例子就是,首先你需要有一份配置文件,形如:
json
{
"primary": "https://primary.com",
"associatedSites": ["https://associate1.com", "https://associate2.com", "https://associate3.com"]
}
然后把这份 JSON 提交给 Google 的 GitHub 仓库(这步属实魔幻行为),提交成功后就可以作为「相关站点」处理,然后你在调用上面提到的 document.requestStorageAccess
或者 requestStorageAccessFor
就可以尽享特权了------没错,费了半天劲,结果还是需要唤起他俩 API。
而且提交给 Chrome 的仓库,怎么想都不会是一个标准化行为,如果其他浏览器要效仿标准,难道要维护一份 Firefox 版和一份 Safari 版吗?因此这个方案看上去更迷了。
具有独立分区状态 (CHIPS) 的 Cookie
CHIPS 这个方案是我目前大力推荐的一种改造形式,因为他改造成本小,在 iframe 上作为解决方案效果较为显著,只需要注意一些 corner case 就能很好的解决这一限制。
CHIPS 也就是分区的 Cookie,用官方出品的图比较好理解分区 Cookie 这个东西:
未分区时 C 的 Cookie 无论是嵌套在 A 站点还是 B 站点都可以读取,而有了分区(Partitioned) Cookie 后,A 站点嵌入 C 和 B 站点嵌入 C 之间读取的 Cookie 是无法共享的,如果用 DB 的概念来说,其实就是加上了联合索引:
这样你就能简单理解什么是 Partitioned Cookie 了,在浏览器支持上,目前 Firefox 和 Safari 都是不支持的。(但 Firefox 宣称他们对所有的第三方 Cookie 默认都是分区的,只是 Chrome 觉得这个方法不好,可能会带来不必要的麻烦/Bug;而 Safari 中是真没办法,还得用 Storage Access API 申请。)
但虽然不支持,如果你在低版本浏览器或者不支持的浏览器中使用,也不会产生什么副作用,只会默认忽略这个 Partitioned
标记罢了。
ini
Set-Cookie: __Host-example=34d8g; SameSite=None; Secure; Path=/; Partitioned;
如果是不支持 Partitioned 的浏览器,会将其忽略,也就是:
ini
Set-Cookie: __Host-example=34d8g; SameSite=None; Secure; Path=/;
Chrome 新版虽然禁用了第三方 Cookie,但如果你分区了,就可以正常在 iframe 内读写隔离版 Cookie 了。
这个方案就是这么简单吗?------对,就是这么简单,这也就是为什么我(以及 Google)都拿这个方案出来说的原因。
当然,实际改造中需要对读写进行一些处理:从优化体验的角度以及 Google 的建议,你不应该默认无脑写分区 Cookie(这个论点可以参考Partition all third-party cookies by default),尤其是在顶级站点的访问上。也就是说理论上一个良好的体验是需要你按需写入 Cookie 或者双写 Cookie 的。双写可以更好的让那未被灰度到 99% 的用户保证原来的访问体验,而 1% 的用户使用 Partitioned 版本。
但所谓双写一时爽,如果你只是普通的对 Cookie 进行读写似乎没什么问题,如果你在同一个 Cookie 中进行 Append 操作,也就是 Cookie 写入不幂等的情况,可能就会出现一些尴尬的情况了,场景可以类似于一个加减法:
也就是说,不幂等会导致 Cookie 不一致,在未禁用第三方 Cookie 那 99% 的用户场景下可能会带来灾难性的后果------因为我不知道读的是哪一个。
特别需要注意的是:尽管在前端可以看到两个 Cookie 一个带 Partitioned 标,一个不带,但是在后端只能收到 Cookie 的 key 和 value,所以对于后端来说,这只是普通的两个同名 Cookie。(RFC 中提到:客户端实际上有排序规则,但服务端不应该依赖排序)而笔者读了几种语言的标准 cookie 库,通常本质都是构造一个 Map,或者直接以分号和等号作为分隔符进行切割并循环遍历,本质上都是取第一次出现或者最后一次出现作为有效 Cookie,而忽略其他结果。),目前几乎所有的标准库实现对倾向于取一个(也可能是因为 RFC 中提到客户端如果收到多个同名 Cookie 只取一个,而库之间尽可能对齐带来的现象)。
如果对此抱有疑问,可以通过检查后端库或者观察目前站点发送同名 Cookie 的表现来预测行为是否会存在异常。
而不同于上面的兼容性处理,对于这一方案来说,更重要的是「降级方案」,也就是如果 Partitioned 实现过程中出了问题,我们是不是只能让用户去清除他们的 Cookie------实际上我们可以通过写入 max-age=0
的 Cookie 来清除 Cookie,或者使用 Clear-Site-Data 来清除 Cookie,这一点在 Chrome 对于 CHIPS 的提案中有所提及。
再次说明,这个方案相当简单粗暴好用,但解决不了 Safari 第三方 Cookie 的问题。
共享存储空间
共享存储空间(Shared Storage)可以提供一系列 API 帮助你实现未分区的跨站存储,这样就可以和以前使用 Cookie 那样使用它了。
虽然什么方案都会比相关网站集方便,但这个 API 高度依赖 fencedframe
,而这个 HTML 标签甚至没有一个属于自己的 MDN 页面,给这个方案带来了一些「面向未来」的感觉,笔者并不推荐,研究的也比较少,如果仍想了解,可以参考:
JWT / Access Token
最后说了半天,前面的解决方案好像大部分更多着眼于 iframe 中,还是不太能解决 SSO 场景中的问题,对于 SSO 来说,流程可以简单看做:
登录步骤从原来的写 Cookie 变成写 localstorage,从某种程度上来说也是从后端写变成了生成页面前端写(后端返回 HTML 限时复刻)。最后再跳回原始页面。
其中,Ticket 是一次性的;而 Token 是持久化的,具有一定时间的保质期。
当然,也可以是 callback 直接带着 ticket 跳回原始页面,原始页面收到 ticket 后自己换 token 写入 localstorage 进行持久化(使用 localstorage + querystring 是我们最早提出的方案)。
而对于登录态的判断上,也从原来的直接调用接口变成了需要从 localstorage 取值,或者拿着 ticket 去换 token 再鉴权。(注意,这里 ticket 必须马上被消费掉,避免泄露后被利用)
这套方案最麻烦的地方可能是,即使 Cookie 中用户数据和 Token 的算法进行了对齐,值都一样了,但是后端还是取不到 Cookie 值,需要前端显式的传入,而在显式传入的过程中,必然涉及到后端的读取改造,这一方案对于整站的回归成本极大。
另外需要注意的是,建议不要直接把 Token 放在 querystring 中,这相当于在 URL 中写明:username=sky&password=123456
这么露骨,可以选择放在 Header 或者 Body 中,更为安全。
这一方案的优点是,绝对通用,一次改造,终生受益,没有任何兼容性烦恼;缺点是,改造成本大到多数站点会退避三舍。
本身想把这个方案称为 JWT,但实际上他只是一个普通的 Access Token,而加密算法和内部实现还是有很多灵活空间的。如果真的采用 JWT,登出问题可能会变成另一个新的问题。
总结
尽管第三方 Cookie 已经禁用有一周多了,各种文章也同步出台,但是很少看到有具体完善的解决方案,而本文借用相当长的篇幅去介绍多种解决方案,所以写作成本比较高,收集了大量资料和花了好几个小时才写好(其实是拖更了一周多 emmm)。
本文确实有点长,感谢大家耐心的读到结尾,如果有对于第三方 Cookie 其他方案的想法,也欢迎留言讨论。
最后,最近有个小想法,之后更新的时候想更新一些自己的「想法」而不是「实践」,因为「实践」写作/验证周期比较长,而「想法」较为天马行空,更偏向「脑洞」,更新起来比较容易。最近有很多杂七杂八的感慨,如果大家有兴趣,之后我会开始写更短篇幅的想法类分享。
Reference
本文文章中链接较多,Reference 不代表全部引用文章。