Cookie 机制: Android VS IOS (抽奖 H5 引发的惨案)

功能描述

在 APP 中有一个积分抽奖的 H5 页面,要求 抽奖H5 的登录状态必须和本地的登录状态一致,也就是说:如果尚未登录,点击 H5 的抽奖按钮则跳转登录,如果已经登录那么则直接可以抽奖。


早期开发描述

用户可用的 cookie 是在 登录接口 中返回的,但是对应在 Android 和 IOS 两个平台却有着不同的表现:

IOS : IOS 平台在请求接口之后,对于 WebView 而言,就也处于登录状态,也就是说,在调用 登录接口 之后,就可以抽奖了,不需要任何其他的代码,猜测IOS 统一管理了 cookie ,即 IOS 将 接口请求产生的 cookie 和 WebView 浏览网页产生的 cookie 放在了一处,甚至于可以混合使用对方的 cookie.PS: 这虽然是个猜测,但是从现象上来看,八九不离十,另外这个猜测很重要!

Android: 而在Android这边,和IOS完全不同,在调用了 登录接口 之后,WebView 的状态依然处于未登录状态,这样看下来,Android 平台并没有对 接口请求 相关的 cookie 做相应管理,换句话说 Android 中请求所产生的 cookie 根本没有被记录下来所以才不得不使用 CookieManager 对 WebView 进行手动的 cookie 机制(仿照浏览器)

相同点: 当然作为一个标准浏览器,在关闭 app 之后,cookie 自然就会被清理掉,这一点不管是 IOS 还是 Android 平台都遵循了这个规则。于是当第二次再次进入 App 之后,本地认为自己是 登录了,但是再次调用 WebView 时,WebView 中的 cookie 其实已经被清除掉了,H5 自然也就认为自己没有登录。所以,为了统一登录状态,必须在 App 每次开启都将 WebView 的 cookie 动态强制设置成上一次登录成功后的 cookie。(事实上,IOS 暂且不提,因为我了解有限,但是 Android 平台上,如果不在 App 每次启动的时候,调用 CookieManager 设置 cookie,不同的手机的表现却令人摸不着头脑,有的手机则如之前所描述的标准浏览器的规则,有的则是,本地和 WebView 的登录状态是一致的,就好像浏览器状态永远保持着,倒是没有试过关机会不会清除状态 cookie,还有的手机保持了一段时间的 cookie 后,貌似就清除了,因为登录状态也不一致了。这也算是 Android 碎片化 的问题之一?)

最初实现

**重要信息:**在登录接口中的 cookie 包含 sid 、path、还有过期字段: sid=54f85966-ed12-48be-9910-f9c01cadc8e4;Path=/1bPlus-web;Max-Age=2592000; Expires=Thu, 12-Jul-2018 10:06:10 GMT; HttpOnly,在保存 sid 信息的时候,项目值保存了,sid相关的部分:sid=54f85966-ed12-48be-9910-f9c01cadc8e4

IOS: IOS 平台在登录接口的回调中,将 接口的 Response Header 中的 cookie 记录在了本地,也仅仅只是记录 sid = "我是一段hash"。然后在 APP 重启的时候通过 api 动态设置当前 WebView 的 cookie 为之前登录后记录下的 cookie ,没有设置 path,那么默认就是 Path=/

Android: Android 平台,在 登录接口 的回调中,不仅仅需要将 cookie 记录在本地,还需要将 cookie 手动的设置到 WebView 中,这样才能刷新 APP 中 WebView 的登录状态。另外在 APP 启动时,也必须设置当前 WebView 的 cookie 为之前登录后记录下的 cookie ,没有设置 path,那么默认就是 Path=/


Android平台 问题的诞生

####重要参数相关:
base url: http://www.abc.com
抽奖 url: http://www.abc.com/1bPlus-web/....
项目中cookie设有30天的过期时间
在用户退出登录后,app 并没有清除 cookie。
在登录接口返回的cookie中,Path是/1bPlus-web
可以适当有个印象,在后面分析问题的过程中会用到

####代码

