没错,大家也看到了这篇博客的标题是英文,这说明了什么呢?这说明了这是一个悲伤的故事,预示着作者心中的哀伤(苦笑脸)。其实最主要的原因是,用中文描述起来可能有点啰嗦。
『Https请求的response的header中, Set-Cookie里的domain字段的值,是否需要以点.开头呢?』
为什么会有这样的疑问?一切还要从一个月前的那件事说起~
背景介绍
书归正文。先介绍一下业务背景。这次发生问题的登录业务。和大多数登录业务一样同样是基于Oauth登录,客户端访问授权方拿到授权,然后通过这份授权向认证服务器申请令牌(Token)。拿到Token后,客户端就可以合法的访问具体业务从而请求资源。大致的流程可以通过下图来表示
这次问题就发生在这个Token身上。客户端因为域名的调整导致拿不到Token了,引发了一系列问题。为了方便大家理解,我先简单的介绍下https关于下发Cookie的背景。
我们知道,https下发Cookie信息, 其中一种方式是通过把Cookie信息存放在Response的Header里面的Set-Cookie中。
假设下发Token的url是:
login.com
在Response的Header中,以Set-Cookie为Key,存有Token信息,格式为:
token=xxxxxx;path=/;domain=login.com
下图是我用Charles的抓包看response的header的截图,业务相关的地方打了马赛克,不影响理解。
而我们客户端要做的,就是从响应中取出这个Token的值,用于访问业务接口。而我们这边项目由于业务的原因,客户端这边从网络请求到拿Token实现方式有两套不同但又类似的代码,最终方式都是从请求的CookieManager中,直接根据key=token,取出对应的value。
- 方式一:基于HttpURLConnection封装的网络请求,收到响应之后,从CookieManager中读取key为『token』的value。
- 方式二:基于OkHttp封装的网络请求,同样的,收到响应之后,从CookieManager中读取key为『token』的value。
可以用这段代码理解:
1 |
|
暴露问题
这次事情的起因是公司的MIUI国际部隐私团队提出要求,海外版内置App需要杜绝直接连国内服务器进行网络请求的情况。而我们登录业务的callback也需要做调整。调整方案是统一替换原有callback的域名。替换为海外服务访问域名。
假设我们针对海外用户(比如说美国用户)的处理方式是提供海外子域名:
usa.login.com
确定了针对海外用户的域名后。在保证业务逻辑一致的情况下,我们在测试环境下切换了callback的链接 login.com -> usa.login.com。这时Set-Cookie的格式是:
token=xxxxxx;path=/;domain=login.com
但是我们发现:域名切换后,方法一基于HttpURLConnection封装的网络请求,CookieManager读取不到key为『token』的cookie信息。但是方法二正常。
时间仓促,我们只能紧急查找方案。有同事提出,CookieManager拿不到Token,是不是因为callback的url加了usa.的前缀后,和Set-Cookie的domain无法正常匹配?于是我们抱着试试看的想法,在domain现有的值前面加上一个点.,也就是格式变成:
token=xxxxxx;path=/;domain=.login.com
修改之后测试,结果又是大跌眼睛,方法一正常了,但是方法二基于OkHttp封装的网络请求,CookieManager读取不到key为『token』的cookie信息。
时间仓促,我们只好再次尝试,domain改成当前callback url完整域名,这下两种方法都能正常获取到token了。
token=xxxxxx;path=/;domain=usa.login.com
查找原因
作为一名程序猿,当然不能满足于只解决问题。我们首先查一下CookieManager的domain到底是怎么定义的?我们无意中在square/okhttp的issues中,发现了这个issue
Inject a leading . for better matching under JavaNetCookieJar #2722
里面核心的有这么一段话:
提到了两个关键词,『RFC 6265』和『RFC 2965』,然后又提到了okhttp遵循的是RFC 6265协议,而java.net遵循的是RFC 2965协议。通过这些线索,我们最后找到了原因。
HTTP State Management Mechanism
HTTP State Management Mechanism(Http状态管理机制),正是它规定了Set-Cookie中domain的写法。根据时间顺序,2000年10月份的RFC 2965协议在前,2011年4月份的RFC 6265在后。
可以看到,RFC 6265 同时声明了 RFC 2965 过时。
首先要说明的是,无论是哪份协议,都引入了一个概念,『domain-match』。请求头部的Set-Cookie的数据如果想合法的存到CookieManager中,需要保证url domain-match domain value string。如果不满足这个条件,CookieManager里也就不会有对应的数据。
RFC 2965
让我们首先看一下RFC 2965是如何定义domain-match的?
这里主机名可以是IP地址,也可以是主机域名。可以很清楚的看到,假设有A和B两个主机名,如何判定A domain-match B呢?满足两个条件之一即可:
- A和B的主机名字符串相等,或者
- A是一个主机域名并且其可以看做NB的组合,N是一个非空的字符串,而B的格式必须是是.B,同时B也是主机域名。(所以说x.y.com domain-match .y.com,但是并不 domain-match y.com)
根据这个定义,我们来复盘一下我们业务中的url和domain在RFC 2965的标准下,domain-match的情况分别是怎样?
业务调整前:
A(login.com), B(login.com),满足RFC 2965条件一 A equals B,所以业务调整前的A domain-match B。
业务调整后第一次:
A(usa.login.com), B(login.com),不满足RFC 2965的两个条件任何一个,所以A not domain-match B!!
业务调整后第二次:
A(usa.login.com), B(.login.com),满足RFC 2965条件二,所以A domain-match B !!
业务调整后第三次:
A(usa.login.com), B(usa.login.com),再次又满足RFC 2965条件一 A equals B,所以业务调整前的A domain-match B。
而基于HttpURLConnection封装的网络请求是源自于java.net,遵循RFC 2965,完全符合此前的现象!!
RFC 6265
让我们再来看看RFC 6265又是怎么定义 domain-match 的呢?指的注意的是,这里不再采用A,B这样的说法,而是相对应的用一个字符串(a string)和域名字符串(the domain string)来表示。
同样也是两个条件满足一个
- 域名字符串和字符串是相同的。(注意,在这一情况时域字符串和字符串都将被规范化为小写)。
- 以下所有条件同时成立
- 域名字符串是该字符串的后缀
- 该字符串的最后一个不包含在域名字符串的字符是%x2E(“.”)字符。
- 该字符串是一个主机名(即不是一个IP地址)
第二个条件可能有点难理解。尤其是我们该怎么理解这里的『该字符串的最后一个不包含在域字符串的字符是%x2E(“.”)字符。』这句话呢?
假如域字符串是A.com,字符串是B.A.com,那么最后一个不包含在域字符串的字符就是%x2E(“.”)字符(从后向前算)。
如果域字符串是.A.com,字符串是B.A.com,那么最后一个不包含在域字符串的字符就是B字符。
同样的。我们来复盘一下两个domain在新标准RFC 6265下,domain-match的情况分别是怎样?
业务调整前:
A(login.com), B(login.com),满足RFC 6265条件一 A equals B,所以业务调整前的A domain-match B。
业务调整后第一次:
A(usa.login.com), B(login.com),满足RFC 6265的条件二,同样A domain-match B !!
业务调整后第二次:
A(usa.login.com), B(.login.com),RFC 6265 的两个条件都不满足,所以A not domain-match B !!
业务调整后第三次:
A(usa.login.com), B(usa.login.com),再次又满足RFC 6265条件一 A equals B,所以业务调整前的A domain-match B。
可以看到,基于新标准RFC 6265okhttp网络库的情况,与该标准描述的情况也完全符合,
解决方案
看到这,我们可以先得到一个结论。本次问题的出现,正是不同的网络请求库应用的HTTP State Management Mechanism标准不一致导致的。那么该如何处理呢?
其实很简单,放弃原先从CookieManager中读取的方式(虽然它省事,也符合标准),直接从response的header里手动解析,这样,无论Cookie遵循的是哪种规范,都不影响我们客户端最终读取数据。
事情到这本应该告一段落了,但不死心的程序猿们,想继续深入一下源码,看看到底是哪里的代码导致的问题呢?结果又有了意外发现。
分析源码
HttpURLConnection这部分没什么好说的,判断domain-match的方法也正如前面所说的,基于的是RFC 2965协议的标准。没有leading dot的话是不会判断为domain-match的。让我们好奇的是,okhttp基于的虽然是新的协议,但是为什么没有兼容老版本的协议呢?
我们这里的OkHttpClient是这样构造的:
1 | mOkHttpClient = new OkHttpClient.Builder() |
可以看到我这里用的cookieJar是JavaNetCookieJar。而在JavaNetCookieJar用到的Cookie这个类发现了domainMatch(HttpUrl url, String domain):
1 | private static boolean domainMatch(HttpUrl url, String domain) { |
而调domainMatch(HttpUrl url, String domain)之前,domain有经过处理。
1 | /** |
我们看到这里JavaNetCookieJar主动的去除掉了domain前面的leading dot!!额?看到这就奇怪了,也就是说,其实okhttp是兼容了老版本协议带leading dot的情况。也就是说A(usa.login.com), B(.login.com)这种情况,B(.login.com)移除掉leading dot就变成B(login.com), 应该能A domain-match B 啊?
看到这,我不禁怀疑起来,那我们业务之前遇到的cookie取不到对应数据的原因到底是什么呢?这时,我又看到了这个issue:
okhttp Cookie strips off leading . from domain #2549
通过这份issue我们可以看到,这是okhttp当初的一个bug,虽然移除掉domain前面的leading dot,但是由于Android本身的CookiePolicy为ACCEPT_ORIGINAL_SERVER, 如果使用时结合JavaNetCookieStore和CookieManger时,这样的cookie数据是会被抛弃的!!CookieManager自然拿不到Token了。
并且这个bug在2016年7月份就commit修复。原来是我们项目中的okhttp版本号太旧了。因为项目中集成ReactNative的关系,目前使用的还是3.4.1的老版本,升级的话需要ReactNative这边各种依赖库同时升级,所以当前项目中的okhttp还是很早前的版本。不过正所谓无巧不成书,正好我们乘着这个机会,来看一下旧版本的okhttp为什么丢掉了这个cookie字段呢?
来看一下 JavaNetCookieJar.class
1 | public void saveFromResponse(HttpUrl url, List<Cookie> cookies) { |
再来看一下Cookie的toString()方法
1 | public String toString() { |
前面我们提到JavaNetCookieJar配合Android的CookieManager会导致这个bug。果不其然我们看到put(URI uri, Map<String, List
我们发现,cookieJar存cookie的时候,不是所有的cookie都被存下来了。而是有一步过滤!!也就是这个shouldAcceptInternal()方法,那这到底过滤掉什么cookie呢?
1 | public void put(URI uri, Map<String, List<String>> responseHeaders) |
再结合CookiePolicy的代码一看就了然了
1 | public interface CookiePolicy { |
没错,如果CookiePolicy是ACCEPT_ORIGINAL_SERVER的话,过滤掉的正是不符合HttpCookie.domainMatches()的cookie。而HttpCookie.domainMatches(),是遵循老版本RFC 2965协议的!!okhttp把domain的leading dot去掉,虽然通过了自己本身的domainMatch()方法,但是没想到,Android系统又过滤了一道,真是太坑了!!
那okhttp是如何修复这个bug的呢?大家可以参考okhttp这个commiit
Inject a leading . for better matching under JavaNetCookieJar
其实也很简单,就是第一个okhttp的domainMatch()方法判断前,去掉leading dot。然后在第二步Android系统的domainMatch()方法判断前,再把leading dot主动加回来!!
1 | /** |
总结
总结一下,啰哩啰嗦说了这么多,其实可以简单总结成以下几点:
HTTP State Management Mechanism协议针对domain-match的定义,从RFC 2965到RFC 6265有一个条件发生了很大的变化,也就是要不要leading dot的问题。RFC 2965 协议是要求一定要有leading dot的,而RFC 6265协议则明确要求不可以有leading dot。
在Android的网络请求库中,okhttp遵循的是RFC 6265协议,而java.net遵循的是RFC 2965协议。这是一个大坑,也就是意味着,如果我们采用从CookieManager中读取数据的方式的话,都需要注意着ddomain-match定义的兼容性。
okhttp不愧是Android最优秀的网络请求库,最新版本已经修复了leading dot导致的在Android CookieManager中使用JavaNetCookieJar的bug。也就是说,使用okhttp是可以做到两种协议在domain-match定义上的兼容。这对开发者来说是好事情。但是,可能你和我们一样,项目中引入了ReactNative导致很多库的版本不是最新版,还带着老版本的bug。所以,强烈建议Android开发者手动从Header中解析Set-Cookie,而不是依赖CookieManager。
问题本身很简单,但是表象可能很复杂。所以,遇到问题我们要尽可能的深入,找到原因。