前端鑒權的兄弟們:cookie、session、token、jwt、單點登錄

前端鑒權的兄弟們:cookie、session、token、jwt、單點登錄

基於 http 的前端鑒權背景,cookie 為什麼是最方便的存儲方案,有哪些操作 cookie 的方式,session 方案是如何實現的,存在哪些問題

最后更新 2022/4/27 上午7:31
HenryLulu_几木
预计阅读 19 分钟
分类
前端
标签
鑒權

本文你將看到:

  1. 基於 http 的前端鑒權背景
  2. cookie 為什麼是最方便的存儲方案,有哪些操作 cookie 的方式
  3. session 方案是如何實現的,存在哪些問題
  4. token 方案是如何實現的,如何進行編碼和防篡改?jwt 是做什麼的?refresh token 的實現和意義
  5. session 和 token 有什麼異同和優缺點
  6. 單點登錄是什麼?實現思路和在瀏覽器下的處理

1. 從狀態說起

1.1 http 無狀態

我們知道,http 是無狀態的。也就是說,http 請求方和響應方間無法維護狀態,都是一次性的,它不知道前後的請求都發生了什麼。 但有的場景下,我們需要維護狀態。最典型的,一個用戶登錄微博,發布、關注、評論,都應是在登錄後的用戶狀態下的。

1.2標記

那解決辦法是什麼呢?標記

在學校或公司,入學入職那一天起,會錄入你的身份、帳戶信息,然後給你發個卡,今後在園區內,你的門禁、打卡、消費都只需要刷這張卡。

1.3前端存儲

這就涉及到一發、一存、一帶,發好辦,登錄接口直接返回給前端,存儲就需要前端想辦法了。

前提是,你要把卡帶在身上。

前端的存儲方式有很多。

  • 最矬的,掛到全局變量上,但這是個“體驗卡”,一次刷新頁面就沒了
  • 高端點的,存到 cookie、localstorage 等里,這屬於“會員卡”,無論怎麼刷新,只要瀏覽器沒清掉或者過期,就一直拿著這個狀態。

前端存儲這裡不展開了。

有地方存了,請求的時候就可以拼到參數裡帶給接口了。

可是前端好麻煩啊,又要自己存,又要想辦法帶出去,有沒有不用操心的?

有,cookie。

cookie 也是前端存儲的一種,但相比於 localstorage 等其他方式,藉助 http 頭、瀏覽器能力,cookie 可以做到前端無感知。

一般過程是這樣的:

  • 在提供標記的接口,通過 http 返回頭的 set-cookie 欄位,直接“種”到瀏覽器上
  • 瀏覽器發起請求時,會自動把 cookie 通過 http 請求頭的 cookie 欄位,帶給接口

2.1配置:domain/path

你不能拿清華的校園卡進北大。

cookie 是要限制::“空間範圍”::的,通過 domain(域)/path(路徑)兩級。

domain 屬性指定瀏覽器發出 http 請求時,哪些域名要附帶這個 cookie。如果沒有指定該屬性,瀏覽器會默認將其設為當前 url 的一級域名,比如www.example.com會設為example.com,而且以後如果訪問 example.com 的任何子域名,http 請求也會帶上這個 cookie。如果伺服器在 set-cookie 欄位指定的域名,不屬於當前域名,瀏覽器會拒絕這個 cookie。

path 屬性指定瀏覽器發出 http 請求時,哪些路徑要附帶這個 cookie。只要瀏覽器發現,path 屬性是 http 請求路徑的開頭一部分,就會在頭信息裡面帶上這個 cookie。比如,path 屬性是/,那麼請求/docs 路徑也會包含該 cookie。當然,前提是域名必須一致。

—— Cookie — JavaScript 标准参考教程(alpha)

2.2配置:expires/max-age

你畢業了卡就不好使了。

cookie 還可以限制::“時間範圍”::,通過 expires、max-age 中的一種。

expires 屬性指定一個具體的到期時間,到了指定時間以後,瀏覽器就不再保留這個 cookie。它的值是 utc 格式。如果不設置該屬性,或者設為 null,cookie 只在當前會話(session)有效,瀏覽器窗口一旦關閉,當前 session 結束,該 cookie 就會被刪除。另外,瀏覽器根據本地時間,決定 cookie 是否過期,由於本地時間是不精確的,所以沒有辦法保證 cookie 一定會在伺服器指定的時間過期。

