前端之道:淺談cookie

語言: CN / TW / HK

依稀記得大學畢業剛入職的時候學的第一個東西:SSO(單點登入),那時候很懵懂,連最基礎的Cookie都沒有弄懂:flushed:。導師還叫我去給同年一起入職的其他童靴分享SSO,自然分享得一塌糊塗。

回頭看,Cookie這個東西說簡單也簡單,說不簡單也不簡單。後臺需要它維護會話,前端不僅需要它幹很多事,還要擔心它被盜用。

往前看,各大瀏覽器對Cookie在安全性等各方面提出了並落地了更加嚴格的新標準,每一個標準或多或少對業務有影響,都需要我們去關注。

LocalStorage的出現讓很多人在一些資料的儲存上毫不猶豫地選擇了它,當然,大多數場景上是沒有任何問題的。但是,從軟體設計的角度出發,我們在做需求實現的時候還是需要做更多的選型對比,有完整的理論支撐才能讓我們的產品更具健壯性和可維護性。

下面,跟著燒烤君徹底搞懂Cookie,讓前端路上沒有絆腳石。

Cookie 的由來——門票的故事

說到Cookie的由來,那就不得不說一下HTTP了。

無狀態協議 HTTP

HTTP是無狀態的協議,什麼叫無狀態?什麼又叫有狀態呢?

打個比方,有個旅遊景區需要門票才能進去,這張門票有效期是一天,一天之內可以憑票隨意出入。景區的售票處不管你是誰,只要你給錢它就會給一張門票。

假如某天你買了一張票,剛通過大門進景區發現電動車沒鎖,還好一天之內可以憑票隨意出入,就出去鎖電動車了,剛出景區大門就被鎖了。這是你又不想重新買票,就想和景區門衛大爺說你已經買了票了而且剛才進去了。景區大爺肯定不認啊,我這裡5A景區每天都數萬人進進出出,誰知道你是不是騙子,正義不允許大爺放你進去。

如果別人撿到了或者你把門票轉手給其他人了,別人可以拿著門票進出景區的。

很明顯你進出景區對於門衛大爺來說是無狀態的。有狀態的是你手中的那張門票,大爺只認門票。

Cookie 的誕生

早期網際網路的web網頁是沒有互動一說的,從一個頁面點選跳轉到另外一個頁面這個行為伺服器是無法得知的,這也是http無狀態的結果。但是隨著網際網路慢慢的發展,網際網路能夠做給多的東西,互動式web應運而生,但是如何記錄會話依舊是一個棘手的問題。

1994年,網景公司當時一名員工Lou Montulli(盧-蒙特利)在實現一個購物車功能的時候將Cookie首次應用性實現了。

基於當初網景瀏覽器的強大影響,這個Cookie就被各大瀏覽器所接受,並慢慢的形成了標準。

Cookie 就是那張門票

Cookie又稱為”小餅乾“,是網站為了辨別使用者身份而儲存在使用者瀏覽器上的資料。當然,這裡既然作為一種使用者瀏覽器上的資料,前端工程師也經常使用它作為一種非持久化資料的儲存方式。

HTTP請求中的Cookie

圖示流程,服務端通過Response Headers中的Set-Cookie欄位將Cookie儲存在客戶端。客戶端再次請求同一個服務的時候會將前面設定的Cookie攜帶在Request Headers的Cookie欄位中(這一行為是瀏覽器預設行為),服務端收到請求後解析Headers中的Cookie欄位即可獲取對應Cookie。

客戶端瀏覽器中的Cookie是一種非持久化的資料。同時,瀏覽器會根據伺服器設定Cookie的過期時間對Cookie有不同的處理:如果不設定過期時間,那麼這個Cookie的生命週期僅僅是當前瀏覽器執行期間,瀏覽器關閉後自動清除。

Cookie 一張不簡單的門票

Chrome瀏覽器介面F12開啟控制檯,開啟Cookies檢視頁,可以看到Cookie的屬性非常之多。

