踐:無狀態(tài)認(rèn)證與簽名機(jī)制全解析)
JWT這東西做服務(wù)端開發(fā)的肯定不陌生。我印象很深的是剛工作那會兒第一次在項(xiàng)目代碼里看到一長串密密麻麻的token長得像亂碼夾著幾個(gè)英文單詞塞進(jìn)請求頭里就能驗(yàn)證身份。當(dāng)時(shí)我盯著那串字符琢磨了半天服務(wù)端到底是怎么認(rèn)出這個(gè)token的別人把它改一改服務(wù)端能不能發(fā)現(xiàn)后來花了幾天時(shí)間把JWT從結(jié)構(gòu)到原理整個(gè)捋了一遍才算是真正開了竅。如果你也在學(xué)JWT或者只是看著同事代碼里那行“Authorization: Bearer xxx”有點(diǎn)發(fā)懵這篇文章就是為你準(zhǔn)備的。我按自己的學(xué)習(xí)路徑來寫先搞明白它到底解決什么問題再拆開看它長什么樣最后說清楚它為什么能保證安全。這一篇不急著寫代碼重點(diǎn)是建立完整的認(rèn)知框架把“是什么”這層窗戶紙捅破。1. 先從最常見的場景說起登錄狀態(tài)到底是誰記住的1.1 HTTP協(xié)議天生“記性差”要理解JWT得先回到最原始的Web交互場景。用過抓包工具或者看過瀏覽器開發(fā)者工具的朋友都知道HTTP協(xié)議本身是一個(gè)“無狀態(tài)”的協(xié)議。什么叫無狀態(tài)就是你每次發(fā)請求服務(wù)器看待你都像看待一個(gè)陌生人。我發(fā)第一次請求說“我是A用戶”服務(wù)器處理完就把這件事忘了發(fā)第二次請求說“我是A用戶”服務(wù)器依舊不記得你是誰。這帶來的問題太明顯了一個(gè)電商網(wǎng)站你辛辛苦苦把商品加進(jìn)了購物車刷新一下頁面購物車空了。你登錄了賬號點(diǎn)進(jìn)個(gè)人中心結(jié)果它讓你重新登錄。沒有“狀態(tài)”這個(gè)概念所有需要記住用戶身份的功能全都玩不轉(zhuǎn)。所以從很早開始業(yè)界就在解決一個(gè)問題怎么讓HTTP請求“記住”用戶1.2 傳統(tǒng)方案基于Session的會話保持最常見的傳統(tǒng)方案是Session。我在很多項(xiàng)目里都接觸過這套機(jī)制流程大概是這樣的用戶提交用戶名密碼服務(wù)端驗(yàn)證通過后在服務(wù)器內(nèi)存或者Redis里存一份會話數(shù)據(jù)生成一個(gè)會話IDSession ID。這個(gè)ID通過響應(yīng)頭里的Set-Cookie下發(fā)給瀏覽器。瀏覽器收到后在后續(xù)請求的Cookie字段里自動帶回這個(gè)ID。服務(wù)端拿到ID去內(nèi)存或Redis里一查發(fā)現(xiàn)這個(gè)會話有效就放行。在整個(gè)鏈路里真正的用戶數(shù)據(jù)存在服務(wù)端這邊客戶端手里只有一把“鑰匙”叫Session ID。鑰匙本身不包含任何用戶信息甚至沒有實(shí)際價(jià)值。這套機(jī)制在單體應(yīng)用時(shí)代非常好用。但我后來在幾個(gè)項(xiàng)目里逐步發(fā)現(xiàn)它有兩個(gè)讓人頭疼的地方第一服務(wù)端需要保存Session數(shù)據(jù)。用戶量一大這些會話數(shù)據(jù)占內(nèi)存也占存儲。更麻煩的是如果你部署了多臺服務(wù)器用戶在A服務(wù)器登錄了下次請求被負(fù)載均衡分到B服務(wù)器B服務(wù)器那沒有這個(gè)Session用戶就掉線了。解決辦法是搞Session共享把Session數(shù)據(jù)從各臺服務(wù)器挪出來放到統(tǒng)一的Redis集群里。這確實(shí)能解決問題但多了一層基礎(chǔ)設(shè)施鏈路也復(fù)雜了不少。第二Cookie在跨域場景下非常折騰?,F(xiàn)在的項(xiàng)目尤其前后端分離的項(xiàng)目前端頁面在A域名后端接口在B域名跨域請求里處理Cookie要額外配置域名、路徑、SameSite之類的屬性。你要是再做移動端AppApp里面根本就不是Cookie那套機(jī)制了還得手動管理Session ID的存儲。這些問題疊加在一起業(yè)內(nèi)自然開始找新的方案。JWT就是在這個(gè)背景下被越來越多人采用的一種替代思路。2. JWT到底是什么把三段結(jié)構(gòu)拆開揉碎2.1 先看一眼實(shí)物JWT全稱是JSON Web Token。按字面理解它是一個(gè)基于JSON格式的、用于網(wǎng)絡(luò)身份認(rèn)證的令牌Token。我在項(xiàng)目里見過很多次實(shí)體它長這樣eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c猛一看就是三節(jié)亂碼中間用兩個(gè)英文句號..隔開所以JWT的整體結(jié)構(gòu)是“三段式兩分隔”。每一段分別叫Header頭部、Payload載荷、Signature簽名合在一起就是Header.Payload.Signature的格式。2.2 拆開看第一段Header把第一段解出來其實(shí)是個(gè)很簡單的JSON{ alg: HS256, typ: JWT }這里面有兩個(gè)字段。alg告訴服務(wù)端“我這個(gè)Token是用什么算法簽的名”比如HS256表示HMAC-SHA256RS256表示RSA-SHA256。typ說明這是一個(gè)JWT類型的令牌。這個(gè)JSON經(jīng)過Base64Url編碼就成了我們在JWT里看到的第一段字符串。注意這里只是編碼不是加密任何人都能把它解碼出來。2.3 再拆第二段Payload第二段Payload是JWT里攜帶信息的核心位置里面存放著所謂的“聲明”Claim。Claim就是“關(guān)于用戶和Token本身的一些陳述”。常見的標(biāo)準(zhǔn)聲明有這些subSubject這個(gè)Token的主題一般存用戶IDiatIssued At簽發(fā)時(shí)間時(shí)間戳格式expExpiration Time過期時(shí)間過了這個(gè)時(shí)間Token失效nbfNot Before在這之前不可用配合時(shí)間敏感的場景issIssuer簽發(fā)者標(biāo)識是誰簽發(fā)的這個(gè)TokenaudAudience接收方標(biāo)識Token是給誰用的jtiJWT IDToken的唯一標(biāo)識除了標(biāo)準(zhǔn)聲明我們完全可以往Payload里塞自定義字段。比如想在前端頁面顯示用戶名就可以加一個(gè)username: 張三進(jìn)去想?yún)^(qū)分用戶角色權(quán)限可以加一個(gè)role: admin。一個(gè)實(shí)際的Payload解碼出來長這樣{ sub: 1234567890, name: John Doe, iat: 1516239022, exp: 1516242622, role: admin }同樣這段JSON也是Base64Url編碼明文可讀。這里有個(gè)關(guān)鍵點(diǎn)我后面會反復(fù)強(qiáng)調(diào)Payload里的信息是任何人都能解的所以千萬別把密碼、身份證號、手機(jī)號這類敏感信息直接放進(jìn)去。2.4 第三段是靈魂Signature前兩段編碼之后把結(jié)果和服務(wù)器保存的密鑰一起喂給簽名算法算出來結(jié)果就是第三段Signature。它的計(jì)算邏輯大致是signature HMACSHA256( base64urlEncode(header) . base64urlEncode(payload), secret )也就是說簽名是對“前兩段固定字符串”加上“密鑰”做一次哈希計(jì)算得到一串定長的輸出。這個(gè)Signature是JWT防篡改的關(guān)鍵。因?yàn)楣S?jì)算有一個(gè)特性輸入的內(nèi)容哪怕只改一個(gè)字符輸出結(jié)果就會完全不一樣。而攻擊者手里沒有服務(wù)器那把密鑰他就算偽造了Header和Payload也算不出一個(gè)能通過服務(wù)端驗(yàn)證的簽名。我經(jīng)常給朋友打的比方是JWT像一張蓋了防偽章的通行證。證件上的信息Payload誰都能看但章是專用章拿不到章就偽造不出同款通行證。服務(wù)端驗(yàn)證的時(shí)候重新蓋一次章比對兩次章是否一致一致就說明這張通行證沒有被改過。2.5 為什么要用Base64Url而不是普通Base64這里有個(gè)小細(xì)節(jié)值得多說一嘴。網(wǎng)上很多文章會說JWT用Base64編碼這話不能說錯(cuò)但不準(zhǔn)確。JWT用的是Base64Url變種。普通Base64編碼的結(jié)果里可能含有、/以及末尾的填充字符。但和/在URL里是有特殊含義的——會被解析成空格/會干擾URL路徑分割。JWT經(jīng)常出現(xiàn)在URL參數(shù)、請求頭這些地方如果里面夾著這種特殊字符解析起來就容易出問題。Base64Url的解決辦法是把替換成-把/替換成_并且去掉末尾的填充。就這三處改動讓JWT可以安全地放進(jìn)URL和Header里不用額外轉(zhuǎn)義。3. 完整走一遍從登錄到放行的流程3.1 登錄瞬間發(fā)生了什么光看結(jié)構(gòu)有點(diǎn)抽象我把整個(gè)流程串起來講一遍你就明白JWT是怎么在系統(tǒng)里流轉(zhuǎn)的了。假設(shè)現(xiàn)在有一個(gè)前后端分離的訂單系統(tǒng)。用戶在瀏覽器里輸入用戶名密碼點(diǎn)擊登錄。流程開始前端把用戶名密碼通過HTTPS POST到后端登錄接口后端驗(yàn)證賬號密碼確認(rèn)無誤后取出用戶ID把用戶ID、角色、過期時(shí)間這些信息組裝進(jìn)Payload后端用密鑰對Header和Payload做簽名生成完整的JWT后端把JWT返回給前端登錄流程結(jié)束注意看這個(gè)過程中服務(wù)端并沒有在內(nèi)存或Redis里保存這個(gè)Token的任何信息。Token生成完服務(wù)端自己就不再“持有”它了。接下來就看客戶端怎么用。3.2 客戶端拿到Token之后怎么用前端收到這個(gè)JWT后通常會把它存起來存哪可以是localStorage、sessionStorage或者cookie各有各的坑我后面專門講。用戶接下來要訪問訂單列表了前端在發(fā)請求時(shí)會在HTTP請求頭里加上這樣一行Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c這個(gè)Bearer前綴是一個(gè)約定俗成的寫法表示后面這個(gè)字符串是一個(gè)承載令牌Bearer Token。也有一些系統(tǒng)直接用Cookie來攜帶JWT前端代碼不用管瀏覽器自動帶上。但顯式放在Authorization頭里是目前API對接最通用的方式。3.3 服務(wù)端怎么驗(yàn)證一個(gè)JWT后端收到請求后不會去數(shù)據(jù)庫里查“這個(gè)Token是不是在有效會話列表里”它只需要做三件事把請求頭里的Token按.切分成三段拿到Header、Payload、Signature用同樣的算法Header里的alg字段會告訴它和服務(wù)器密鑰對前兩段重新計(jì)算一次簽名把算出來的簽名和客戶端傳來的簽名比對一致則驗(yàn)證通過驗(yàn)證通過后服務(wù)端還會順手檢查Payload里的exp字段如果當(dāng)前時(shí)間已經(jīng)超過過期時(shí)間就直接拒絕。通過之后服務(wù)端就直接信任Payload里攜帶的用戶ID、角色信息用它完成后續(xù)的業(yè)務(wù)邏輯。關(guān)鍵區(qū)別在于驗(yàn)證不依賴于查詢“存儲”。這就是它和Session最大的分水嶺。3.4 無狀態(tài)到底意味著什么Session方案里服務(wù)端拿到Session ID后必須“查”一下自己的存儲確認(rèn)會話存在才能放行這個(gè)動作叫“有狀態(tài)認(rèn)證”。JWT不需要這個(gè)查詢動作——Token本身就包含了所有驗(yàn)證所需的信息服務(wù)端拿著密鑰一算就知道Token真假。你只要算一遍簽名驗(yàn)一下時(shí)間結(jié)果就在眼前。這就是常說的“無狀態(tài)”。無狀態(tài)帶來的直接好處是任意一臺服務(wù)器都能獨(dú)立驗(yàn)證Token服務(wù)器之間不需要共享任何會話數(shù)據(jù)。應(yīng)用需要擴(kuò)容時(shí)隨便加機(jī)器就行不用考慮Session同步的問題。微服務(wù)架構(gòu)里各個(gè)服務(wù)各自驗(yàn)簽認(rèn)證邏輯從業(yè)務(wù)代碼里剝離開來這是JWT在分布式環(huán)境下越來越流行的核心原因。4. JWT的優(yōu)點(diǎn)和代價(jià)都得看清楚4.1 那些讓人舒服的地方我在項(xiàng)目里實(shí)際對比過Session和JWTJWT的優(yōu)勢相當(dāng)明顯??缬蚝彤悩?gòu)場景友好。Session依賴CookieCookies有跨域限制設(shè)置起來又得動域名又得配屬性。JWT放在Authorization頭里不受瀏覽器同源策略約束前后端分離、多端共用一套API都能很自然地對接受。移動端和原生應(yīng)用友好。App里面做Session要手動處理會話ID的保存和傳遞JWT相比之下簡單得多拿到就存請求就帶沒有任何額外機(jī)制。天然支持水平擴(kuò)展。前面說了服務(wù)端不存狀態(tài)擴(kuò)容時(shí)零負(fù)擔(dān)。這個(gè)優(yōu)勢在訪問量上來之后會體現(xiàn)得非常明顯。信息自包含。Payload里帶用戶ID和角色微服務(wù)拿到Token就能直接知道用戶是誰、權(quán)限多大不需要再回查統(tǒng)一的會話服務(wù)。在一個(gè)訂單服務(wù)、用戶服務(wù)、支付服務(wù)各自獨(dú)立的架構(gòu)里這種體驗(yàn)簡直順滑。4.2 那些必須付出的代價(jià)JWT也不是銀彈我自己使用過程中踩過的坑也大多在這些方面。Token無法在失效前主動作廢。這是JWT最讓人糾結(jié)的點(diǎn)。Session方案里管理員把會話從Redis里刪掉用戶立刻失效。JWT呢只要Token沒過期就算用戶改了密碼就算管理員把用戶封了服務(wù)端驗(yàn)簽仍然是“通過”的除非服務(wù)端額外維護(hù)一個(gè)黑名單那又回到“有狀態(tài)”的老路上了。Token體積偏大。JWT的Payload里帶了用戶信息加簽名后整體比一個(gè)Session ID大很多。每次請求都要在Header里帶一個(gè)幾KB的字符串雖然有Gzip壓縮能緩解但在移動端網(wǎng)絡(luò)環(huán)境下這開銷是實(shí)打?qū)嵉?。風(fēng)險(xiǎn)面前置到客戶端。Session方案里客戶端只持有一個(gè)隨機(jī)的ID就算被偷了ID本身沒暴露什么信息。JWT直接在客戶端存了一份可解碼的用戶信息。所以JWT對存Storage的安全要求更高XSS攻擊的風(fēng)險(xiǎn)要專門防范。依賴簽名算法和密鑰管理。服務(wù)端密鑰一旦泄露所有Token都能被偽造。而alg字段如果服務(wù)端沒做校驗(yàn)還有算法混淆攻擊的風(fēng)險(xiǎn)。這些在后面安全部分細(xì)講。5. 新手最容易踩的幾個(gè)坑5.1 把敏感信息直接塞進(jìn)Payload這是我最想強(qiáng)調(diào)的一個(gè)坑。JWT的Payload是Base64Url編碼不是加密。我見過有同學(xué)把用戶密碼、手機(jī)號、身份證號直接塞進(jìn)Payload理由是“項(xiàng)目里需要用到”完全沒有意識到這等于把敏感信息明文送到了客戶端手里。正確做法是Payload里只放非敏感的用戶標(biāo)識比如用戶ID、角色、過期時(shí)間這種信息。真正敏感的數(shù)據(jù)還是得到服務(wù)端按ID去查。一定記住一句話JWT的作用是“驗(yàn)證”和“標(biāo)識”不是“加密存儲”。所有寫進(jìn)Payload的內(nèi)容默認(rèn)都是半公開的。5.2 登出接口明明調(diào)了Token還是能用這是JWT新手幾乎必踩的坑。前端把Token從本地刪掉請求確實(shí)帶不過去了但這只是“客戶端這邊不再發(fā)送”而已。如果攻擊者提前把這個(gè)Token抄走了他照樣可以用這個(gè)Token繼續(xù)訪問接口。服務(wù)端層面要做真正的“登出”辦法有幾種一是維護(hù)一個(gè)Token黑名單登出時(shí)把Token的jti或原始字符串加進(jìn)黑名單過期時(shí)間到了才移除二是縮短Token有效期配合Refresh Token機(jī)制輪換三是部署時(shí)用網(wǎng)關(guān)層統(tǒng)一攔截。各有利弊但核心思想要明確單靠前端刪Token不叫真正的登出。5.3 Token有效期設(shè)置不合理我見過兩種極端一種是把exp設(shè)成一小時(shí)結(jié)果用戶用著用著突然掉線體驗(yàn)很差另一種是一不做二不休把過期時(shí)間設(shè)成一年甚至干脆不設(shè)expToken永久有效出安全問題根本沒法收場。后來我實(shí)踐中常用的模式是雙Token方案一個(gè)短期Token比如15分鐘或1小時(shí)負(fù)責(zé)正常業(yè)務(wù)訪問一個(gè)長期Refresh Token比如7天或30天負(fù)責(zé)快過期時(shí)申請新的短期Token。Refresh Token走單獨(dú)的接口只在需要續(xù)期的時(shí)候攜帶被攻擊的窗口就小很多。5.4 簽名密鑰和算法校驗(yàn)HS256和RS256兩種算法都有團(tuán)隊(duì)在用但它們的密鑰體系不一樣。HS256是對稱加密簽名和驗(yàn)證用同一個(gè)密鑰所以密鑰必須妥善保管在服務(wù)端絕不能下發(fā)到前端。RS256是非對稱加密簽名用私鑰驗(yàn)證用公鑰公鑰可以公開。正是因?yàn)镠eader里的alg字段是客戶端可以改的服務(wù)端如果把算法寫死只信任自己配置的那一種就不會出問題。如果動態(tài)讀取alg來驗(yàn)證攻擊者可以把a(bǔ)lg改成none或者HS256來混淆算法。這類攻擊在安全圈真實(shí)發(fā)生過服務(wù)端務(wù)必白名單校驗(yàn)算法。5.5 Token存哪里更安全localStorage、sessionStorage還是Cookie這個(gè)問題每次都能吵一輪。我的個(gè)人實(shí)踐經(jīng)驗(yàn)是localStorage簡單但容易被XSS腳本讀取Token一旦被拿走就是致命的Cookie配合HttpOnly屬性腳本代碼讀不到防XSS效果好但要留意CSRF攻擊如果項(xiàng)目XSS面控制得不好用HttpOnly Cookie更放心穩(wěn)妥的做法是短期Token存內(nèi)存或比較安全的地方Refresh Token放HttpOnly Cookie盡量減小被竊取的暴露面。具體取舍要看團(tuán)隊(duì)對安全風(fēng)險(xiǎn)的接受程度。6. 一份不折騰的學(xué)習(xí)路線建議6.1 最快的方式跑通一個(gè)最小示例學(xué)這個(gè)東西光看不練真的記不住。我的建議是直接寫一個(gè)最小的Demo感受一下“簽出來、驗(yàn)回去”的過程。用Node.js的jsonwebtoken庫幾分鐘就能跑通。const jwt require(jsonwebtoken); // 模擬Payload const payload { sub: 10001, name: 某開發(fā)者, role: admin }; const secret my-secret-key; // 簽發(fā)默認(rèn)使用HS256算法 const token jwt.sign(payload, secret, { expiresIn: 2h }); console.log(生成的JWT:); console.log(token); // 驗(yàn)證 try { const decoded jwt.verify(token, secret); console.log(\n驗(yàn)證通過解析結(jié)果:); console.log(decoded); } catch (err) { console.log(\n驗(yàn)證失敗:, err.message); }你把它save成demo.jsnpm install jsonwebtoken之后node demo.js跑起來控制臺會打印出完整的JWT和驗(yàn)證后的對象。把Token復(fù)制到 jwt.io 上解碼看看直觀感受一下Payload的信息是完全可見的。這一步能讓你把抽象的結(jié)構(gòu)和實(shí)際字符串對上號。6.2 再動手寫一段“偽造”Token跑通簽發(fā)和驗(yàn)證之后我強(qiáng)烈建議你做一個(gè)實(shí)驗(yàn)把生成的Token隨便改一個(gè)字符比如把Payload里的某個(gè)字母換掉再放進(jìn)驗(yàn)證代碼里跑一次。你會發(fā)現(xiàn)jwt.verify立刻拋錯(cuò)提示簽名不匹配。這個(gè)“破壞性”實(shí)驗(yàn)做完你對“簽名防篡改”的記憶會非常牢固。6.3 最后還想多說一句學(xué)了JWT之后別急著把項(xiàng)目里的Session全部替換掉。兩者各有所長Session在需要“立即失效”場景下有不可替代的優(yōu)勢JWT在分布式、多端場景里更方便。你現(xiàn)在要做的不是站隊(duì)而是理解它們各自解決什么問題。以后真正面對項(xiàng)目選型時(shí)腦子里能有個(gè)清晰的判斷框架這比多背幾個(gè)名詞有用得多。我自己在好幾次項(xiàng)目里從Session切到JWT感受最深的不是它“更安全”而是它“更簡單”——它讓每個(gè)服務(wù)都能獨(dú)立驗(yàn)證請求把認(rèn)證這件事從業(yè)務(wù)代碼里解耦了出來。但每一次當(dāng)有人問我“JWT能不能主動注銷”我還是會停下來想兩秒然后告訴他這就是無狀態(tài)要付出的代價(jià)。把這個(gè)問題想明白了你就真正理解了JWT。