[筆記] 透過 JWT 實作驗證機制

過去在專案中大多透過 Session 和 Cookie 實作驗證機制(例如 👉 透過 Passport.js 實作驗證機制),在使用者成功登入後,會在伺服器端建立 Session,並提供客戶端相對應的 Session ID。往後來自客戶端的請求,伺服器以 Cookie 中 Session ID 尋找 Session 確認使用者驗證狀態。然而,使用這個方法,意味著每位用戶經過驗證後,都需要為其建立和存取 Session(可能在資料庫中)當用戶量增加,資料庫的花費成本將不斷提高;每當用戶拜訪需經驗證的路徑時,也需要額外到資料庫執行資料查詢,花費額外的效能。

透過 Session 和 Cookie 實作驗證機制

JSON Web Token(JWT)也因此誕生,它更符合設計 RESTful API 時「Stateless 無狀態」原則:意味著每一次從客戶端向伺服器端發出的請求都是獨立的,使用者經驗證後,在伺服器端不會將用戶驗證狀態透過 Session 儲存起來,因此每次客戶端發出的請求都將帶有伺服器端需要的所有資訊 — 從客戶端發出給伺服器端的請求將帶有 JWT 字串表明身份 。整個驗證的流程大致會是:

  1. 伺服器端在收到登入請求後驗證使用者
  2. 伺服器端產生和回傳一組帶有資訊,且僅能在伺服器端被驗證的 Token
  3. Token 被回傳後,存取在「客戶端」(大多存在瀏覽器的 Storage 當中)
  4. 往後客戶端向伺服器端發送請求時,皆附帶此 Token 讓伺服器端驗證
  5. 若伺服器端在請求中沒有找到 Token,回傳錯誤;若有找到 Token 則驗證

JWT 組成

JWT 是一組字串,透過(.)切分成三個為 Base64 編碼的部分:

  • Header:含 Token 的種類及產生簽章(signature)要使用的雜湊演算法
  • Payload:帶有欲存放的資訊(例如用戶資訊)
  • Signature:編譯後的 Header、Payload 與密鑰透過雜湊演算法所產生
// base64(Header) + base64(Payload) + base64(Signature)
// xxxxx.yyyyy.zzzzz

Header

Header 是一個包含定義 Token 種類(type)及雜湊演算法(alg)資訊的 JSON。在此設定 Token 種類為 JWT、產生簽章(signature)要使用的雜湊演算法為 HS256。此 JSON 將被轉換成 Base64 編碼,成為第一個部分:

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

Payload

Payload 也是一個 JSON,使用者和相關的資訊都可以放置其中。通常會使用 exp 設定 Token 到期的時間、iat 設定 Token 簽發時間。最後再被轉換成 Base64 編碼,成為第二個部分:

{
"_id": "<user_id>",
"name": "Mike",
"exp": 1300819380
}

Payload 又可以被稱為 claims,能想成使用者是透過附帶 JWT 通過認證,因此能說這筆資訊是屬於他(她)的。

❗️不要將隱私資訊存放在 Payload 當中Payload 和 Header 被轉換成 Base64 編碼後,能夠被輕易的轉換回來
因此不應該把如用戶密碼等重要資料存取在 Payload 當中

Signature

簽章(Signature )是將被轉換成 Base64 編碼的 Header、Payload 與自己定義的密鑰,透過在 Header 設定的雜湊演算法方式所產生的。

由於密鑰並非公開,因此伺服器端在拿到 Token 後,能透過解碼,確認資料內容正確,且未被變更,以驗證對方身份。

安裝 jsonwebtoken

這次使用的是 jsonwebtoken 套件,透過這個套件能更方便的創建、完成驗證 JWT,首先透過 npm 安裝套件:

$ npm install jsonwebtoken

載入 jsonwebtoken

const jwt = require('jsonwebtoken')

產生 JWT

透過模組上的sign()方法可以產生一組 JWT,產生時需要將存放在 Token 當中的資料帶入payload參數中、密鑰帶入secretOrPrivateKey參數中:

jwt.sign(payload, secretOrPrivateKey, [options, callback])