這些屬性是什麼意思,有什麼作用呢?下面跟著燒烤君一起看看這張門票有多不簡單!

name 和 value

Cookie的名稱和值,Javascript可以通過下面的方式操作Cookie:

function setCookie(cname,cvalue){
    var d = new Date();
    d.setTime(d.getTime()+(exdays*24*60*60*1000));
    document.cookie = cname+"="+cvalue+"; "
}
function getCookie(cname){
    var name = cname + "=";
    var ca = document.cookie.split(';');
    for(var i=0; i<ca.length; i++) {
        var c = ca[i].trim();
        if (c.indexOf(name)===0) { return c.substring(name.length,c.length); }
    }
    return "";
}

看起來不是很智慧的樣子,高階程式設計師要學會找別人造的輪子:

​js-cookie:A simple, lightweight JavaScript API for handling cookies​

domain 和 path

domain 是指可以訪問該Cookie的域名,path則是定義domain下的什麼路徑可以訪問該Cookie。

當設定Cookie時沒有設定domain的話,瀏覽器會預設使用當前域名。當前網頁只能訪問當前域名及當前域名的父級域名下的Cookie,如A.B.C.D 能訪問A.B.C.D、B.C.D、C.D域名下的Cookie,同理,也只能修改或設定這些域名下的Cookie。

重要的,domain、path、name構成了Cookie的唯一性:

  • domain、path、name 都相同時,代表的是同一個Cookie, 重複設定將被覆蓋
  • domain、path不同,name相同,代表的是不同的Cookie,可以共存

下圖印證了這個唯一性:

expires / max-age

expires、max-age這兩個屬性都表示同一個意思:設定Cookie的過期時間。雖然表示的是同一個意思,但是這兩個屬性還是有差別的。

expires是最初推出Cookie概念的時候設下的標準,需要設定一個具體的GMT時間作為Cookie的過期時間,當瀏覽器檢測到這個時間點已過期,將清清除對應的Cookie,如果直接設定一個過期時間,那麼這個Cookie將不生效。

max-age是HTTP1.1推出用來替代expires的,它的單位是秒,代表設定Cookie存活時間。這裡要注意一下,max-age 三種值有不同的效果:

  • max-age為負數,代表這個Cookie只是臨時,在瀏覽器關閉後會被清除
  • max-age為0,代表這個Cookie是無效的,應該被立即刪除,用於刪除Cookie
  • max-age為正數時,代表這個Cookie有效,有效期是Cookie建立時間 + max-age

如果同時存在expires 和 max-age,大多數瀏覽器都會採用max-age。

建議設定expires為Cookie的過期時間,因為max-age在IE低版本下相容性問題(如果你不care,可以使用max-age)。

雖然建議使用expires為Cookie的過期時間,但是如果使用者系統的時間被修改成其他時間,那可能會帶來不一樣的效果。

size

這個size代表的是單個Cookie的name + value的字元數,偷偷告訴你,如果value裡面含有中文字元(一般來說不會有),公式應該這樣算了:size = name + value[非中文字元] + value[中文字元] * 3。這個長度在每個瀏覽器上都是有不同的長度限制的;另一方面,每個瀏覽器對於同一個域下的Cookie總數也是有限制的,具體如下:

IE6.0

IE7.0及以上

Opera

firefox

Safari

Chrome

cookie個數

每個域為20個

每個域為50個

每個域為30個

每個域為50個

沒有個數限制

每個域為53個

cookie大小

4095個位元組

4095個位元組

4096個位元組

4097個位元組

4097個位元組

4097個位元組

所以設定Cookie的時候需要多想想這個Cookie是否是必須的,有沒有其他的方式代替,避免造成使用者瀏覽器Cookie氾濫。

珍愛每一個使用者的瀏覽器,最重要的,這樣做"環保"。

HttpOnly 和 Secure

HttpOnly是一個bool值,設定為true時意味著該Cookie無法通過js直接獲取到,只能通過網路請求由瀏覽器自動匹配攜帶。這個屬性的設定是為了防範XSS,這個後續會介紹到。

