帳密認証與JWT、利用 middleware,認証 JWT middleware
回憶
昨天完整的介紹了 router 路由,它幫助我們輕易的組裝出 API。完成此目的三個核心概念是 中間件(middleware)、路由(routing)、流(stream),其中 middleware 是更加的重要。
因為 middleware 可以做很多事,例如:
- 任易串接在 router 中,想放哪都可以:因為 middleware 簽章都長的一樣,可以放在
router.use(middleware、router.METHOD(path, middleware)、app.use(middleware和app.METHOD(path, middleware)中。 - 共用處理流程:放在較上層的路由中可以使下層所有路由都作用到。像是
app.use(express.json())放在越前面,可以使後面串進的 router 都可以被express.json()這 middleware 作用(req.body解析成 JSON object)。 - middleware 可以獨立出套件:當 middleware 的相依越少,越不會有 Side Effect,就可以獨立出來做成套件供別人使用。
- middleware 命名直覺增加可讀性:像
express.json()就是跟 JSON 有關,越直覺的命名可以增加程式碼可讀性。下面雖然串更多 middleware,但意思很明顯1router.post('/api/accounts', LogMiddleware, AuthMiddleware, AddAccountMiddleware, WrappedDataEndpointMiddleware)
目標
- 基本帳密認証
- 什麼是JWT?
- 利用 middleware,認証 JWT middleware
帳密認証
基本的帳密認証(token 版)
我們採用的模式是,認証完成後回應 token 給 client,client 自己留著,每當client要對後端操作時會帶著 token 一併給後端,而不是用在後端保留登入資訊(session)。 這樣做有個好處是後端是無狀態的(stateless),任何一個台可以識別 token 的後端都可以服務 client,以提高後端的可擴展性(scalability)。
基本的認証機制有幾個步驟(與上圖對應)
- 使用者送出帳號、密碼
- 後端到資料庫比對
- 資料庫回傳用戶資料
- 回傳 token
雖然看起來很單純但在這些環節中有些資安注意事項
- 使用者送出帳號、密碼:
- 儘量使用 https 連線,request message 會被加密傳送(包含網址),攔截封包的人最多只能看到 hostname。 (其實,還有個人會知道,就是 Chrome,因為你是用它的瀏覽器)
- 儘量不要明碼儲存密碼,可以經過「不可反解」加密後(ex: sha256)才存或傳送,因為 Web Storage / cookie,是可以透過 javscript 存取查看的
- 能不儲密碼就不要存,就算是加密密碼它也是密碼阿! 若要持續性登入,可以用存 token 代替
- 後端到資料庫比對:拿到帳密,就去資料庫比對
- 依需求決定:後端與資料庫連線要不要加密連線或資料庫要不要設帳密
- 儘量不要明碼儲存密碼在資料庫中
- 資料庫回傳用戶資料:
- 用戶資料要確保把敏感資料過濾,也可以在後端一收到用戶資料立刻過濾敏感資料,減少敏感資料外洩的可能性
- 回傳 token
- token 可以存在 client cookie 中,並設定
httpOnly(Cookie只能被伺服端存取,client 無法用 javascript 讀取)、secure(只能透過https的方式傳輸) - 若用上述存在 cookie 中,也不用刻意帶 token 到未來發出的 request 中(少寫一些程式),瀏覽器會自動帶入
- token 可以存在 client cookie 中,並設定
Token 像什麼?
token 是經過認証單位認証後,所簽發(sign)的字串。 client 拿到 token 後當要求後端服務時一併送出,後端就可以依 token 識別身份給與服務。
JWT (JSON Web Token) 是什麼?
我們要採用 JWT (JSON Web Token) 做為 token 的資料格式。
它是由三個部分組成的
1標頭(Header).內容(Payload).簽名(Signature)
-
標頭(Header):Base64編碼的字串。一般內含兩個屬性:token 類型、雜湊(hashing)函數的名字(ex: HMAC SHA256 or RSA),如:
1{ 2 "alg": "HS256", 3 "typ": "JWT" 4}再透過 Base64Url 編碼,一般在轉換前,會把不可見字元(ex: 空白, 換行)拿掉
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9就是 Header。
-
內容(Payload):Base64編碼的字串。內含一堆 Claims,像是:(截錄自JSON Web Token (JWT) 簡介)
iss: The issuer of the token,token 是給誰的sub: The subject of the token,token 主題exp: Expiration Time。 token 過期時間,Unix 時間戳記iat: Issued At。 token 建立時間, Unix 時間戳記jti: JWT ID。針對當前 token 的唯一標識
上面只列出一些 JTW 定義的 claims,其它見 IANA JSON Web Token Registry。你也可以自己放任何的資料。來個 Payload 可能資訊
1{ 2 "sub": "1234567890", 3 "name": "John Doe", 4 "iat": 1516239022 5}再透過 Base64Url 編碼,一般在轉換前,會把不可見字元(ex: 空白, 換行)拿掉
1eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ就是 Payload。
到目前為止的 Header 和 Payload 雖然是 Base64Url 編碼後,但它們 可以解碼,所以也算是明文資料,不應該放敏感資料。
-
簽名(Signature):拿 Header、Payload和一個密鑰(secret)當參數,經過不可反解的雜湊函數後得到。以 HMAC SHA256 來說
1HMACSHA256( 2 base64UrlEncode(header) + "." + 3 base64UrlEncode(payload), 4 secret)這裡 header, payload是指未經過 Base64Url 編碼, secret 是
your-256-bit-secret,產生:1SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
最後的 JTW 就是
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT首頁 有 JTW 加解密互動網頁可以玩玩看。
要注意,這個 secret 要好好保存在後端,這是拿來判斷 JWT 是否有效的鑰匙。
JWT 帶來什麼好處?
- 自帶狀態:自帶JWT 中可以自帶狀態,不用把狀態存在後端,也不會增加後端負擔。適合運行在 無狀態 架構的後端。若是把 token 過期時間放在 payload 中,就可以為 token 加入效期特性。
- 自驗性:只要有同樣的 secret ,就可以判斷 JWT 是否有效,只要簡單在做一次
看結果和 Signature 一不一樣,就可以判斷 JWT 是否有效。
1HMACSHA256( 2 JWT.header + "." + 3 JWT.payload, 4 secret) - 易變性/完整性(integrality):跟 hashing 函數一樣,只要 Header 或 Payload 的內容改一點點,Signature 的結果就有很大的改變,所以保証資料的完整性。
最後要注意一件是:
1JWT 解決的是簽証(sign)安全,不是傳輸全安全,要配合加密通道(ex: https)才能安全地傳遞 JWT。
JWT 要自己寫嗎?
JWT首頁 有不同程式語言的實作套件,直接用就可以了。 Node.js 用的是 jsonwebtoken。
實作自己的認証
過程請見 github commit log ithelp-30dayfullstack-Day19 執行前需要先開 mongodb (
npm run startdb),不用時可以關 mongodb (npm run stopdb)
我們例用 JWT 來實作認証機制
POST /api/auth/login這 api 是用來登入POST /api/echo這 api 需要有 token 才能運作,利用middleware 輕鬆掛入
實作 POST /api/auth/login
-
加入
./routers/AuthRouter.js專們處理受權相關1const express = require('express'); 2 3/** 4 * 5 * @param {object} dependencies 6 */ 7function createRouter(dependencies) { 8 // Get dependencies 9 const { } = dependencies; 10 11 // Create a router 12 var router = express.Router(); 13 14 /* POST log */ 15 router.post('/login', function (req, res, next) { 16 next(new Error('Not implement')); 17 }); 18 return router; 19} 20 21module.exports = { 22 createRouter 23}; -
串入 root router 串入root router (不懂的請見:Day 18 - 二周目 - 剖析 express 路由(router) 三概念:中間件(middleware)、路由(routing)、流(stream))
1// routers/index.js 2router.use('/api/auth', authRouter);authRouter 物件設定 (不懂的請見:Day 17 - 二周目 - 依賴注入與組態化專案)
1// app.js 2const { createRouter: createAuthRouter } = require('./routes/AuthRouter'); 3 4container.register({ 5 ...略 6 authRouter: asFunction(createAuthRouter, { lifetime: Lifetime.SINGLETON }), 7});Postman 打看看
-
帳密比對實作 我們假設
verifyUser()會做資料庫查詢1// routers/AuthRouter.js 2async function verifyUser(data) { 3 const username = _.get(data, 'username'); 4 const password = _.get(data, 'password'); 5 6 if(username === 'billy' && password === '1234') { // pass 7 return Promise.resolve({ 8 username, 9 email: 'billy@gmail.com', 10 }); 11 } 12 return Promise.reject(new Error('Fail')); 13} -
套用帳密比對
1// routers/AuthRouter.js 2router.post('/login', function (req, res, next) { 3 const data = req.body; 4 verifyUser(data) 5 .then(user => { 6 res.json(user); 7 }) 8 .catch(next); 9});
到目前我們做出簡單的帳密驗証。接下來,我們要為驗証成功的 client 回傳 JWT
回傳 JWT
我們約定把 JWT 儲存在client 的 cookie中
- 安裝 jsonwebtoken
1npm install jsonwebtoken --save - 當帳密比對成功,就產生 JWT 並回傳
這裡用
1// routers/AuthRouter.js 2const EXPIRES_IN = 10 * 1000; // 10 sec 3const SECRET = 'YOUR_JWT_SECRET'; 4 5router.post('/login', function (req, res, next) { 6 console.log(JSON.stringify(req.cookies)); // 印出 cookies 7 const data = req.body; 8 verifyUser(data) 9 .then(user => { 10 const token = jwt.sign(user, SECRET, { expiresIn: EXPIRES_IN }); 11 res.json({ 12 token 13 }); 14 }) 15 .catch(next); 16 });expiresIn選項可以方便地指定簽發的 JWT 多久到期,像我們設 10 秒。 - 試打看看
我們就可以得到 JWT
- 強化安全性
加入這行,後端要求 client 設定 cookie
1res.cookie('token', token, { maxAge: EXPIRES_IN, httpOnly: true}); // 回應 client ,把 token 存在名為 token 的 cookie 並設定相關屬性
再試打一下,查看 client(Postman) 收到回應的 headers
HttpOnly 設定時,cookie token 不能用 javascript 取出。但你可以在 Postman 的 Cookies,可以查看所有 cookies,你會看到 token 被設定
另外,觀察到:
- 因為我們設定 cookie 10 秒到期(
EXPIRES_IN),所以時間到後再看一次就會消失。 - 之後的 request 會一直帶著 cookies 一併送給後端
第一次打,沒有 cookies 所以印出
{},第二次打,因為前一次登入成功並設定cookies,所以就會有 cookies。
為 POST /api/echo 加入 token 驗証
假設 client 會把 JWT 放在 名為 token 的 cookie 中,所以後端可以由
1req.cookies.token
得到來自 client 的 token。因此,我們只要驗証此 token 就可以知道,request 是否有授權。
我們利用 middleware 來做 JWT 的驗証
- 加入
VerifyJWT,當VerifyJWT()動態產生 middelware我們很刻意的利用閉包技巧,輸入1// middlewares/VerifyJWT.js 2const _ = require('lodash'); 3const jsonwebtoken = require('jsonwebtoken'); 4 5const SECRET = 'YOUR_JWT_SECRET'; // 要和簽發時一樣,所以可以放在 ./configs/config.js 中 6 7async function verifyJWT(jwt) { 8 if (!jwt) { 9 return Promise.reject(new Error('No JWT')); 10 } 11 const decoded = jsonwebtoken.verify(jwt, SECRET); 12 return decoded; 13} 14 15 16module.exports = function (options = {}) { 17 const {tokenPath = 'cookies.token'} = options; // tokenPath 是取出 token 的路徑 18 return function (req, res, next) { 19 const jwt = _.get(req, tokenPath); 20 verifyJWT(jwt) 21 .then(decoded => { 22 console.log(decoded); 23 next(); // next middleware 24 }) 25 .catch(next); 26 }; 27}tokenPath來動態產生 middelware。使用時,VerifyJWT()才是 middelware 的簽章。 POST /api/echo掛入 JWT 驗証1router.post('/api/echo', VerifyJWT(), function (req, res, next) { 2 ...略 3}
這樣就完成對POST /api/echo 掛入 JWT 驗証,要有 JWT 才能執行這支API。
總結
今天介紹基本認証的機制,還利用 JWT 來傳遞驗証結果。最後,利用 middleware 可以方便的掛入需要驗証的 APIs.
未來有機會在來談 passport.js、OAuth、Time-based One-Time Password(TOTP)二階段驗証。
評論