淺析 JWT

土二寸發表於2019-05-25

JSON Web Token,簡稱 JWT,讀音是 [dʒɒt]( jot 的發音),是一種當下比較流行的「跨域認證解決方案」。注意它是一套 RFC 規範,相關的還有 JWE/JWS/JWK/JOSE。它有很多優點,也有侷限性,但我們可以配合其他方案做出適合自己業務的一套方案。本篇是對 JWT 做一個簡單的介紹和簡單實踐總結。

JSON Web Token (JWT) is a compact claims representation format intended for space constrained environments such as HTTP Authorization headers and URI query parameters.

JWT的組成

JWT 由三部分組成:頭部、資料體、簽名/加密。

這三部分以 . (英文句號)連線,注意這三部分順序是固定的,即 header.payload.signature 如下示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

1. 頭部 The Header

這部分用來描述 JWT 的後設資料,比如該 JWT 所使用的簽名/加密演算法、媒體型別等。

這部分原始資料是一個JSON物件,經過Base64Url編碼方式進行編碼後得到最終的字串。其中只有一個屬性是必要的:alg——加密/簽名演算法,預設值為HS256

最簡單的頭部可以表示成這樣:

{
    "alg": "none"
}

其他可選屬性:

  • typ,描述 JWT 的媒體型別,該屬性的值只能是 JWT,它的作用是與其他 JOSE Header 混合時表明自己身份的一個引數(很少用到)。
  • cty,描述 JWT 的內容型別。只有當需要一個 Nested JWT 時,才需要該屬性,且值必須是 JWT
  • kid,KeyID,用於提示是哪個金鑰參與加密。

Base64url 編碼是 Base64 的一種針對 URL 的特定變種。因為 = 、+、/ 這個三個字元在 URL 中是有特定含義的,所以 Base64url 分別將 = 直接忽略,+ 替換成 -,/ 替換成 _

2. 資料體 The Payload

這部分用來描述JWT的內容資料,即存放些什麼。

原始資料仍是一個 JSON 物件,經過 Base64url 編碼方式進行編碼後得到最終的 Payload。這裡的資料預設是不加密的,所以不應存放重要資料(當然你可以考慮使用巢狀型 JWT)。官方內建了七個屬性,大小寫敏感,且都是可選屬性,如下:

  • iss (Issuer) 簽發人,即簽發該 Token 的主體
  • sub (Subject) 主題,即描述該 Token 的用途,一般就最為使用者的唯一標識
  • aud (Audience) 作用域,即描述這個 Token 是給誰用的,多個的情況下該屬性值為一個字串陣列,單個則為一個字串
  • exp (Expiration Time) 過期時間,即描述該 Token 在何時失效
  • nbf (Not Before) 生效時間,即描述該 Token 在何時生效
  • iat (Issued At) 簽發時間,即描述該 Token 在何時被簽發的
  • jti (JWT ID) 唯一標識

除了這幾個內建屬性,我們也可以自定義其他屬性,自由度非常大。

這裡對 aud 做一個說明,有如下 Payload:

{
    "iss": "server1",
    "aud": ["http://www.a.com","http://www.b.com"]
}

那麼如果我拿這個 JWT 去 http://www.c.com 獲取有訪問許可權的資源,就會被拒絕掉,因為 aud 屬性明確了這個 Token 是無權訪問 www.c.com 的,有同學會說這部分反正不加密,那我本地把 www.c.com 加入進去不就完事了。別急,下面這部分看完先。

3. 簽名/加密 The signature/encryption data

這部分是相對比較複雜的,因為 JWT 必須符合 JWS/JWE 這兩個規範之一,所以針對這部分的資料如何得來就有兩種方式,我們先來看一個簡單的例子,有如下 JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZW1vIiwibmFtZSI6InhmbHkiLCJhZG1pbiI6dHJ1ZX0.5SHkLkM4KAHtOCtLhSNHOgkFZhPO419ukot1C5bgyUM

對前兩部分用 Base64url 解碼後能得出相應原始資料,

Header 部分:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload 部分:

{
  "sub": "demo",
  "name": "xfly",
  "admin": true
}

根據 Header 部分的 alg 屬性我們可以知道該 JWT 符合 JWS 中的規範,且簽名演算法是 HS256 也就是 HMAC SHA-256 演算法,那麼我們就可以根據如下公式計算最後的簽名部分:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

其中的金鑰是保證簽名安全性的關鍵,所以必須儲存好,在本例中金鑰是 123456。因為有這個金鑰的存在,所以即便呼叫方偷偷的修改了前兩部分的內容,在驗證環節就會出現簽名不一致的情況,所以保證了安全性。

在實現過程中,遇到了這樣一個問題:如果使用 RS256 這類非對稱加密演算法,加密出來的是一串二進位制資料,所以第三部分還是用 Base64 編碼了一層,這樣最終的 JWT 就是可讀的了。

Why JWTs

JWTs 相比於在記憶體中使用隨機 Token 的會話管理方式,其最大優勢在於認證邏輯的可擴充套件性。舉個例子,對於認證邏輯,完全可以單獨部署,或者使用第三方的認證服務。

而相比於使用資料庫進行統一儲存和管理 Token 的會話管理方式,其最大優勢在於消耗小,不需要頻繁呼叫資料庫這類 I/O 耗時操作。

