前端鑒權(quán)必須了解的5個兄弟:cookie、session、token、jwt、單點(diǎn)登錄

點(diǎn)擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
原文:https://juejin.cn/post/6898630134530752520#heading-0
本文你將看到:
基于 HTTP 的前端鑒權(quán)背景 cookie 為什么是最方便的存儲方案,有哪些操作 cookie 的方式 session 方案是如何實(shí)現(xiàn)的,存在哪些問題 token 方案是如何實(shí)現(xiàn)的,如何進(jìn)行編碼和防篡改?jwt 是做什么的?refresh token 的實(shí)現(xiàn)和意義 session 和 token 有什么異同和優(yōu)缺點(diǎn) 單點(diǎn)登錄是什么?實(shí)現(xiàn)思路和在瀏覽器下的處理
從狀態(tài)說起
「HTTP 無狀態(tài)」
我們知道,HTTP 是無狀態(tài)的。也就是說,HTTP 請求方和響應(yīng)方間無法維護(hù)狀態(tài),都是一次性的,它不知道前后的請求都發(fā)生了什么。
但有的場景下,我們需要維護(hù)狀態(tài)。最典型的,一個用戶登陸微博,發(fā)布、關(guān)注、評論,都應(yīng)是在登錄后的用戶狀態(tài)下的。
「標(biāo)記」
那解決辦法是什么呢?::標(biāo)記::。
在學(xué)校或公司,入學(xué)入職那一天起,會錄入你的身份、賬戶信息,然后給你發(fā)個卡,今后在園區(qū)內(nèi),你的門禁、打卡、消費(fèi)都只需要刷這張卡。
「前端存儲」
這就涉及到一發(fā)、一存、一帶,發(fā)好辦,登陸接口直接返回給前端,存儲就需要前端想辦法了。
前提是,你要把卡帶在身上。
前端的存儲方式有很多。
最矬的,掛到全局變量上,但這是個「體驗(yàn)卡」,一次刷新頁面就沒了 高端點(diǎn)的,存到 cookie、localStorage 等里,這屬于「會員卡」,無論怎么刷新,只要瀏覽器沒清掉或者過期,就一直拿著這個狀態(tài)。
前端存儲這里不展開了。
有地方存了,請求的時(shí)候就可以拼到參數(shù)里帶給接口了。
基石:cookie
可是前端好麻煩啊,又要自己存,又要想辦法帶出去,有沒有不用操心的?
有,cookie。
cookie 也是前端存儲的一種,但相比于 localStorage 等其他方式,借助 HTTP 頭、瀏覽器能力,cookie 可以做到前端無感知。
一般過程是這樣的:
在提供標(biāo)記的接口,通過 HTTP 返回頭的 Set-Cookie 字段,直接「種」到瀏覽器上 瀏覽器發(fā)起請求時(shí),會自動把 cookie 通過 HTTP 請求頭的 Cookie 字段,帶給接口
「配置:Domain / Path」
你不能拿清華的校園卡進(jìn)北大。
cookie 是要限制::「空間范圍」::的,通過 Domain(域)/ Path(路徑)兩級。
Domain屬性指定瀏覽器發(fā)出 HTTP 請求時(shí),哪些域名要附帶這個 Cookie。如果沒有指定該屬性,瀏覽器會默認(rèn)將其設(shè)為當(dāng)前 URL 的一級域名,比如 www.example.com 會設(shè)為 example.com,而且以后如果訪問example.com的任何子域名,HTTP 請求也會帶上這個 Cookie。如果服務(wù)器在Set-Cookie字段指定的域名,不屬于當(dāng)前域名,瀏覽器會拒絕這個 Cookie。
Path屬性指定瀏覽器發(fā)出 HTTP 請求時(shí),哪些路徑要附帶這個 Cookie。只要瀏覽器發(fā)現(xiàn),Path屬性是 HTTP 請求路徑的開頭一部分,就會在頭信息里面帶上這個 Cookie。比如,PATH屬性是/,那么請求/docs路徑也會包含該 Cookie。當(dāng)然,前提是域名必須一致。
—— Cookie — JavaScript 標(biāo)準(zhǔn)參考教程(alpha)
「配置:Expires / Max-Age」
你畢業(yè)了卡就不好使了。
cookie 還可以限制::「時(shí)間范圍」::,通過 Expires、Max-Age 中的一種。
Expires屬性指定一個具體的到期時(shí)間,到了指定時(shí)間以后,瀏覽器就不再保留這個 Cookie。它的值是 UTC 格式。如果不設(shè)置該屬性,或者設(shè)為null,Cookie 只在當(dāng)前會話(session)有效,瀏覽器窗口一旦關(guān)閉,當(dāng)前 Session 結(jié)束,該 Cookie 就會被刪除。另外,瀏覽器根據(jù)本地時(shí)間,決定 Cookie 是否過期,由于本地時(shí)間是不精確的,所以沒有辦法保證 Cookie 一定會在服務(wù)器指定的時(shí)間過期。
Max-Age屬性指定從現(xiàn)在開始 Cookie 存在的秒數(shù),比如60 * 60 * 24 * 365(即一年)。過了這個時(shí)間以后,瀏覽器就不再保留這個 Cookie。
如果同時(shí)指定了Expires和Max-Age,那么Max-Age的值將優(yōu)先生效。
如果Set-Cookie字段沒有指定Expires或Max-Age屬性,那么這個 Cookie 就是 Session Cookie,即它只在本次對話存在,一旦用戶關(guān)閉瀏覽器,瀏覽器就不會再保留這個 Cookie。
—— Cookie — JavaScript 標(biāo)準(zhǔn)參考教程(alpha)
「配置:Secure / HttpOnly」
有的學(xué)校規(guī)定,不帶卡套不讓刷(什么奇葩學(xué)校,假設(shè));有的學(xué)校不讓自己給卡貼貼紙。
cookie 可以限制::「使用方式」::。
Secure屬性指定瀏覽器只有在加密協(xié)議 HTTPS 下,才能將這個 Cookie 發(fā)送到服務(wù)器。另一方面,如果當(dāng)前協(xié)議是 HTTP,瀏覽器會自動忽略服務(wù)器發(fā)來的Secure屬性。該屬性只是一個開關(guān),不需要指定值。如果通信是 HTTPS 協(xié)議,該開關(guān)自動打開。
HttpOnly屬性指定該 Cookie 無法通過 JavaScript 腳本拿到,主要是Document.cookie屬性、XMLHttpRequest對象和 Request API 都拿不到該屬性。這樣就防止了該 Cookie 被腳本讀到,只有瀏覽器發(fā)出 HTTP 請求時(shí),才會帶上該 Cookie。
—— Cookie — JavaScript 標(biāo)準(zhǔn)參考教程(alpha)
「HTTP 頭對 cookie 的讀寫」
回過頭來,HTTP 是如何寫入和傳遞 cookie 及其配置的呢?
HTTP 返回的一個 Set-Cookie 頭用于向?yàn)g覽器寫入「一條(且只能是一條)」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 請求中允許重復(fù))
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 頭用于瀏覽器把符合當(dāng)前「空間、時(shí)間、使用方式」配置的所有 cookie 一并發(fā)給服務(wù)端。因?yàn)橛蔀g覽器做了篩選判斷,就不需要?dú)w還配置內(nèi)容了,只要發(fā)送鍵值就可以。
Cookie: username=jimu; height=180; weight=80
「前端對 cookie 的讀寫」
前端可以自己創(chuàng)建 cookie,如果服務(wù)端創(chuàng)建的 cookie 沒加HttpOnly,那恭喜你也可以修改他給的 cookie。
調(diào)用document.cookie可以創(chuàng)建、修改 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';
調(diào)用document.cookie也可以讀到 cookie,也和 HTTP 一樣,能讀到所有的非HttpOnly cookie。
console.log(document.cookie);
// username=jimu; height=180; weight=80
(就一個 cookie 屬性,為什么讀寫行為不一樣?get / set 了解下)
「cookie 是維持 HTTP 請求狀態(tài)的基石」
了解了 cookie 后,我們知道 cookie 是最便捷的維持 HTTP 請求狀態(tài)的方式,大多數(shù)前端鑒權(quán)問題都是靠 cookie 解決的。當(dāng)然也可以選用別的存儲方式(后面也會多多少少提到)。
那有了存儲工具,接下來怎么做呢?
應(yīng)用方案:服務(wù)端 session
現(xiàn)在回想下,你刷卡的時(shí)候發(fā)生了什么?
其實(shí)你的卡上只存了一個 id(可能是你的學(xué)號),刷的時(shí)候物業(yè)系統(tǒng)去查你的信息、賬戶,再決定「這個門你能不能進(jìn)」「這個雞腿去哪個賬戶扣錢」。
這種操作,在前后端鑒權(quán)系統(tǒng)中,叫 session。
典型的 session 登陸/驗(yàn)證流程:

