Cookie 機制: Android VS IOS (抽獎 H5 引發的慘案)

NO IMAGE

功能描述

在 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機制