跳至主要内容

[note] JWT

觀念

驗證概念

imgur

Token 類型

一般來說通常包含兩類的 token - access tokenrefresh token

當使用者第一次向伺服器要求登入時,伺服器通常會同時回應 access token 和 refresh token。

# 例如向伺服器發送登入請求
curl -X POST -H -d 'username=webgoat&password=webgoat' localhost:8080/WebGoat/login
// 伺服器同時回應 access_token 和 refresh_token
{
"token_type": "bearer",
"access_token": "XXXX.YYYY.ZZZZ",
"expires_in": 10,
"refresh_token": "4a9a0b1eac1a34201b3c5659944e8b7"
}

接著所有使用 API 向伺服器進行操作時,都需要附上 access token 才可以存取資料。但一般來說 access token 會有使用期限,當過了效期之後,透過 refresh token 可以向伺服器核發新的 access token。refresh token 也可能會過期,但一般來說效期比 access token 來得長。

透過 refresh token 省去了使用者需要重新再向伺服器進行驗證(或授權)的動作,但因為有了 refresh token 等同於可以取得新的 access token ,因此當 client 在使用 refresh token 時,伺服器這邊應該要更留意安全性的考量,也許要記錄使用者的 IP、地點、裝置資訊、使用 refresh token 的次數,若有異常的情況,則要請使用者重新進行一次驗證。例如,每當使用不同裝置登入 google 或 facebook 時,都需要再重複進行一次驗證。

JWT Token 的組成

JWT 包含三個部分,並以(.)區隔

  • Header
  • Payload(claims)
  • Signature

imgur

{
"alg": "HS256", // hashing algorithm
"typ": "JWT", // type of token
"cty": // content type
}

Payload(Claims)

在 JWT token 的 payload (claims) 中通常會包含可以辨認使用者的資訊。但千萬不要把敏感的資料放在 token 中,並且只在安全的管道傳遞 token

Payload 中通常包含:

  • Reserved claims(建議提供): iss (issuer), exp (expiration time), sub (subject), aud(audience) -- 其他,nbf (not before)
  • Public claims
  • Private claims
{
"iss":"Wavinfo", // issuer, 發證者
"sub":"12345678", // subject:主體
"aud":"", // Audience:對象
"exp":~~((new Date((new Date()).getTime() + 1000*60*60*24)).getTime() / 1000), // Expiration Time:有效期限
"iat":~~(new Date()), // Issued At:簽發時間
"nbf": ~~((new Date()).getTime() / 1000), // Not Before:生效時間
"jti":"" //JWT ID
}

JWT RFC

Signature

每一個 JWT token 都應該在送出給 client 前進行簽章(sign),如果一個 token 沒有簽章,那麼 client 即可自由修改 token 中的內容。關於簽章的規範可以參考這裡,簽章所使用的演算法則可以參考這裡。通常會使用「HMAC 搭配 SHA-2 的函式」或「搭配 RSASSA-PKCS1-v1_5/ECDSA/RSASSA-PSS 的數位簽章」。

透過 encoded header, encoded payload, secret, header 中定義的 algorithm 來產生。

HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);

在 JS 中要進行 base64UrlEncode 可以使用 atobbtoa 的方法:

var encodedData = window.btoa('Hello, world'); // encode a string
var decodedData = window.atob(encodedData); // decode the string

// base64UrlEncode(header)
const header = '{"alg":"none","typ":"JWT"}';
window.btoa(header);

atob @ MDN

產生 JWT

可以使用 jsonwebtoken 這個套件。

使用方式

// STEP1: 匯入 jsonwebtoken
const jwt = require('jsonwebtoken')
const jwtSignOptions = {
algorithm: 'HS256',
};
// STEP2: 先產生 Payload 內容
const payload = {
...
}

// STEP3: 使用 jwt.sign 取得 Token
const mySecret = 'pjchender'
const jwtToken = jwt.sign(payload, mySecret, jwtSignOptions)

實際程式