Secure屬性可防止資訊在傳遞的過程中被監聽捕獲後導致資訊洩露,也是一個bool值,如果設定為true,可以限制只有通過https訪問時,才會將瀏覽器儲存的cookie傳遞到服務端,如果通過http訪問,不會傳遞cookie。

SameSite

SameSite 屬性決定Cookie在進行跨站訪問時是否攜帶傳送,作用是防止CSRF和使用者追蹤(也就是網頁廣告),對於門票來說,就是景區需要門票上的身份資訊與持有人相符,防止你當小黃牛。

它一共有三個值:

  • Strict 表示只會在請求與當前網站相同的域名的地址時才會攜帶該域名下的Cookie,否則將不攜帶
  • Lax 表示大多數情況下是不傳送Cookie的,除了一些get資源請求之外
  • None 表示所有第三方網站請求都會攜帶Cookie,很多瀏覽器設定None的時候服務必須是同時設定secure:true才會生效

SameSite存在一定的相容性問題,因為它是一個新的Cookie標準,有些古老的瀏覽器是沒有實現這個特性的,如IE瀏覽器需要Windows 10 RS3 及之後版本才有這個特性。相關相容性如下圖:

有些瀏覽器在較早的版本就支援了該屬性,但是做了循序漸進的處理。如Chrome,其下有很多廣告的業務,為了給自己整改的機會,先是在80版本之前將SameSite的屬性預設設定成None,防止廣告機制立即失效,然後就是80版本SameSite設定的預設值改成Lax,中性處理。

Lax除了一些GET資源請求是都不會發送第三方Cookie的,這裡的條件也是很苛刻。這些GET請求的要求是會產生頂級域名變化,其實就是跳到請求地址的那個介面。那什麼是頂級域名呢?

頂級域名就是一級域名,如baidu.com、juejin.cn,而map.baidu.com是baidu.com的子域名。

產生頂級域名變化的操作有:

  • a 標籤,點選標籤後會域名跳轉到連結
  • link prerender 預載入,瀏覽器會在隱藏的tab重預載入指定網頁,等待指定網頁開啟時立即顯示。具體看著Prebrowsing
  • form表單GET請求

Lax 的條件還是很苛刻,它和Strict都能夠有效地防範CSRF,當然前提是上面的GET請求沒有做一些危險的操作。

下面用一張表來描述這個SameSite對第三方cookie帶來的變化對比:

當前地址

請求地址

SameSite型別

是否攜帶

​a.b.com​

​c.com​

Strict

任何情況都不攜帶

​a.b.com​

​c.com​

Lax

除了三種GET請求,都不攜帶

​a.b.com​

​c.com​

None

攜帶

​a.b.com​

​c.com​

None

不攜帶

​a.b.com​

​c.b.com​

Lax

攜帶

​a.b.com​

​c.b.com​

Stric

攜帶

​a.b.com​

​c.b.com​

Lax

除了三種GET請求,都不攜帶

​a.b.com​

​c.b.com​

Strict

任何情況都不攜帶

以上這個表格可以用以下知識點總結:

  • 以上的第三方是指跨站地址,跨站是指不是同站的地址,同站又是指頂級域名+二級域名相同的地址就是同站,如a.taobao.com中的taobao.com,但a.github.io和b.github.io就不屬於同站,因為github.io屬於頂級域名。
  • chrome 86版本之後的同站延申到需要兩個站點協議相同,所以,就算是頂級域名+二級域名都相同,也不屬於同站,屬於第三方站點
  • SameSite設定為None需要同時設定secure為true,而且設定cookie的站點的只能是https協議,訪問時是也只能是訪問https的站點時才會攜帶這個cookie
  • https站點中不能向http協議的站點發送請求,會出發瀏覽器混合內容限制,所以上面列表沒有這種案例

Cookie的應用

