筆記 - JWT 認證機制與實作流程

之前在 AC 課程最後一週才學到前後分離與 JWT 認證機制,當時進度很趕。趁做另一個專案的時候來複習,並記錄開發流程。

Intro

HTTP stateless 的特性下,每個 request 都是獨立的。故若想保留使用者登入狀態,常用「交換憑證」的方式來達成。

  • cookie:用戶端的憑證,有如會員卡,卡上有會員編號 (也就是 session_id)
  • session:伺服器端的憑證對照表,有如會員名冊,可以透過會員編號 (session_id) 查找到使用者資訊 (user_id)
    當 client 端發送請求時,會在 cookie 中帶有 session_id 一起傳送給伺服器,伺服器到 session 中尋找,若成功找到該 id 即可確認使用者身份。

但是cookie 的值只能在特定網域內被存取,在前後分離的開發中,當前後端站部署在不同網域時,就會無法使用 cookie-based 的方式去做身份驗證。

token-based 登入機制

與 cookie-based 同樣是「交換憑證讓 client 與 server 認出彼此」的機制,但改用 token 來做為憑證。client 端在 POST /singin 的時候,會用帳號與密碼向 server 拿到一個 token ,之後發送的每一個 HTTP request 都夾帶這個 token。


JWT: JSON Web Token

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

用 JSON object 來製作 token,規範了 token 由三個部分所組成:

  • Header - 標記 token 的類型與雜湊函式名稱
  • Payload - 要攜帶的資料,例如 user_id 與時間戳記,也可以指定 token 的過期時間
  • Signature - 根據 Header 和 Payload,加上密鑰 (secret) 進行雜湊,產生一組不可反解的亂數,當成簽章,用來驗證 JWT 是否經過篡改。

實作

登入機制簡單分為兩個階段

(1) 登入 => 簽發 JWT

client: POST/singin with account and password

server: validate account and password => find the user => sign a JWT => send back to client

(2) 身份認證 => 使用網站服務

client: send request bearer JWT

server: verify JWT and find user thought password.authenticate() => send req.user => accept the request


登入與簽發 JWT

在 router 檔案中新增登入路由,注意登入路由不需要認證

1
router.post('/users/signin', userController.signIn)

在 controller 中引入 jwt 套件,並簽發 JWT:

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

  • payload: 想要打包的資訊 (object)
  • secretOrPrivateKey: 專案是使用 secret,JWT 會拿 secret 加上 header 和 payload 進行雜湊產生一組不可反解的亂數,避免 payload 和 header 資訊被篡改。

參考官方文件

(Asynchronous) If a callback is supplied, the callback is called with the err or the JWT.

(Synchronous) Returns the JsonWebToken as string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// controller/user-controller.js
const jwt = require('jsonwebtoken')
const userController = {
signIn: async (req, res, next) => {
try {
// 登入
// check user exists and password is correct (略)

// 簽發 JWT(此處將 user.id 作為 payload 資訊,token 效期 7 天)
const payload = { id: user.id }
const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '7d'})
delete user.password // 回傳前端前把密碼刪掉
res.json({
status: 'success',
data: { token, user }
})
} catch (err) {
next(err)
}
}
}
module.exports = userController

身份認證

client 發出請求時同時攜帶 token

server 需負責解析 token 是否合法且有效。

實作包含兩階段:(1) 設定 passport-jwt strategy (2) 實作 middleware 並視需求加入路由中。

設定 Passport-jwt strategy (config/passport.js)

npm i passport passport-jwt

touch config/passport.js

在這邊使用 passport.use() 設定所要使用的 authentication strategy,寫法為 new JwtStrategy(options, verify)參考 passport-jwt 文件

options: 會根據 strategy 不同有各種不同客制選項,以下為 jwt 的

  • secretOrKey: 密鑰 (REQUIRED unless secretOrKeyProvider is provided)
  • jwtFromRequest (REQUIRED): Request 如何攜帶 JWT 的方法,參考 設定選項
  • passReqToCallback: If true the request will be passed to the verify callback. i.e. verify(request, jwt_payload, done_callback). 設定 true 可以把 callback 的第一個參數拿到 req 裡(在 local strategy 的時候需要 req.flash 的時候會有用,要設定 true)
  • 其他例如像加密演算法、或是要指定 issuer, audience (token 簽發者與對象?)等等

verify(jwt_payload, done): 驗證函式

  • jwt_payload: an object literal containing the decoded JWT payload.
  • done: a passport error first callback accepting arguments done(error, user, info)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// config/passport.js
const passport = require('passport')
const JWTStrategy = require('passport-jwt').Strategy
const ExtractJWT = require('passport-jwt').ExtractJwt
const { User } = require('../models')

// strategy option 客制部分
const jwtOptions = {
secretOrKey: process.env.JWT_SECRET,
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true
}

// setup passport strategy
passport.use( new JwtStrategy(jwtOptions, async(payload, next) => {
try {
// 簽發 token 的 payload.id 是 user.id,以此來找 user,找到以後回傳
const user = await User.findByPk(payload.id, {
attributes: { excluded: ['password'] }
})
if (!user) return next(null, false)
return next(null, user)
} catch (err) {
next(err)
}
}))

將認證程序封裝成 middleware 加入路由中

touch middleware/auth.js

參考passport.authenticate()方法

1
2
3
4
5
6
7
8
9
10
11
12
// middleware/auth.js
const passport = require('../config/passport') // 這邊要引入的 passport 是我們設定好的那個

const authenticated = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => { // 要把 passport session 關掉
if (err || !user) return res.status(401).json({ status: 'error', message: 'Unauthorized.' })
if (user) req.user = user
next()
})(req, res, next)
}

module.exports = authenticated

參考 express 官方文件 我們知道 app.METHOD(path, callback, [, callback ...]) 其中 callback 可以是 middleware (用逗號連、或是放在 array 裡面、或是前述組合等等)。

別忘了在主程式 app.js 中引入 passport 套件,並且初始化

1
2
3
4
5
6
// 主程式 app.js
const passport = require('passport') // 這邊引入 passport or config/passport 感覺都可以
...
app.use(passport.initialize()) // 要放在路由前面
app.use(routes)
...

然後就可以將認證 middleware 放在路由中了

1
2
3
4
5
// routes/index.js
const authenticated = require('../middleware/auth')
router.get('/', authenticated, (req, res) => {
res.send('hello world')
})


Summary

  1. 登入 controller 中引入 jsonwebtoken 套件,使用 jwt.sign() 簽發 token (參數: payload 是什麼內容、secret 、token 效期等)
  2. config/passport.js 檔案中設定 authentication strategy,不同的 strategy 有不同的客制選項(jwt options: secret, token 夾帶方式等)。passport.use(new JwtStrategy(jwtOptions, function(payload, next){...}),本次專案用 user.id 作為 payload.id,故 User.findByPk() 傳入 payload.id ,找到 user 的話就回傳
  3. 引入設定好的 passport 檔案(config/passport.js) 使用 passport.authenticate() 來進行認證程序,並將其封裝成 middleware,放在路由中。也別忘了在主程式中引入 passport 並在進入路由前初始化 app.use(passport.initialize())