// ./config/jwt.js
module.exports = {
secret: process.env.JWT_SECRET_ACCESS_KEY,
options: {
session: false,
},
};
/**
* [NodeJS][套件]
* ./routes/index.js
**/

const jwt = require('jsonwebtoken');
const jwtConfig = require('../../config/jwt');

function giveMeToken(user) {
let payload = {
id: user.facebookId,
email: user.email,
exp: ~~(new Date(new Date().getTime() + 1000 * 60 * 60 * 24).getTime() / 1000),
nbf: ~~(new Date().getTime() / 1000),
};
return jwt.sign(payload, jwtConfig.secret);
}

驗證 JWT Token

Node.js 實作

使用 passport-jwt 來實做:

passport-jwt strategy middleware 設定:

//  ./middlewares/passport.js

const passport = require('passport');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const Model = require('../models');
const jwtConfig = require('../config/jwt');

/**
* passport.use('驗證策略名稱', '想建立的策略類型')
* passReqToCallback: 讓我們在後面的 callback 中可以使用 req 參數
**/

// Passport Initialization
passport.serializeUser(function (user, done) {
done(null, user._id);
});
passport.deserializeUser(function (id, done) {
Model.User.findById(id, function (err, user) {
done(err, user);
});
});

let jwtStrategy = new JwtStrategy(
{
secretOrKey: jwtConfig.secret,
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.versionOneCompatibility({ authScheme: 'Bearer' }),
ExtractJwt.fromAuthHeader(),
]),
},
function (payload, done) {
console.log('payload received', payload);
Model.User.findById(payload.email, function (err, user) {
if (err) return done(err);
if (!user) return done(null, false, { message: 'User not found' });
// if (payload.sub !== user.facebookId) return done(null, false, {message: 'Wrong JWT Token'})

const exp = payload.exp;
const nbf = payload.nbf;
const curr = ~~(new Date().getTime() / 1000);
if (curr > exp || curr < nbf) {
return done(null, false, 'Token Expired');
}
return done(null, user);
});
},
);

passport.use('jwt', jwtStrategy);

module.exports = passport;

在 routes 中使用:

// ./routes/index.js
const express = require('express');
const router = express.Router();
const bodyParser = require('body-parser');
const jsonParser = bodyParser.json();
const passport = require('passport');
const Model = require('../../models');

// GET /v1.0/palette/:id
router.get(
'/:id',
jsonParser,
passport.authenticate('jwt', { session: false }),
(req, res, next) => {
res.json({
params: req.params.id,
message: 'Success! You can not see this without a token',
});
},
);

module.exports = router;

如果使用了 passport.authenticate('jwt'),但是在 request Header 中沒有給 JWTToken ,會直接回傳 Unauthorized

安全性注意

避免 JWT Headers 的 alg 被串改

Critical vulnerabilities in JSON Web Token libraries

JWT 該保存在哪

過去經常會把 JWT 保存在 local storage,但因為 local storage 很容易透過 JavaScript 即可取得,更安全的方式應該是保存在 Cookie。

透過 Server 來設定 Cookies,並且將它設為 httpOnlyhttpOnly 的 cookie 無法被 JavaScript 取得,只能被傳送給 server(參考「Restrict access to cookies @ MDN):

Set-Cookie: id=a3fWa; Expires=Thu, 21 Oct 2021 07:28:00 GMT; Secure; HttpOnly

在 JS 中,如果需要從 server 設定 cookie 可以類似這樣:

// "cookie" is a third-party library
import cookie from 'cookie';

res
.status(200)
.setHeader(
'Set-Cookie',
cookie.serialize('jwt', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/api',
}),
)
.json({
id: user.id,
name: user.username,
});

真的應該使用 JWT 嗎

有許多的資料都質疑在 client 和 server 間溝通時使用 JWT 的安全性,JWT 最好的使用情境是在 server 對 server 的情況,在一般的網頁應用程式中,最好還是用一般傳統使用的 cookies:

參考資料