Android OkHttp Cookie持久化問題總結
說明
最近封裝一個SDK時,遇到一個需求就是登入成功之後,APP需要持久儲存Cookie,當APP退出再進入時需要從本地讀取Cookie值,類似於瀏覽器,一個網站登入成功之後,關閉瀏覽器再開啟,還能繼續訪問這個網站網頁。

Cookie
分析
首先我們清除谷歌瀏覽器裡面快取的Cookie,當首次訪問百度 https://www.baidu.com/ ,請求體中還沒有攜帶Cookie,響應體中會出現Set-Cookie欄位,要求瀏覽器儲存Cookie,當第二次請求時會攜帶這個Cookie資訊。
請求頭(第一次請求):
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Connection: keep-alive Host: www.baidu.com Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3472.3 Safari/537.36
響應頭:
Bdpagetype: 1 Bdqid: 0xe1a8fd3600011fd8 Cache-Control: private Connection: Keep-Alive Content-Encoding: gzip Content-Type: text/html Cxy_all: baidu+c1a146ec227bccffbb8afe4da97bdf3e Date: Sat, 06 Apr 2019 09:48:35 GMT Expires: Sat, 06 Apr 2019 09:47:45 GMT P3p: CP=" OTI DSP COR IVA OUR IND COM " Server: BWS/1.1 Set-Cookie: PSTM=1554544115; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com Set-Cookie: BAIDUID=F7EBDE8F1230A7DDF1DD141A458BD04B:FG=1; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com Set-Cookie: BIDUPSID=F7EBDE8F1230A7DDF1DD141A458BD04B; expires=Thu, 31-Dec-37 23:55:55 GMT; max-age=2147483647; path=/; domain=.baidu.com Set-Cookie: delPer=0; path=/; domain=.baidu.com Set-Cookie: BDSVRTM=0; path=/ Set-Cookie: BD_HOME=0; path=/ Set-Cookie: H_PS_PSSID=1439_28794_21081_28774_28721_28558_28585_26350_28604_28625_22159; path=/; domain=.baidu.com Strict-Transport-Security: max-age=172800 Transfer-Encoding: chunked Vary: Accept-Encoding X-Ua-Compatible: IE=Edge,chrome=1
請求頭(第二次請求):
裡面攜帶Cookie資訊
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Cache-Control: max-age=0 Connection: keep-alive Cookie: BAIDUID=F7EBDE8F1230A7DDF1DD141A458BD04B:FG=1; BIDUPSID=F7EBDE8F1230A7DDF1DD141A458BD04B; PSTM=1554544115; delPer=0; BD_HOME=0; H_PS_PSSID=1439_28794_21081_28774_28721_28558_28585_26350_28604_28625_22159; BD_UPN=12314353 Host: www.baidu.com Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3472.3 Safari/537.36
現象
使用的是鴻洋的 okhttputils 網路框架,PersistentCookieStore其中存在一個bug;github上也有類似的問題 https://github.com/hongyangAndroid/okhttputils/pull/140
OkHttpClient okHttpClient = new OkHttpClient.Builder() .connectTimeout(10000L, TimeUnit.MILLISECONDS) .readTimeout(10000L, TimeUnit.MILLISECONDS) .cookieJar(new CookieJarImpl(new PersistentCookieStore(this))) //.cookieJar(new CookieJarImpl(new MemoryCookieStore())) .addInterceptor(new LoggerInterceptor("TAG")) .build(); OkHttpUtils.initClient(okHttpClient);
String top250 = "http://api.douban.com/v2/movie/top250"; // 配置基本網路請求 OkHttpUtils.get().url(top250) .build() .execute(new StringCallback() { @Override public void onError(Call call, Exception e, int id) { Log.d(TAG, " 失敗:" + e.toString()); } @Override public void onResponse(String response, int id) { Log.d(TAG, " 成功:" + response); } });
當設定記憶體儲存Cookie時(MemoryCookieStore),第二次訪問攜帶上Cookie,但是退出APP之後就丟失了。