max-age 屬性指定從現在開始 cookie 存在的秒數,比如 60 _ 60 _ 24 * 365(即一年)。過了這個時間以後,瀏覽器就不再保留這個 cookie。

如果同時指定了 expires 和 max-age,那麼 max-age 的值將優先生效。

如果 set-cookie 欄位沒有指定 expires 或 max-age 屬性,那麼這個 cookie 就是 session cookie,即它只在本次對話存在,一旦用戶關閉瀏覽器,瀏覽器就不會再保留這個 cookie。

—— Cookie — JavaScript 标准参考教程(alpha)

2.3配置:secure/httponly

》有的學校規定,不帶卡套不讓刷(什麼奇葩學校,假設);有的學校不讓自己給卡貼貼紙。

cookie 可以限制::“使用方式”::。

secure 屬性指定瀏覽器只有在加密協議 https 下,才能將這個 cookie 發送到伺服器。另一方面,如果當前協議是 http,瀏覽器會自動忽略伺服器發來的 secure 屬性。該屬性只是一個開關,不需要指定值。如果通信是 https 協議,該開關自動打開。

httponly 屬性指定該 cookie 無法通過 javascript 腳本拿到,主要是 document.cookie 屬性、xmlhttprequest 對象和 request api 都拿不到該屬性。這樣就防止了該 cookie 被腳本讀到,只有瀏覽器發出 http 請求時,才會帶上該 cookie。

—— Cookie — JavaScript 标准参考教程(alpha)

回過頭來,http 是如何寫入和傳遞 cookie 及其配置的呢?

http 返回的一個 set-cookie 頭用於向瀏覽器寫入“一條(且只能是一條)”cookie,格式為 cookie 鍵值 + 配置鍵值。例如:

Set-Cookie: username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

那我想一次多 set 幾個 cookie 怎麼辦?多給幾個 set-cookie 頭(一次 http 請求中允許重複)

Set-Cookie: username=jimu; domain=jimu.com
Set-Cookie: height=180; domain=me.jimu.com
Set-Cookie: weight=80; domain=me.jimu.com

http 請求的 cookie 頭用於瀏覽器把符合當前“空間、時間、使用方式”配置的所有 cookie 一併發給服務端。因為由瀏覽器做了篩選判斷,就不需要歸還配置內容了,只要發送鍵值就可以。

Cookie: username=jimu; height=180; weight=80

前端可以自己创建 cookie,如果服务端创建的 cookie 没加HttpOnly,那恭喜你也可以修改他给的 cookie。

调用document.cookie可以创建、修改 cookie,和 HTTP 一样,一次document.cookie能且只能操作一个 cookie。

document.cookie = 'username=jimu; domain=jimu.com; path=/blog; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly';

调用document.cookie也可以读到 cookie,也和 HTTP 一样,能读到所有的非HttpOnly cookie。

console.log(document.cookie);
// username=jimu; height=180; weight=80

(就一個 cookie 屬性,為什麼讀寫行為不一樣?get/set 了解下)

了解了 cookie 後,我們知道 cookie 是最便捷的維持 http 請求狀態的方式,大多數前端鑒權問題都是靠 cookie 解決的。當然也可以選用別的存儲方式(後面也會多多少少提到)。

那有了存儲工具,接下來怎麼做呢?

3. 應用方案:服務端 session

現在回想下,你刷卡的時候發生了什麼?

其實你的卡上只存了一個 id(可能是你的學號),刷的時候物業系統去查你的信息、帳戶,再決定“這個門你能不能進”“這個雞腿去哪個帳戶扣錢”。

這種操作,在前後端鑒權系統中,叫 session。

典型的 session 登錄/驗證流程:

  1. 瀏覽器登錄發送帳號密碼,服務端查用戶庫,校驗用戶
  2. 服務端把用戶登錄狀態存為 session,生成一個 sessionid
  3. 通過登錄接口返回,把 sessionid set 到 cookie 上
  4. 此後瀏覽器再請求業務接口,sessionid 隨 cookie 帶上
  5. 服務端查 sessionid 校驗 session
  6. 成功後正常做業務處理,返回結果