String sidCookie = parseSidCookie(response);
CommonUtils.syncCookie(LoginActivity.this, CommonUtils.getBaseUrl(), sidCookie);
public String parseSidCookie(Response response) {
        List<String> headers = response.headers().values("set-cookie");
        String sidCookie = null;
        for (String header : headers) {
            List<String> cookies = Arrays.asList(header.split(";"));
            for (String cookie : cookies) {
                if (cookie.contains("sid")) {
                    sidCookie = cookie.trim();
                    break;
                }
            }
        }
        return sidCookie;
    }
public static boolean syncCookie(Context context, String url, String cookie) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            CookieManager cookieManager = CookieManager.getInstance();
            cookieManager.removeSessionCookies(null);
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
            cookieManager.flush();
            String newCookie = cookieManager.getCookie(url);
            return !TextUtils.isEmpty(newCookie);
        } else {
            CookieSyncManager.createInstance(context);
            CookieManager cookieManager = CookieManager.getInstance();
            cookieManager.removeSessionCookie();
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
            String newCookie = cookieManager.getCookie(url);
            CookieSyncManager.getInstance().sync();
            return !TextUtils.isEmpty(newCookie);
        }
    }

仔细检查过代码,从流程上而言是正确的。

**重要:**问题就出在 cookieManager.setCookie(url,cookie);这一行,其中 url 是项目的 host: 类似于 http://www.abc.com, cookie: sid=我是一个测试的hash,其中需要注意的是,保存&设置的 cookie 并没有保留 path 等字符串(看 parseSidCookie 方法)。那么该 cookie 默认的 path 自然就是根:/

####现象

现象0:用户在还未登录过的状态下,也就是从未保存&设置过 cookie 的状态,先进入 抽奖H5 页面,从 H5 唤醒登录界面,登录之后,返回抽奖页面,无法抽奖,点击没有效果,也就是 H5 认为当前用户并没有登录。即便退出登录,再次进入抽奖,本地和H5的登录状态依然不一致。

现象1:用户进入 app ,二话不说,直接打开登录页面,登录之后,进入抽奖页面,没有任何问题,就是项目想要的效果,并且即便退出app了,再次进入APP,也是没有问题的。

现象2:在 现象1 的基础上,用户已经登录过了,并且进入过抽奖页面。然后用户退出帐号,此时从 抽奖H5 唤醒登录,即便登录之后,也是无法抽奖。事实上,用户一旦浏览了任何和抽奖H5有相同Path的网页,然后,不管是从网页唤醒登录,还是主动登录,都是无法抽奖的。

####分析
cookie 的相关机制:
首先,需要知道的是,不管你是否登录,作为标准浏览器,WebView 只要打开了网页,那么总会产生cookie,并且记录下来,比如你先访问了一个网址: http://www.abc.com/article/yours/1,产生了浏览器里面的第一个 cookieA0,然后我又访问了另外一个网址,按照规则会有以下情况:

  1. 如果访问的网址 依然在 /article/yours 这个Path 下,如果 cookieA0 依然有效,也就是用户既没有退出登录,该 cookie 也没有过期,那么服务器也就不会再给浏览器分配 cookie,此时相同 path 下,共用相同的 cookie。

  2. 但如果你访问了http://www.abc.com/article/2,又或者是http://www.abc.com/article/mine/1,甚至是 http://www.abc.com/others/1,那么浏览器是找不到这些 Path(/article, /article/mine, /others )相对应的cookie 的,因为浏览器只能找到/article/yours所对应的cookie,相应的服务器就是为浏览器创造对应的cookie,供浏览器选择。

  3. 但是如果此时得到cookieA0是你先访问 http://www.abc.com/article/2 该网址所得到的cookie,那么此时你再访问 http://www.abc.com/article/yours/1,在没有/article/yours对应cookie的情况下,/article对应的cookieA0就会被找出来。这就是选择最优cookie的意思吧。举个栗子:假如此时浏览器里面保存了两个cookie的值分别是:cookieA1 对应的Path是/article;cookieA2对应Path/article/yours,如果我访问的网址是http://www.abc.com/article/yours/...,那么浏览器找到的cookieA2,其实如果没有cookieA2,cookieA1也是合法的,不过在有更精确cookie值的情况下,自然选择更加精确的cookie值。