當設定永久儲存Cookie時(PersistentCookieStore),第二次訪問還是沒有攜帶上Cookie,

image.png

PersistentCookieStore程式碼實現

persistent值
從原始碼上可以看出,當請求頭中存在expires和max-age時,返回為True,這個時候PersistentCookieStore是不對Cookie進行磁碟、記憶體儲存的,這裡只是設定一個Cookie的有效期,此時Cookie值並沒有過期。
維持持久化Cookie,推薦使用持久化cookie框架, PersistentCookieJar ,
ClearableCookieJar cookieJar = new PersistentCookieJar(new SetCookieCache(), new SharedPrefsCookiePersistor(this)); OkHttpClient okHttpClient = new OkHttpClient.Builder() .connectTimeout(10000L, TimeUnit.MILLISECONDS) .readTimeout(10000L, TimeUnit.MILLISECONDS) .cookieJar(cookieJar) //.cookieJar(new CookieJarImpl(new PersistentCookieStore(this))) //.cookieJar(new CookieJarImpl(new MemoryCookieStore())) .addInterceptor(new LoggerInterceptor("TAG")) .build(); OkHttpUtils.initClient(okHttpClient);

Cookie未儲存

Cookie過濾條件

persistent

Cookie判斷
從原始碼上可以看出,當請求頭中不存在expires和max-age時,返回為False,這個時候PersistentCookieJar是不對Cookie進行磁碟儲存的。
另外一種情況
okttp3訪問IP地址Cookie丟失的現象,這裡使用百度的IP地址:http://220.181.112.244:80/,
//這裡使用百度IP地址 String baidu = "http://220.181.112.244:80/"; // 配置基本網路請求 OkHttpUtils.get().url(baidu) .build() .execute(new StringCallback() { @Override public void onError(Call call, Exception e, int id) { Log.d(TAG, " 失敗:" + e.toString()); } @Override public void onResponse(String response, int id) { Log.d(TAG, " 成功:" + response); } });