竟然Cookie的誕生為了解決持久會話的問題,會話與登入有關,單純靠一個Cookie是完成不了登入會話這個簡單又複雜的過程的。所以,出現了很多與Cookie協作的工具或者機制可以實現登入會話的維持。

如服務端使用的Session,如擴充套件性非常好的Token令牌機制,看過很多Cookie、Session、Token的區別的文章,都寫得很好,這裡就不展開了,大家可以取搜搜。

Cookie讓登入持久化變得輕輕鬆鬆。進而衍生了很多更高層次的需求如單點登入。

單點登入SSO

單點登入簡單說就是在一個地方實現了多個系統的登入。比較常見的是公司的內部系統。

如果進到一些比較有規模的公司,你會發現,公司裡面的辦公系統是真的五花八門,像OA、gitlab、kb等等。如果沒有單點登入,從A系統切換到B系統又得登入一次,這對於公司的辦公效率是大大的降低的。單點登入就是在一個系統登入成功之後可以隨意切換到其他相關的系統並且不需要登入。

如下是一個簡單的SSO系統的實現:

其中的關鍵是token如何在不同域名之間交換。圖示中是通過統一登入頁面重定向token,也可以通過jsonp或者業務系統直接請求使用者登入系統,使用後面的這兩種方式如果要使用cookie儲存,需要設定samesite為None(如果不同域),或者可以不使用cookie儲存。

其實,SSO系統就是一種登入的解耦,能夠實現不同系統共享一套使用者系統。通過統一的登入頁面和統一使用者服務,將使用者登入和使用者資訊整合起來,token的生成和交換、使用者資訊的交換都在一個地方,實現使用者系統與業務系統的解耦。

Cookie 門票的安全性

Cookie這張門票剛被創造出來的時候沒有考慮到太多的安全問題,所以Cookie大規模使用之後出現了很多安全性的問題,如XSS、CSRF。這些問題的出現同時警醒我們對於資訊保安方面需要萬分的關注和預防式的設計。

XSS

通過XSS攻擊可以通過js程式碼獲取使用者的的登入token,進而偽造請求。

document.cookie

這個很容一防禦,設定cookie的一定記得設定http-only。當然,作為一個嚴謹的開發,我們應該在伺服器對涉及敏感操作的請求進行來源鑑定,通過鑑定通過http請求頭中的以下兩個欄位:

origin: http://juejin.cn
    referer: http://juejin.cn/

CSRF

Cookie安全問題出現比較多的是CSRF(跨站請求偽造)。

CSRF的簡單流程是通過瀏覽器的cookie攜帶機制,誘騙使用者開啟偽造的網址,向目標的網站傳送請求,以達到獲取使用者資訊或者修改使用者資訊的目的。

在SameSite未出現之前,傳送第三方網站請求的cookie是預設攜帶的。SameSite出來之後大多數情況下是不攜帶cookie的,除了設定為None或者Lax。

因此,設定cookie的SameSite值也並不能完全防止CSRF攻擊。除非設定為Strict...

防禦措施有多種多樣:

  • 校驗請求的referer、origin,這個方式是最直接的,能偶防範大多數CSRF攻擊。通常情況下,referer、origin是不能夠被篡改的。
  • 設定cookie的SameSite數學為Lax或者Strict,有效禁止第三方傳送
  • CSRF token機制(如下圖),這種機制的解決方案是在頁面開啟時就注入csrf token,後續請求中通過請求頭header攜帶。規避了CSRF攻擊中自動攜帶的弊病,服務端不校驗cookie中的token,而是校驗header中的。

總結

Cookie因為其瀏覽器自動攜帶的特性為互動式頁面的快速發展做出了不可磨滅的貢獻,同時,也產生了很多問題。在瀏覽器不斷地發展的今天,Cookie也在不斷地被賦予更多的特性而沒有被淘汰,說明其作用的不可替代性。

localStorage的出現也是為了彌補Cookie本身的缺陷,但不是替代。更多時候,還是應當在不同場景使用最恰當的技術方案。Cookie,仍然值得深究!