####现象分析:
现象0的分析: — 在用户从未登录过,本地没有cookie的情况下,先进入H5抽奖,此时立刻就产生了一个Path是/1bPlus-web的cookieB0,然后通过该网页唤醒了登录界面,登录成功后通过CookieManager设置了登录接口所返回的cookieB1,代码:

...
url = base url;
//前面有提到,没有设置path的cookie对应的就是根目录 /
cookieB1 = "sid=我就是个hash,没有Path哦亲!"
cookieManager.setCookie(url, cookieB1);
...

在运行完以上的代码后,现在在本地WebView里面,就存在了两个 cookie, 它们分别是对应着Path是/1bPlus-web的cookieB0,还有对应着根目录/的cookieB1。

**问题的原因:**在登录之后,服务器返回的它认为的登录合法的cookie是cookieB1,也就是如果我想让服务器也认为我登录了,那么我WebView带过去的cookie也必须是cookieB1。但实际上,从前面cookie的机制中可以了解到,在本地的两个cookie: cookeB0对应的Path是/1bPlus-web,cookieB1对应的是/,那么当你访问网址为http://www.abc.com/1bPlus-web/....的抽奖H5时,所能找到的cookie就只能是cookieB0,和服务器所认为的合法的cookieB1,永远无法对应上!在这种情况下,即便用户退出帐号,重新登录,也毫无作用。

现象1的分析:
用户打开任何Path包含/1bPlus-web网址,而直接登录,那么即便本地有很多不同的cookie,但是在此时进入抽奖H5时,能够被WebView找到合法的cookie,只有登录成功后所记录下来,对应Path是根/的cookie,其他的cookie因为Path无法对应所以也就不合法。此时本地能所能提供的cookie和服务器认为合法的cookie,就是一致的,自然可以抽奖。即便退出app,再次打开,前面我提到过,不管是android还是IOS都会在重新启动app的时候,把cookie重新设置进WebView。在APP退出后,WebView所有cookie会被全部清除(即便不清除,之前本身就只能认定Path是/的cookie合法),此时设置的cookie所对应的path即便是/,此时进入H5抽奖,也只能找到/所对应的cookie,而且此cookie也是合法的未过期的,那么自然没有任何问题。

现象2的分析:
既然现象2是基于现象1的,从现象1的分析中,可以知道,此时状态是正确的,并且本地也就只有一个对应/的cookie,这里把它称作cookieC0。
当用户退出帐号,前面提到过,并不会对本地的cookie做任何处理,那么在这种情况下,首先服务器会将之前合法的cookieC0,认定为失效不合法的cookie,那么进入H5抽奖(不单单是H5抽奖,任何与抽奖有相同Path的网页都一样),通过网页唤醒登录:此时进入抽奖,找到了本地的cookieC0,但是服务器认为这是不合法的cookie,会为该网页重新分配一个合法的cookieC1,此时cookieC1所对应的Path就是抽奖网址的path — /1bPlus-web,于是又回到了现象0的情况(可以看现象0的分析),本地的cookie和服务器的cookie不一致!

为什么IOS没有问题?
相同的实现机制,Android出问题了,但是IOS却没有出问题,这是为什么?其实在前面的章节—最初实现描述中已经提到过,对于接口请求中所产生的cookie,Android平台没有做任何处理,直接忽视了;但是IOS却是将其也管理了起来。相同的代码逻辑,保证了两个平台在登录成功后,本地都有了Path为/的cookie,但是IOS统一管理cookie的机制,却又相当于在本地多了一个Path为1bPlus-web的cookie(可以参考下前面提到过的重要参数),于是不管怎么操作,IOS平台的WebView永远能够找到合法的cookie。

问题解决

**方法一:**对于没有特殊需求的app,可以将removeSessionCookie()替换成removeAllCookie()
再次粘贴下设置cookie的方法:

public static boolean syncCookie(Context context, String url, String cookie) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            CookieManager cookieManager = CookieManager.getInstance();
            //请关注这个方法 removeSessionCookie
            cookieManager.removeSessionCookies(null);
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
            cookieManager.flush();
            String newCookie = cookieManager.getCookie(url);
            return !TextUtils.isEmpty(newCookie);
        } else {
            CookieSyncManager.createInstance(context);
            CookieManager cookieManager = CookieManager.getInstance();
             //请关注这个方法 removeSessionCookie
            cookieManager.removeSessionCookie();
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
            String newCookie = cookieManager.getCookie(url);
            CookieSyncManager.getInstance().sync();
            return !TextUtils.isEmpty(newCookie);
        }
    }