丟失Cookie情況
檢視OkHttp-3.3.1底層Cookie實現,可以看到這一部分程式碼:
... } else if (attributeName.equalsIgnoreCase("domain")) { try { domain = parseDomain(attributeValue); hostOnly = false; } catch (IllegalArgumentException e) { // Ignore this attribute, it isn't recognizable as a domain. } } ... // If the domain is present, it must domain match. Otherwise we have a host-only cookie. if (domain == null) { domain = url.host(); } else if (!domainMatch(url, domain)) { return null; // No domain match? This is either incompetence or malice! } ... for (int i = 0, size = cookieStrings.size(); i < size; i++) { Cookie cookie = Cookie.parse(url, cookieStrings.get(i)); if (cookie == null) continue; if (cookies == null) cookies = new ArrayList<>(); cookies.add(cookie); }
當請求頭中存在domain時,這個時候主地址為ip與domian不等,Cookie解析失敗為null,導致儲存Cookie失敗,這個瀏覽器也是存在問題的,這個得後臺注意格式。

瀏覽器情況
程式碼實現
第一種實現方式(攔截器實現)
這裡為了安全可以對Cookie進行加密儲存,可以使用這個SharedPreferences加密庫, https://github.com/iamMehedi/Secured-Preference-Store
mSharedPreferences = getSharedPreferences("Cookie_Pre", Context.MODE_PRIVATE); cookies = new HashMap<>(); OkHttpClient okHttpClient = new OkHttpClient.Builder() .connectTimeout(10000L, TimeUnit.MILLISECONDS) .readTimeout(10000L, TimeUnit.MILLISECONDS) //網路攔截器 .addInterceptor(new Interceptor() { @Override public Response intercept(Chain chain) throws IOException { //獲取請求連結 Request originalRequest = chain.request(); //獲取url的主機地址 String hostString = originalRequest.url().host(); if (!cookies.containsKey(hostString)) { //獲取磁盤裡面的spCookie字串 String spCookie = mSharedPreferences.getString(hostString, ""); if (!TextUtils.isEmpty(spCookie)) { //獲取spCookie解密放到記憶體中 cookies.put(hostString, spCookie); } } //獲取記憶體中的Cookie String memoryCookie = cookies.get(hostString); //攔截網路請求資料 Request request = originalRequest.newBuilder() //設定請求頭Cookie值 .addHeader("Cookie", memoryCookie == null ? "" : memoryCookie) .build(); //攔截返回資料 Response originalResponse = chain.proceed(request); //判斷請求頭裡面是否有Set-Cookie值,更新Cookie if (!originalResponse.headers("Set-Cookie").isEmpty()) { //字串集 StringBuilder stringBuilder = new StringBuilder(); for (String header : originalResponse.headers("Set-Cookie")) { stringBuilder.append(header); stringBuilder.append(";"); } //拼接Cookie成字串 String cookie = stringBuilder.toString(); //更新記憶體中Cookies值 cookies.put(hostString, cookie); //儲存到本地磁碟中 SharedPreferences.Editor editor = mSharedPreferences.edit(); //儲存cookie(為了安全這裡可以加密儲存) editor.putString(hostString, cookie); editor.apply(); Log.e("Set-Cookie", "cookies: " + cookie + " host: " + hostString); } return originalResponse; } }) .addInterceptor(new LoggerInterceptor("TAG")) .build(); OkHttpUtils.initClient(okHttpClient);
第二種實現方式(繼承CookieJar實現)
這裡可以參考OKGO裡面實現的庫, Cookie ,實現
CookieJarImpl繼承CookieJar和SPCookieStore。
public class SPCookieStore implements CookieStore { private static final String COOKIE_PREFS = "okhttp_cookie";//cookie使用prefs儲存 private static final String COOKIE_NAME_PREFIX = "cookie_";//cookie持久化的統一字首 private final Map<String, ConcurrentHashMap<String, Cookie>> cookies; private final SharedPreferences cookiePrefs; public SPCookieStore(Context context) { cookiePrefs = context.getSharedPreferences(COOKIE_PREFS, Context.MODE_PRIVATE); cookies = new HashMap<>(); //將持久化的cookies快取到記憶體中,資料結構為 Map<Url.host, Map<CookieToken, Cookie>> Map<String, ?> prefsMap = cookiePrefs.getAll(); for (Map.Entry<String, ?> entry : prefsMap.entrySet()) { if ((entry.getValue()) != null && !entry.getKey().startsWith(COOKIE_NAME_PREFIX)) { //獲取url對應的所有cookie的key,用","分割 String[] cookieNames = TextUtils.split((String) entry.getValue(), ","); for (String name : cookieNames) { //根據對應cookie的Key,從xml中獲取cookie的真實值 String encodedCookie = cookiePrefs.getString(COOKIE_NAME_PREFIX + name, null); if (encodedCookie != null) { Cookie decodedCookie = SerializableCookie.decodeCookie(encodedCookie); if (decodedCookie != null) { if (!cookies.containsKey(entry.getKey())) { cookies.put(entry.getKey(), new ConcurrentHashMap<String, Cookie>()); } cookies.get(entry.getKey()).put(name, decodedCookie); } } } } } } private String getCookieToken(Cookie cookie) { return cookie.name() + "@" + cookie.domain(); } /** 當前cookie是否過期 */ private static boolean isCookieExpired(Cookie cookie) { return cookie.expiresAt() < System.currentTimeMillis(); } /** 將url的所有Cookie儲存在本地 */ @Override public synchronized void saveCookie(HttpUrl url, List<Cookie> urlCookies) { for (Cookie cookie : urlCookies) { saveCookie(url, cookie); } } @Override public synchronized void saveCookie(HttpUrl url, Cookie cookie) { if (!cookies.containsKey(url.host())) { cookies.put(url.host(), new ConcurrentHashMap<String, Cookie>()); } //當前cookie是否過期 if (isCookieExpired(cookie)) { removeCookie(url, cookie); } else { saveCookie(url, cookie, getCookieToken(cookie)); } } /** 儲存cookie,並將cookies持久化到本地 */ private void saveCookie(HttpUrl url, Cookie cookie, String cookieToken) { //記憶體快取 cookies.get(url.host()).put(cookieToken, cookie); //檔案快取 SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); prefsWriter.putString(url.host(), TextUtils.join(",", cookies.get(url.host()).keySet())); prefsWriter.putString(COOKIE_NAME_PREFIX + cookieToken, SerializableCookie.encodeCookie(url.host(), cookie)); prefsWriter.apply(); } /** 根據當前url獲取所有需要的cookie,只返回沒有過期的cookie */ @Override public synchronized List<Cookie> loadCookie(HttpUrl url) { List<Cookie> ret = new ArrayList<>(); if (!cookies.containsKey(url.host())) return ret; Collection<Cookie> urlCookies = cookies.get(url.host()).values(); for (Cookie cookie : urlCookies) { if (isCookieExpired(cookie)) { removeCookie(url, cookie); } else { ret.add(cookie); } } return ret; } /** 根據url移除當前的cookie */ @Override public synchronized boolean removeCookie(HttpUrl url, Cookie cookie) { if (!cookies.containsKey(url.host())) return false; String cookieToken = getCookieToken(cookie); if (!cookies.get(url.host()).containsKey(cookieToken)) return false; //記憶體移除 cookies.get(url.host()).remove(cookieToken); //檔案移除 SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); if (cookiePrefs.contains(COOKIE_NAME_PREFIX + cookieToken)) { prefsWriter.remove(COOKIE_NAME_PREFIX + cookieToken); } prefsWriter.putString(url.host(), TextUtils.join(",", cookies.get(url.host()).keySet())); prefsWriter.apply(); return true; } @Override public synchronized boolean removeCookie(HttpUrl url) { if (!cookies.containsKey(url.host())) return false; //記憶體移除 ConcurrentHashMap<String, Cookie> urlCookie = cookies.remove(url.host()); //檔案移除 Set<String> cookieTokens = urlCookie.keySet(); SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); for (String cookieToken : cookieTokens) { if (cookiePrefs.contains(COOKIE_NAME_PREFIX + cookieToken)) { prefsWriter.remove(COOKIE_NAME_PREFIX + cookieToken); } } prefsWriter.remove(url.host()); prefsWriter.apply(); return true; } @Override public synchronized boolean removeAllCookie() { //記憶體移除 cookies.clear(); //檔案移除 SharedPreferences.Editor prefsWriter = cookiePrefs.edit(); prefsWriter.clear(); prefsWriter.apply(); return true; } /** 獲取所有的cookie */ @Override public synchronized List<Cookie> getAllCookie() { List<Cookie> ret = new ArrayList<>(); for (String key : cookies.keySet()) { ret.addAll(cookies.get(key).values()); } return ret; } @Override public synchronized List<Cookie> getCookie(HttpUrl url) { List<Cookie> ret = new ArrayList<>(); Map<String, Cookie> mapCookie = cookies.get(url.host()); if (mapCookie != null) ret.addAll(mapCookie.values()); return ret; } }
//當前cookie是否過期 if (isCookieExpired(cookie)) { removeCookie(url, cookie); } else { saveCookie(url, cookie, getCookieToken(cookie)); } /** 當前cookie是否過期 */ private static boolean isCookieExpired(Cookie cookie) { return cookie.expiresAt() < System.currentTimeMillis(); }
【總結】這裡儲存持久化Cookie的關鍵看expiresAt與當前時間戳相比是否為過期,而不是看響應頭裡是否存在expires和max-age欄位。
使用與之前類似:
OkHttpClient okHttpClient = new OkHttpClient.Builder() .connectTimeout(10000L, TimeUnit.MILLISECONDS) .readTimeout(10000L, TimeUnit.MILLISECONDS) .cookieJar(new CookieJarImpl(new SPCookieStore())) .addInterceptor(new LoggerInterceptor("TAG")) .build(); OkHttpUtils.initClient(okHttpClient);
總結
後臺對Cookie返回格式還是要規範一點,否則Cookie持久化儲存會出現莫名其妙的錯誤。