安全

  1. 因為 JWT 的前兩個部分僅是做了 Base64 編碼處理並非加密,所以在存放資料上不能存放敏感資料。
  2. 用來簽名/加密的金鑰需要妥善儲存。
  3. 儘可能採用 HTTPS,確保不被竊聽。
  4. 如果存放在 Cookie 中則強烈建議開啟 Http Only,其實官方推薦是放在 LocalStorage 裡,然後通過 Header 頭進行傳遞。

Cookie 的 HTTP Only 這個 Flag 和 HTTPS 並不衝突,你會發現其實還有一個 Secure 的 Flag,這個就是指 HTTPS 了,這兩個 Flag 互不影響的,開啟 HTTP Only 會導致前端 JavaScript 無法讀取該 Cookie,更多的是為了防止 類 XSS 攻擊。

問題和思考

JWT 的缺點其實也蠻多的,適不適用得具體看業務場景,哪個優勢更大用哪個。(一點感悟:在寫這篇文章前一直是 JWT 的堅定擁護者,越寫越發現其實傳統的 Session-Cookie 方案挺好的,很成熟。它們兩者都有優缺點,選型上要多思考斟酌才行。)

1. 資料臃腫

因為 payload 只是用 Base64 編碼,所以一旦存放資料大了,編碼之後 JWT 會很長,cookie 很可能放不下,所以還是建議放 LocalStorage,但是每次 HTTP 請求都帶上這個臃腫的 Header 開銷也隨之變大

2. 無法廢棄和續簽

  1. 如果有效期設定過長,意味著這個 Token 洩漏後可以被長期利用,危害較大,所以一般我們都會設定一個較短的有效期。由於有效期較短,意味著需要經常進行重新授權的操作。
  2. 假設在使用者操作過程中升級/變更了某些許可權,勢必需要重新整理以更新資料。

要解決這個問題,需要在服務端部署額外邏輯,常見的做法是增加重新整理機制和黑名單機制,通過 Refresh Token 重新整理 JWT,將需要廢棄的 Token 加入到黑名單。

3. Token 丟失

如果認證邏輯是在自己伺服器上做的話,我們的 JWT secret key 一旦丟失或者洩露那隻能通過更換 key 這一種辦法了,但這樣做的話會導致全部使用者都需要重新登入,所以 key 的保管很重要。如果我們的認證邏輯放在第三方服務上,那其實我們就完全不用操心這部分了,很貼心吧 :)

我覺得 JWT 的最大優勢在於可以把認證邏輯完全從應用服務中剝離出來,交給第三方 JWT 認證服務或者自己部署的認證伺服器上。這樣就把使用者的賬號密碼等敏感資訊放在單獨的伺服器上,更容易管理和維護(相比而言應用伺服器更容易出現漏洞)。這樣做的好處很明顯:

  1. 應用伺服器完全不需要關心使用者的賬號密碼,也不需要關心使用者的註冊登入,只需要校驗 JWT 的合法性即可。
  2. 應用伺服器不需要儲存 JWT 的 key,降低洩露金鑰的概率。

最佳實踐:加密演算法使用 RS256 而不是 HS256

假設我們的認證邏輯放在認證伺服器上(比如說第三方的認證服務),使用 HS256 演算法進行加密,整個過程如下:

  1. 使用者通過登入介面,攜帶賬號密碼請求認證伺服器
  2. 認證伺服器校驗賬號密碼,校驗失敗返回登入失敗資訊,校驗成功則進行下一步
  3. 將使用者的一些必要資訊(主要是使用者的唯一標識)封裝成 JWT 的 payload 部分
  4. 將 JWT 的 header、payload 分別進行 Base64url 編碼,用 . 連線後與金鑰一起參與 HS256 加密得到 signature
  5. 將三部分用 . 連線後組成最終的 JWT 返回

應用伺服器在接收到需要認證的介面請求時,先獲取請求中攜帶的 JWT,然後進行校驗,這裡我們就會看到一些問題。因為是對稱式加密演算法,所以加密用的金鑰和解密用的金鑰必須是同一個,否則是應用伺服器是沒法做校驗的。那麼我們只能把金鑰在應用伺服器上也儲存一份,從而增加了金鑰洩漏的可能性(當然也只是相比於使用非對稱式加密演算法而言,畢竟在應用伺服器裡還有很多其他的 secret key,能丟 JWT 的key,其他 key 也能丟。。。)

那我們使用 RS256 非對稱式加密演算法就不會丟了嗎?是的,至少應用伺服器不用背這個鍋!因為應用伺服器根本就不需要儲存這份重要的金鑰。

簡單科普下非對稱式加密演算法:有兩個金鑰,一個公開金鑰和一個私有金鑰,私鑰參與加密,公鑰用於解密,巧妙之處是解密只能用公鑰來解,即便是加密用的金鑰也無法對密文進行解密。你可以看到加密和解密需要兩個不同的金鑰,故稱之為非對稱加密。

所以我們應用伺服器只需要存一份公開金鑰用於校驗和解密認證伺服器簽發的 JWT 即可,即便這個公開金鑰洩漏了也沒事。因為用公開金鑰進行加密的密文再用公開金鑰去解密是解不出來的,也就是說我們的應用伺服器會認為這個 JWT 是無效的!

參考連結

探索電腦科學,編寫有趣的程式碼