瀏覽器登錄發(fā)送賬號密碼,服務(wù)端查用戶庫,校驗(yàn)用戶 服務(wù)端把用戶登錄狀態(tài)存為 Session,生成一個 sessionId 通過登錄接口返回,把 sessionId set 到 cookie 上 此后瀏覽器再請求業(yè)務(wù)接口,sessionId 隨 cookie 帶上 服務(wù)端查 sessionId 校驗(yàn) session 成功后正常做業(yè)務(wù)處理,返回結(jié)果
「Session 的存儲方式」
顯然,服務(wù)端只是給 cookie 一個 sessionId,而 session 的具體內(nèi)容(可能包含用戶信息、session 狀態(tài)等),要自己存一下。存儲的方式有幾種:
Redis(推薦):內(nèi)存型數(shù)據(jù)庫,redis中文官方網(wǎng)站。以 key-value 的形式存,正合 sessionId-sessionData 的場景;且訪問快。 內(nèi)存:直接放到變量里。一旦服務(wù)重啟就沒了 數(shù)據(jù)庫:普通數(shù)據(jù)庫。性能不高。
「Session 的過期和銷毀」
很簡單,只要把存儲的 session 數(shù)據(jù)銷毀就可以。
「Session 的分布式問題」
通常服務(wù)端是集群,而用戶請求過來會走一次負(fù)載均衡,不一定打到哪臺機(jī)器上。那一旦用戶后續(xù)接口請求到的機(jī)器和他登錄請求的機(jī)器不一致,或者登錄請求的機(jī)器宕機(jī)了,session 不就失效了嗎?
這個問題現(xiàn)在有幾種解決方式。
一是從「存儲」角度,把 session 集中存儲。如果我們用獨(dú)立的 Redis 或普通數(shù)據(jù)庫,就可以把 session 都存到一個庫里。 二是從「分布」角度,讓相同 IP 的請求在負(fù)載均衡時(shí)都打到同一臺機(jī)器上。以 nginx 為例,可以配置 ip_hash 來實(shí)現(xiàn)。
但通常還是采用第一種方式,因?yàn)榈诙N相當(dāng)于閹割了負(fù)載均衡,且仍沒有解決「用戶請求的機(jī)器宕機(jī)」的問題。
「node.js 下的 session 處理」
前面的圖很清楚了,服務(wù)端要實(shí)現(xiàn)對 cookie 和 session 的存取,實(shí)現(xiàn)起來要做的事還是很多的。在npm中,已經(jīng)有封裝好的中間件,比如 express-session - npm,用法就不貼了。
這是它種的 cookie:

express-session - npm 主要實(shí)現(xiàn)了:
封裝了對cookie的讀寫操作,并提供配置項(xiàng)配置字段、加密方式、過期時(shí)間等。 封裝了對session的存取操作,并提供配置項(xiàng)配置session存儲方式(內(nèi)存/redis)、存儲規(guī)則等。 給req提供了session屬性,控制屬性的set/get并響應(yīng)到cookie和session存取上,并給req.session提供了一些方法。
應(yīng)用方案:token
session 的維護(hù)給服務(wù)端造成很大困擾,我們必須找地方存放它,又要考慮分布式的問題,甚至要單獨(dú)為了它啟用一套 Redis 集群。有沒有更好的辦法?
我又想到學(xué)校,在沒有校園卡技術(shù)以前,我們都靠「學(xué)生證」。門衛(wèi)小哥直接對照我和學(xué)生證上的臉,確認(rèn)學(xué)生證有效期、年級等信息,就可以放行了。
回過頭來想想,一個登錄場景,也不必往 session 存太多東西,那為什么不直接打包到 cookie 中呢?這樣服務(wù)端不用存了,每次只要核驗(yàn) cookie 帶的「證件」有效性就可以了,也可以攜帶一些輕量的信息。
這種方式通常被叫做 token。

token 的流程是這樣的:
用戶登錄,服務(wù)端校驗(yàn)賬號密碼,獲得用戶信息 把用戶信息、token 配置編碼成 token,通過 cookie set 到瀏覽器 此后用戶請求業(yè)務(wù)接口,通過 cookie 攜帶 token 接口校驗(yàn) token 有效性,進(jìn)行正常業(yè)務(wù)接口處理
「客戶端 token 的存儲方式」
在前面 cookie 說過,cookie 并不是客戶端存儲憑證的唯一方式。token 因?yàn)樗摹笩o狀態(tài)性」,有效期、使用限制都包在 token 內(nèi)容里,對 cookie 的管理能力依賴較小,客戶端存起來就顯得更自由。但 web 應(yīng)用的主流方式仍是放在 cookie 里,畢竟少操心。
「token 的過期」
那我們?nèi)绾慰刂?token 的有效期呢?很簡單,把「過期時(shí)間」和數(shù)據(jù)一起塞進(jìn)去,驗(yàn)證時(shí)判斷就好。
token 的編碼
編碼的方式豐儉由人。
「base64」
比如 node 端的 cookie-session - npm 庫
不要糾結(jié)名字,其實(shí)是個 token 庫,但保持了和 express-session - npm 高度一致的用法,把要存的數(shù)據(jù)掛在 session 上
默認(rèn)配置下,當(dāng)我給他一個 userid,他會存成這樣:

這里的 eyJ1c2VyaWQiOiJhIn0=,就是 {"userid":"abb”} 的 base64 而已。
「防篡改」
那問題來了,如果用戶 cdd 拿
{"userid":"abb”}轉(zhuǎn)了個 base64,再手動修改了自己的 token 為eyJ1c2VyaWQiOiJhIn0=,是不是就能直接訪問到 abb 的數(shù)據(jù)了?
是的。所以看情況,如果 token 涉及到敏感權(quán)限,就要想辦法避免 token 被篡改。
解決方案就是給 token 加簽名,來識別 token 是否被篡改過。例如在 cookie-session - npm 庫中,增加兩項(xiàng)配置:
secret: 'iAmSecret',
signed: true,
這樣會多種一個 .sig cookie,里面的值就是 {"userid":"abb”} 和 iAmSecret通過加密算法計(jì)算出來的,常見的比如HMACSHA256 類 (System.Security.Cryptography) | Microsoft Docs。

好了,現(xiàn)在 cdd 雖然能偽造出eyJ1c2VyaWQiOiJhIn0=,但偽造不出 sig 的內(nèi)容,因?yàn)樗恢?secret。
「JWT」
但上面的做法額外增加了 cookie 數(shù)量,數(shù)據(jù)本身也沒有規(guī)范的格式,所以 JSON Web Token Introduction - jwt.io 橫空出世了。
JSON Web Token (JWT) 是一個開放標(biāo)準(zhǔn),定義了一種傳遞 JSON 信息的方式。這些信息通過數(shù)字簽名確??尚?。
它是一種成熟的 token 字符串生成方案,包含了我們前面提到的數(shù)據(jù)、簽名。不如直接看一下一個 JWT token 長什么樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOiJhIiwiaWF0IjoxNTUxOTUxOTk4fQ.2jf3kl_uKWRkwjOP6uQRJFqMlwSABcgqqcJofFH5XCo
這串東西是怎么生成的呢?看圖:

類型、加密算法的選項(xiàng),以及 JWT 標(biāo)準(zhǔn)數(shù)據(jù)字段,可以參考 RFC 7519 - JSON Web Token (JWT)
node 上同樣有相關(guān)的庫實(shí)現(xiàn):express-jwt - npm koa-jwt - npm
refresh token
token,作為權(quán)限守護(hù)者,最重要的就是「安全」。
業(yè)務(wù)接口用來鑒權(quán)的 token,我們稱之為 access token。越是權(quán)限敏感的業(yè)務(wù),我們越希望 access token 有效期足夠短,以避免被盜用。但過短的有效期會造成 access token 經(jīng)常過期,過期后怎么辦呢?
一種辦法是,讓用戶重新登錄獲取新 token,顯然不夠友好,要知道有的 access token 過期時(shí)間可能只有幾分鐘。
另外一種辦法是,再來一個 token,一個專門生成 access token 的 token,我們稱為 refresh token。
access token 用來訪問業(yè)務(wù)接口,由于有效期足夠短,盜用風(fēng)險(xiǎn)小,也可以使請求方式更寬松靈活 refresh token 用來獲取 access token,有效期可以長一些,通過獨(dú)立服務(wù)和嚴(yán)格的請求方式增加安全性;由于不常驗(yàn)證,也可以如前面的 session 一樣處理
有了 refresh token 后,幾種情況的請求流程變成這樣:

如果 refresh token 也過期了,就只能重新登錄了。
session 和 token
session 和 token 都是邊界很模糊的概念,就像前面說的,refresh token 也可能以 session 的形式組織維護(hù)。
狹義上,我們通常認(rèn)為 session 是「種在 cookie 上、數(shù)據(jù)存在服務(wù)端」的認(rèn)證方案,token 是「客戶端存哪都行、數(shù)據(jù)存在 token 里」的認(rèn)證方案。對 session 和 token 的對比本質(zhì)上是「客戶端存 cookie / 存別地兒」、「服務(wù)端存數(shù)據(jù) / 不存數(shù)據(jù)」的對比。
「客戶端存 cookie / 存別地兒」
存 cookie 固然方便不操心,但問題也很明顯:
在瀏覽器端,可以用 cookie(實(shí)際上 token 就常用 cookie),但出了瀏覽器端,沒有 cookie 怎么辦? cookie 是瀏覽器在域下自動攜帶的,這就容易引發(fā) CSRF 攻擊(前端安全系列(二):如何防止CSRF攻擊?- 美團(tuán)技術(shù)團(tuán)隊(duì))
存別的地方,可以解決沒有 cookie 的場景;通過參數(shù)等方式手動帶,可以避免 CSRF 攻擊。
「服務(wù)端存數(shù)據(jù) / 不存數(shù)據(jù)」
存數(shù)據(jù):請求只需攜帶 id,可以大幅縮短認(rèn)證字符串長度,減小請求體積 不存數(shù)據(jù):不需要服務(wù)端整套的解決方案和分布式處理,降低硬件成本;避免查庫帶來的驗(yàn)證延遲
單點(diǎn)登錄
前面我們已經(jīng)知道了,在同域下的客戶端/服務(wù)端認(rèn)證系統(tǒng)中,通過客戶端攜帶憑證,維持一段時(shí)間內(nèi)的登錄狀態(tài)。
但當(dāng)我們業(yè)務(wù)線越來越多,就會有更多業(yè)務(wù)系統(tǒng)分散到不同域名下,就需要「一次登錄,全線通用」的能力,叫做「單點(diǎn)登錄」。
“虛假”的單點(diǎn)登錄(主域名相同)
簡單的,如果業(yè)務(wù)系統(tǒng)都在同一主域名下,比如wenku.baidu.com tieba.baidu.com,就好辦了??梢灾苯影?cookie domain 設(shè)置為主域名 baidu.com,百度也就是這么干的。

“真實(shí)”的單點(diǎn)登錄(主域名不同)
比如滴滴這么潮的公司,同時(shí)擁有didichuxing.com xiaojukeji.com didiglobal.com等域名,種 cookie 是完全繞不開的。
這要能實(shí)現(xiàn)「一次登錄,全線通用」,才是真正的單點(diǎn)登錄。
這種場景下,我們需要獨(dú)立的認(rèn)證服務(wù),通常被稱為 SSO。
「一次「從 A 系統(tǒng)引發(fā)登錄,到 B 系統(tǒng)不用登錄」的完整流程」

用戶進(jìn)入 A 系統(tǒng),沒有登錄憑證(ticket),A 系統(tǒng)給他跳到 SSO SSO 沒登錄過,也就沒有 sso 系統(tǒng)下沒有憑證(注意這個和前面 A ticket 是兩回事),輸入賬號密碼登錄 SSO 賬號密碼驗(yàn)證成功,通過接口返回做兩件事:一是種下 sso 系統(tǒng)下憑證(記錄用戶在 SSO 登錄狀態(tài));二是下發(fā)一個 ticket 客戶端拿到 ticket,保存起來,帶著請求系統(tǒng) A 接口 系統(tǒng) A 校驗(yàn) ticket,成功后正常處理業(yè)務(wù)請求 此時(shí)用戶第一次進(jìn)入系統(tǒng) B,沒有登錄憑證(ticket),B 系統(tǒng)給他跳到 SSO SSO 登錄過,系統(tǒng)下有憑證,不用再次登錄,只需要下發(fā) ticket 客戶端拿到 ticket,保存起來,帶著請求系統(tǒng) B 接口
「完整版本:考慮瀏覽器的場景」
上面的過程看起來沒問題,實(shí)際上很多 APP 等端上這樣就夠了。但在瀏覽器下不見得好用。
看這里:

對瀏覽器來說,SSO 域下返回的數(shù)據(jù)要怎么存,才能在訪問 A 的時(shí)候帶上?瀏覽器對跨域有嚴(yán)格限制,cookie、localStorage 等方式都是有域限制的。
這就需要也只能由 A 提供 A 域下存儲憑證的能力。一般我們是這么做的:

圖中我們通過顏色把瀏覽器當(dāng)前所處的域名標(biāo)記出來。注意圖中灰底文字說明部分的變化。
在 SSO 域下,SSO 不是通過接口把 ticket 直接返回,而是通過一個帶 code 的 URL 重定向到系統(tǒng) A 的接口上,這個接口通常在 A 向 SSO 注冊時(shí)約定 瀏覽器被重定向到 A 域下,帶著 code 訪問了 A 的 callback 接口,callback 接口通過 code 換取 ticket 這個 code 不同于 ticket,code 是一次性的,暴露在 URL 中,只為了傳一下?lián)Q ticket,換完就失效 callback 接口拿到 ticket 后,在自己的域下 set cookie 成功 在后續(xù)請求中,只需要把 cookie 中的 ticket 解析出來,去 SSO 驗(yàn)證就好 訪問 B 系統(tǒng)也是一樣
總結(jié)
HTTP 是無狀態(tài)的,為了維持前后請求,需要前端存儲標(biāo)記 cookie 是一種完善的標(biāo)記方式,通過 HTTP 頭或 js 操作,有對應(yīng)的安全策略,是大多數(shù)狀態(tài)管理方案的基石 session 是一種狀態(tài)管理方案,前端通過 cookie 存儲 id,后端存儲數(shù)據(jù),但后端要處理分布式問題 token 是另一種狀態(tài)管理方案,相比于 session 不需要后端存儲,數(shù)據(jù)全部存在前端,解放后端,釋放靈活性 token 的編碼技術(shù),通?;?base64,或增加加密算法防篡改,jwt 是一種成熟的編碼方案 在復(fù)雜系統(tǒng)中,token 可通過 service token、refresh token 的分權(quán),同時(shí)滿足安全性和用戶體驗(yàn) session 和 token 的對比就是「用不用cookie」和「后端存不存」的對比 單點(diǎn)登錄要求不同域下的系統(tǒng)「一次登錄,全線通用」,通常由獨(dú)立的 SSO 系統(tǒng)記錄登錄狀態(tài)、下發(fā) ticket,各業(yè)務(wù)系統(tǒng)配合存儲和認(rèn)證 ticket


“分享、點(diǎn)贊、在看” 支持一波 