打开CookieManager,看到对 removeSessionCookie 方法的解释
Removes all session cookies, which are cookies without an expiration
很明显是,这是用来删除没有过期时间的cookie的。其实在更早之前,app本来使用的是removeAllCookie(),从方法名就可以明白,这个方法是清除所有缓存的cookie,如果在登录后,先将所有cookie都清除掉,再设置Path是/的cookie,那么就没有任何问题了(有些不明白的话,可以看下上面的cookie机制,还有现象分析)。
之后的改动,是为了保证引入的环信客服Web页面(从我3年前开始写代码,尽跟环信打交道了…狻猊很),在关闭该页面后,在app关闭之前,都能保留用户与客服的交流记录。所以为了这一需求,才将removeAllCookie()改成了removeSessionCookie(),前面。提到过,服务器返回的cookie也是有过期时间的,客户端和服务端对于Android平台cookie机制的不理解,以及IOS平台不同cookie管理机制的干扰下,造成了这一费解的bug。

**方法二:**现象分析中,可以看出,其实归根结底就是找不到Path是1bPlus-web的合法cookie,那么其实仿照IOS平台,Android自己来管理接口cookie,譬如将刷新cookie的方法改造下:

public static boolean syncCookie(Context context, String url, String cookie) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            CookieManager cookieManager = CookieManager.getInstance();
            cookieManager.removeSessionCookies(null);
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
            //敲黑板了,这里是重点
            cookieManager.setCookie(url, cookie+";Path=/1bPlus-web");
            cookieManager.flush();
            String newCookie = cookieManager.getCookie(url);
            return !TextUtils.isEmpty(newCookie);
        } else {
            CookieSyncManager.createInstance(context);
            CookieManager cookieManager = CookieManager.getInstance();
            cookieManager.removeSessionCookie();
            cookieManager.setAcceptCookie(true);
            cookieManager.setCookie(url, cookie);
              //敲黑板了,这里是重点
            cookieManager.setCookie(url, cookie+";Path=/1bPlus-web");
            String newCookie = cookieManager.getCookie(url);
            CookieSyncManager.getInstance().sync();
            return !TextUtils.isEmpty(newCookie);
        }
    }

于是在运行了上面的代码后,本地就像IOS一样缓存了两个Path下的cookie,并且其实是一样的,完美解决。app现在就是这么改的。

**当然还有个方法三:**其实,如果让后台把cookie改成没有过期时间的,那么removeSessionCookie()这个方法会向removeAllCookie()一样,把本地的所有cookie都清除了,那么就像现象1中所述的一样了,在登录后本地就只剩下对应/的唯一一个cookie了,啊哈哈哈哈…不言中~不言中~

**Max-Age:**最后还需要注意的就是Max-Age,cookie的过期时间的问题(文章最后我会贴一篇关于cookie的文章)。关于Max-Age Android 平台的试验:在我没有打开过任何网页的情况下,登录后,我将设置cookie的代码改动了,如下:

cookieManager.setCookie(url, cookie+";Path=/1bPlus-web;Max-Age=25");

在过了25S之后,进入抽奖,抽奖异常。所以本地的Max-Age也是有效的。更多的关于Cookie可以看下面的参考链接,足够详细。另外,前面在早期开发描述中提到过,两个平台在关闭APP之后,就清除Cookie,这也是因为没有设置Max-Age,默认是-1。


至此,这个bug算是结束了,这个Bug断断续续折腾了项目挺长时间,后来总结了下原因:

  1. 对cookie机制的不熟悉
  2. 对Android&IOS平台cookie的不熟悉
  3. bug最初发生的时候,没有进行深入的思考

感谢我Leader帮着我分析到晚上8点…提供了相当多的参考想法(解决后我又有些不好意思的问了好几次后台细节问题,然后我Leader不厌其烦的跟我说了两遍,感谢!!!)

最后,文章中如果又错误的地方,请读者留言指出,最好给个相关链接,不胜感激了!

参考文章:
理解Cookie和Session机制

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值