3.1 session 的存儲方式

顯然,服務端只是給 cookie 一個 sessionid,而 session 的具體內容(可能包含用戶信息、session 狀態等),要自己存一下。存儲的方式有幾種:

  1. Redis(推荐):内存型数据库,redis 中文官方网站。以 key-value 的形式存,正合 sessionId-sessionData 的场景;且访问快。
  2. 內存:直接放到變量里。一旦服務重啟就沒了
  3. 資料庫:普通資料庫。性能不高。

3.2 session 的過期和銷毀

很簡單,只要把存儲的 session 數據銷毀就可以。

3.3 session 的分布式問題

通常服務端是集群,而用戶請求過來會走一次負載均衡,不一定打到哪台機器上。那一旦用戶後續接口請求到的機器和他登錄請求的機器不一致,或者登錄請求的機器宕機了,session 不就失效了嗎? 這個問題現在有幾種解決方式。

  • 一是從“存儲”角度,把 session 集中存儲。如果我們用獨立的 redis 或普通資料庫,就可以把 session 都存到一個庫里。
  • 二是從“分布”角度,讓相同 ip 的請求在負載均衡時都打到同一台機器上。以 nginx 為例,可以配置 ip_hash 來實現。

但通常還是採用第一種方式,因為第二種相當於閹割了負載均衡,且仍沒有解決“用戶請求的機器宕機”的問題。

3.4 node.js 下的 session 處理

前面的图很清楚了,服务端要实现对 cookie 和 session 的存取,实现起来要做的事还是很多的。在npm中,已经有封装好的中间件,比如 express-session - npm,用法就不贴了。

這是它種的 cookie:

express-session - npm 主要实现了:

  1. 封裝了對 cookie 的讀寫操作,並提供配置項配置欄位、加密方式、過期時間等。
  2. 封裝了對 session 的存取操作,並提供配置項配置 session 存儲方式(內存/redis)、存儲規則等。
  3. 給 req 提供了 session 屬性,控制屬性的 set/get 並響應到 cookie 和 session 存取上,並給 req.session 提供了一些方法。

4. 應用方案:token

session 的維護給服務端造成很大困擾,我們必須找地方存放它,又要考慮分布式的問題,甚至要單獨為了它啟用一套 redis 集群。有沒有更好的辦法?

我又想到學校,在沒有校園卡技術以前,我們都靠“學生證”。門衛小哥直接對照我和學生證上的臉,確認學生證有效期、年級等信息,就可以放行了。

回過頭來想想,一個登錄場景,也不必往 session 存太多東西,那為什麼不直接打包到 cookie 中呢?這樣服務端不用存了,每次只要核驗 cookie 帶的“證件”有效性就可以了,也可以攜帶一些輕量的信息。

這種方式通常被叫做 token。

token 的流程是這樣的:

  1. 用戶登錄,服務端校驗帳號密碼,獲得用戶信息
  2. 把用戶信息、token 配置編碼成 token,通過 cookie set 到瀏覽器
  3. 此後用戶請求業務接口,通過 cookie 攜帶 token
  4. 接口校驗 token 有效性,進行正常業務接口處理

4.1客戶端 token 的存儲方式

在前面 cookie 說過,cookie 並不是客戶端存儲憑證的唯一方式。token 因為它的“無狀態性”,有效期、使用限制都包在 token 內容里,對 cookie 的管理能力依賴較小,客戶端存起來就顯得更自由。但 web 應用的主流方式仍是放在 cookie 里,畢竟少操心。

4.2 token 的過期

那我們如何控制 token 的有效期呢?很簡單,把“過期時間”和數據一起塞進去,驗證時判斷就好。

4.3 token 的編碼

編碼的方式豐儉由人。

4.3.1 base64

比如 node 端的 cookie-session - npm

不要纠结名字,其实是个 token 库,但保持了和 express-session - npm 高度一致的用法,把要存的数据挂在 session 上

默認配置下,當我給他一個 userid,他會存成這樣:

这里的 eyJ1c2VyaWQiOiJhIn0=,就是 {"userid":"abb”} 的 base64 而已。

4.3.2 防篡改

那问题来了,如果用户 cdd 拿{"userid":"abb”}转了个 base64,再手动修改了自己的 token 为 eyJ1c2VyaWQiOiJhIn0=,是不是就能直接访问到 abb 的数据了?

是的。所以看情況,如果 token 涉及到敏感權限,就要想辦法避免 token 被篡改。

解决方案就是给 token 加签名,来识别 token 是否被篡改过。例如在 cookie-session - npm 库中,增加两项配置:

secret: 'iAmSecret',
signed: true,

这样会多种一个 .sig cookie,里面的值就是 {"userid":"abb”}iAmSecret通过加密算法计算出来的,常见的比如HMACSHA256 类 (System.Security.Cryptography) | Microsoft Docs

好了,现在 cdd 虽然能伪造出eyJ1c2VyaWQiOiJhIn0=,但伪造不出 sig 的内容,因为他不知道 secret。

4.4 JWT

但上面的做法额外增加了 cookie 数量,数据本身也没有规范的格式,所以 JSON Web Token Introduction - jwt.io 横空出世了。

json web token (jwt) 是一個開放標準,定義了一種傳遞 json 信息的方式。這些信息通過數字簽名確保可信。

它是一種成熟的 token 字符串生成方案,包含了我們前面提到的數據、簽名。不如直接看一下一個 jwt token 長什麼樣:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJhIiwiaWF0IjoxNTUxOTUxOTk4fQ.2jf3kl_uKWRkwjOP6uQRJFqMlwSABcgqqcJofFH5XCo

這串東西是怎麼生成的呢?看圖:

类型、加密算法的选项,以及 JWT 标准数据字段,可以参考 RFC 7519 - JSON Web Token (JWT)

node 上同样有相关的库实现:express-jwt - npm koa-jwt - npm

4.5 refresh token

token,作為權限守護者,最重要的就是“安全”。

業務接口用來鑒權的 token,我們稱之為 access token。越是權限敏感的業務,我們越希望 access token 有效期足夠短,以避免被盜用。但過短的有效期會造成 access token 經常過期,過期後怎麼辦呢?

一種辦法是,讓用戶重新登錄獲取新 token,顯然不夠友好,要知道有的 access token 過期時間可能只有幾分鐘。

另外一種辦法是,再來一個 token,一個專門生成 access token 的 token,我們稱為 refresh token。

  • access token 用來訪問業務接口,由於有效期足夠短,盜用風險小,也可以使請求方式更寬鬆靈活
  • refresh token 用來獲取 access token,有效期可以長一些,通過獨立服務和嚴格的請求方式增加安全性;由於不常驗證,也可以如前面的 session 一樣處理

有了 refresh token 後,幾種情況的請求流程變成這樣:

如果 refresh token 也過期了,就只能重新登錄了。

4.6 session 和 token

session 和 token 都是邊界很模糊的概念,就像前面說的,refresh token 也可能以 session 的形式組織維護。

狹義上,我們通常認為 session 是“種在 cookie 上、數據存在服務端”的認證方案,token 是“客戶端存哪都行、數據存在 token 里”的認證方案。對 session 和 token 的對比本質上是“客戶端存 cookie/存別地兒”、“服務端存數據/不存數據”的對比。

存 cookie 固然方便不操心,但問題也很明顯:

存別的地方,可以解決沒有 cookie 的場景;通過參數等方式手動帶,可以避免 csrf 攻擊。

4.7服務端存數據/不存數據

  • 存數據:請求只需攜帶 id,可以大幅縮短認證字符串長度,減小請求體積
  • 不存數據:不需要服務端整套的解決方案和分布式處理,降低硬體成本;避免查庫帶來的驗證延遲

5. 單點登錄

前面我們已經知道了,在同域下的客戶端/服務端認證系統中,通過客戶端攜帶憑證,維持一段時間內的登錄狀態。

但當我們業務線越來越多,就會有更多業務系統分散到不同域名下,就需要“一次登錄,全線通用”的能力,叫做“單點登錄”。

5.1“虛假”的單點登錄(主域名相同)

简单的,如果业务系统都在同一主域名下,比如wenku.baidu.com tieba.baidu.com,就好办了。可以直接把 cookie domain 设置为主域名 baidu.com,百度也就是这么干的。

5.2“真實”的單點登錄(主域名不同)

比如滴滴这么潮的公司,同时拥有didichuxing.com xiaojukeji.com didiglobal.com等域名,种 cookie 是完全绕不开的。

這要能實現“一次登錄,全線通用”,才是真正的單點登錄。

這種場景下,我們需要獨立的認證服務,通常被稱為 sso。

一次“從 a 系統引發登錄,到 b 系統不用登錄”的完整流程

  1. 用戶進入 a 系統,沒有登錄憑證(ticket),a 系統給他跳到 sso
  2. sso 沒登錄過,也就沒有 sso 系統下沒有憑證(注意這個和前面 a ticket 是兩回事),輸入帳號密碼登錄
  3. sso 帳號密碼驗證成功,通過接口返回做兩件事:一是種下 sso 系統下憑證(記錄用戶在 sso 登錄狀態);二是下發一個 ticket
  4. 客戶端拿到 ticket,保存起來,帶著請求系統 a 接口
  5. 系統 a 校驗 ticket,成功後正常處理業務請求
  6. 此時用戶第一次進入系統 b,沒有登錄憑證(ticket),b 系統給他跳到 sso
  7. sso 登錄過,系統下有憑證,不用再次登錄,只需要下發 ticket
  8. 客戶端拿到 ticket,保存起來,帶著請求系統 b 接口

5.3完整版本:考慮瀏覽器的場景

上面的過程看起來沒問題,實際上很多 app 等端上這樣就夠了。但在瀏覽器下不見得好用。

看這裡:

對瀏覽器來說,sso 域下返回的數據要怎麼存,才能在訪問 a 的時候帶上?瀏覽器對跨域有嚴格限制,cookie、localstorage 等方式都是有域限制的。

這就需要也只能由 a 提供 a 域下存儲憑證的能力。一般我們是這麼做的:

圖中我們通過顏色把瀏覽器當前所處的域名標記出來。注意圖中灰底文字說明部分的變化。

  1. 在 sso 域下,sso 不是通過接口把 ticket 直接返回,而是通過一個帶 code 的 url 重定向到系統 a 的接口上,這個接口通常在 a 向 sso 註冊時約定
  2. 瀏覽器被重定向到 a 域下,帶著 code 訪問了 a 的 callback 接口,callback 接口通過 code 換取 ticket
  3. 這個 code 不同於 ticket,code 是一次性的,暴露在 url 中,只為了傳一下換 ticket,換完就失效
  4. callback 接口拿到 ticket 後,在自己的域下 set cookie 成功
  5. 在後續請求中,只需要把 cookie 中的 ticket 解析出來,去 sso 驗證就好
  6. 訪問 b 系統也是一樣

6. 總結

  1. http 是無狀態的,為了維持前後請求,需要前端存儲標記
  2. cookie 是一種完善的標記方式,通過 http 頭或 js 操作,有對應的安全策略,是大多數狀態管理方案的基石
  3. session 是一種狀態管理方案,前端通過 cookie 存儲 id,後端存儲數據,但後端要處理分布式問題
  4. token 是另一種狀態管理方案,相比於 session 不需要後端存儲,數據全部存在前端,解放後端,釋放靈活性
  5. token 的編碼技術,通常基於 base64,或增加加密算法防篡改,jwt 是一種成熟的編碼方案
  6. 在複雜系統中,token 可通過 service token、refresh token 的分權,同時滿足安全性和用戶體驗
  7. session 和 token 的對比就是“用不用 cookie”和“後端存不存”的對比
  8. 單點登錄要求不同域下的系統“一次登錄,全線通用”,通常由獨立的 sso 系統記錄登錄狀態、下發 ticket,各業務系統配合存儲和認證 ticket
Keep Exploring

延伸阅读

更多文章
同分类 2023/10/16

net工具箱:開源、免費的純前端工具網站,帶你探索10大工具分類和73個實時在線小工具

dotnet工具箱是一個純前端的、開源和免費的工具網站,周末我參考了開源項目it-tools,對網站界面文字進行了漢化,並重新部署了網站。該網站共有10大工具分類,提供了73個實時在線小工具。使用vue3開發的dotnet工具箱具有獨特的特色,本文詳細居間了其中一些特色工具,並簡單分享了如何部署自己的工具網站。如果你對工具網站感興趣,不妨來了解一下dotnet工具箱吧!

继续阅读