👉 options參數非必填,但透過帶入options物件能設定許多選項。例如:

  • algorithm:設定產生簽章要使用的雜湊演算法(預設: HS256)
  • expiresIn:設定 Token 多久後會過期(自動在 Payload 新增 exp
  • noTimestamp:設定產生 JWD 時不會自動在 Payload 中新增iat時間

👉 callback 參數非必填,但若要以非同步方式產生 JWD,可以提供一個 Callback 函式,Token 將能在 Callback 函式中取得。

👉 以下範例中,僅將使用者 ID存入 Token、設定 Token 時效為一天,並帶入自訂密鑰(SECRET),產生一組 Token

查看產生的 JWT

客戶在通過認證後,會收到來自伺服器所回傳的 JWT,並將其存取起來(可能是存在 localStorage 當中)。往後在發送請求時,需要在 Request Header 中設定Authorization,將 JWT 一併帶入送至後端伺服器。Authorization的格式通常由 Token 類型(Type)+ 空格 +JWT 所組成:

Authorization: <type> <credentials>

客戶端請求中帶入 JWT

JWT 是一種 Bearer Token,因此在Authorization帶入:

Authorization: 'Bearer ' + token

伺服器端取得 JWT

從來自客戶端請求的 Header 可以獲取Authorization所附帶的完整值,需透過replace()方法處理,最終獲取 JWT 輸出

輸出 JWT

以上方法能產生一組如下的 JWT,透過(.) Token 能分為三個部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1ZDRlOGQ5YTA2MWMxYTJjMDIxY2JlMTgiLCJpYXQiOjE1NjU4NTczMjAsImV4cCI6MTU2NTk0MzcyMH0.GQVyQJLmwXd2jQZsjZ8n6cAWD0HQGjvlp2Mk8kAsGy8

確認 JWT

Token 的三個部分都是 Base64 編碼,因此可以到 jwt.io 將其轉換回原來的 JSON 資料。簽章部分由於還透過與私鑰,經雜湊演算法產生,因此可以將自訂私鑰帶入「VERIFY SIGNATURE」區塊,驗證該 Token 的正確性:

❗️使用 JWT 的重點不在於把資料隱藏起來,畢竟 Payload 資料是可以被轉換回來的
但由於伺服器端才擁有密鑰,因此即使 payload 被修改,轉換成 Base64 重新置入 Token 中,透過與 Signature 比對之下,就能發現資料的不一致,產生驗證錯誤

驗證 JWT

透過模組上的verify()方法可以完成 Base64 解碼與 Token 的驗證,並回傳解碼後的 Payload — 驗證時需要帶入欲驗證的token與自訂的密鑰:

jwt.verify(token, secretOrPublicKey, [options, callback])

👉 以下範例中,將來自客戶端的 Token 和自訂密鑰(SECRET)帶入 verify()函式中,並將回傳的 Payload 存放在decoded中:

專案實作

最近在學習和實作 RESTful API 打造 Task App,其中驗證機制就是透過 JWT 實現,因此以下將紀錄使用 jsonwebtoken 套件,實作註冊、登入、登出、登出所有裝置四條路由,及建立 Route Protection Middleware 完成驗證機制。

初始化專案

在專案初始時,透過 Mongoose 連線 MongoDB 資料庫、將已經建立好的使用者 Model 引入、建立了註冊、登入、登出、登出所有裝置四條路由、啟動並監聽伺服器:

👉 專案會額外使用到的套件:bcryptjsonwebtoken

註冊路由

使用者發出註冊請求到伺服器端後,從請求中req.body取出驗證資訊,並儲存至資料庫當中。成功建立與儲存資料後,回傳 Status Code 201 與建立之用戶資料。

密碼處理

將使用者資料存至資料庫以前,先將密碼透過 bcrypt 處理,獲取一組 hashed password 再存至資料庫較安全。在很多專案中,除了註冊時會需要處理密碼,也可能會有修改密碼的功能。因此直接使用 Mongoose 提供的 Middleware 統一包裝密碼處理功能:

  • userSchema上使用 Pre middleware:監聽指定的事件(第一個參數),並在展開該事件(pre)以前,執行定義的函式(第二個參數)
  • Pre middleware 帶入save:監聽文件被存取的事件
  • 在 Middleware 當中this指向目前正被儲存的使用者文件
  • 透過文件上 isModified() 方法:確認文件在被儲存時,password欄位是有被變更的(變更包含初次建立密碼和修改密碼時)
  • 若確認密碼欄位有被變更,透過 bcrypt 處理密碼

產生 JWT

註冊和登入成功後,都算完成驗證,因此伺服器端將產生一組 JWT 回傳給客戶端。由於會需要在超過一個以上的路由中「為當前使用者產生 JWT」,因此直接在「使用者實例(instance)」上建立可共用的方法(methods)

  • 建立方法:在userSchemamethods物件上建立generateAuthToken函式
  • 建立的方法中this指向呼叫此方法的使用者實例(instance)
  • 將產生的 JWT 存入資料庫,讓使用者能跨裝置登入,也能登出單一裝置
  • 此方法將回傳當次產生的 JWT

新增欄位存放 JWT

userSchema中新增存放 JWT 的tokens欄位,此欄位的資料格式是一個陣列,其中帶有一個個含有token的物件:

加入註冊路由中

將註冊用戶的資訊建立並儲存至資料庫後,透過使用者實例(instance)的generateAuthToken方法,為此用戶產生一組 JWT 存至token上。伺服器回傳時,不僅回傳成功註冊的用戶資訊,也將 JWT 回傳給客戶端。

測試路由

在 Postman 中透過POST方法向/users路徑發出請求,並在 Body 中帶入註冊所需資訊。回傳後,確認 Body 中包含用戶資訊及 JWT 資料。

在 Robo 3T 中也能確認該用戶的資料及 Token 有被成功存至資料庫中:

登入路由

使用者發出登入請求到伺服器端後,會從請求中req.body取出驗證資訊,通過驗證(待完成)後,同樣為使用者產生 JWT 一併回傳給客戶端。

建立驗證方法

在此欲將驗證功能,獨立寫成一個可以重複被使用的函式,因此直接在使用者 Model 上建立一個驗證方法(statics)

  • 建立方法:在userSchemastatics物件上建立findByCredentials函式
  • findByCredentials函式接收兩個參數:使用者emailpassword
  • 若資料庫中無該名使用者或密碼驗證失敗,皆會丟出錯誤訊息
  • 成功驗證後,此方法將回傳當次使用者完整資料
📍 methods(instance methods) vs statics(model methods)
Document(物件實例)的共用方法將被建立在 userSchema methods 物件上
Model 使用的方法將被建立在 userSchema statics 物件上

加入登入路由中

將欲驗證的使用者 email 和密碼,帶入使用者 Model 的findByCredentials方法中,並把驗證成功回傳的使用者資訊存至user上。最終伺服器回傳用戶資訊和 JWT 給客戶端。

測試路由

在 Postman 中透過POST方法向/users/login路徑發出請求,並在 Body 中附帶登入所需資訊。回傳後,確認 Body 中包含用戶資訊及 JWT 資料。

Route Protection

在設計路由時,有些路徑將僅提供經過驗證的使用者才能拜訪,因此將建立一個驗證用的 Middleware,並將其置入所有需擁有權限才可拜訪的路由中 — Route Protection:

  • req.header中取得和擷取 JWT 驗證
  • Token 驗證成功後,到資料庫找尋符合用戶 id 和 Tokens 欄位中包含此 Token 的使用者資料
  • Token 驗證成功且找到符合的使用者資料後,分別存至req.tokenreq.user上供後續使用

將驗證 Middleware 加入路由中

建立了一個驗證用的 Middleware 後,就能將其放置於所有需權限才能拜訪的路由中。例如:若使用者想登出,前提是該名使用者是經過驗證狀態,才能執行登出的行為:

  • 將驗證 Middleware 載入
  • 在登出路由中加入驗證 Middleware

登出路由

登出的運作方式就是將當次的 Token 從使用者資料中 tokens 欄位刪除— 使用者發出登出請求到伺服器端後,會先通過auth驗證 Middleware,因此能在請求中取得usertoken資料來使用:將當前的 Token 從使用者 Tokens 欄位資料中篩掉,並存回資料庫當中。

測試路由

在 Postman 中透過POST方法向/users/logout路徑發出請求,並在 Request Header 中設定Authorization,附帶 JWT 資訊。回傳後,確認 Status Code 為 200 OK:

登出所有裝置路由

登出的運作方式就是將所有 JWT 從使用者資料中 tokens 欄位刪除

結語

透過這次的實作,很開心能透過不同的方式實作驗證機制。運用 JWT 來實現與運用 Session + Cookie 的運作方式有很多不同之處。以 JWT 可以解決資料庫擴展性問題,並提升效能以增加用戶體驗,且更能符合設計 RESTful API 時「Stateless 無狀態」原則。

--

--

持續培養實力與技能以成為一位更好的軟體工程師 — 紀錄學習心得與心路歷程

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store