JSON Web Token (JWT) 是一種廣泛使用的身分驗證機制,但如果實作不當,可能會導致嚴重的安全漏洞。這篇文章深入探討 JWT 的安全性,分析一個不安全的 JWT 實作,並討論如何改進。
JWT 是一種開放標準(RFC 7519),簡潔(compact) 且自包含(self-contained) ,用於在雙方之間安全地傳輸資訊作為 JSON 物件。
這些資訊可以被驗證和信任,他透過數位簽章方式進行驗證
一個典型的 JWT 可能包含使用者 ID、使用者名稱、過期時間等資訊。當伺服器收到這個 JWT 時,它可以直接從 token 中解析出這些資訊,而不需要查詢資料庫。這就體現了 JWT 的「自包含」特性。
這種自包含的特性使得 JWT 特別適合用於分散式系統和微服務架構,因為它減少了系統間的相依性和通訊需求。
JWT 主要由三個部分組成,每個部分之間用點(.)分隔:
typ
:指定 token 的類型,通常為 "JWT"。alg
:指定用於生成簽章的演算法,如 "HS256"(HMAC SHA256)或 "RS256"(RSA SHA256)。{
"alg": "HS256",
"typ": "JWT"
}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
iss
(Issuer):發行者sub
(Subject):主題aud
(Audience):接收者exp
(Expiration Time):過期時間nbf
(Not Before):生效時間iat
(Issued At):發行時間jti
(JWT ID):JWT 的唯一識別碼{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
完整的 JWT 就是這三個部分用點(.)連接起來:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
可使用 jwt.io 來查看與驗證
這種結構使得 JWT 成為一種自包含的 token,接收方可以獨立驗證 token 的真實性,而不需要額外的資料庫查詢。
https://github.com/fei3363/ithelp_web_security_2024/commit/9ccddf754b65d4108888aed7346eff23bd03d375
安裝必要的套件:
在 package.json
中新增 jsonwebtoken
套件:
"dependencies": {
// ... 其他相依套件
"jsonwebtoken": "^8.5.1"
}
然後執行 npm install
安裝新增的套件。
建立 JWT
新建檔案 web/controllers/authJWTController.js
:
const jwt = require('jsonwebtoken');
const db = require('../db/postgres');
const Secretkey = 'mysecret'; // 不安全的密鑰
const authJWTHandler = {
// ... 在此處實作各個方法
};
module.exports = authJWTHandler;
實作登入功能:
在 authJWTHandler
物件中新增 login
方法:
async login(req, res) {
const { username, password } = req.body;
try {
const user = await db.query('SELECT * FROM users WHERE username = $1', [username]);
if (user.rows.length === 0) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
const userObj = user.rows[0];
// 驗證密碼,不安全的寫法
const isPasswordValid = password === userObj.password;
if (!isPasswordValid) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
// 生成 token,不安全寫法
const token = jwt.sign({ id: userObj.id, username: userObj.username, password: userObj.password }, Secretkey);
res.json({ message: '登入成功', token });
} catch (error) {
res.status(500).json({ error: error.message });
}
}
實作 token 刷新功能:
在 authJWTHandler
物件中新增 refresh
方法:
async refresh(req, res) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ message: '沒有提供 token' });
}
try {
const decoded = jwt.verify(token, Secretkey);
const newToken = jwt.sign({ id: decoded.id, username: decoded.username }, Secretkey);
res.json({ message: 'Token 更新成功', token: newToken });
} catch (error) {
res.status(401).json({ message: 'Token 驗證失敗' });
}
}
實作驗證中間 middleware:
在 authJWTHandler
物件中新增 insecureAuth
方法:
insecureAuth(req, res, next) {
const token = req.header('Authorization');
if (!token) return res.status(401).json({ error: 'Access denied' });
try {
const verified = jwt.verify(token, Secretkey);
req.user = verified;
next();
} catch (err) {
res.status(400).json({ error: 'Invalid token' });
}
}
實作受保護的路由處理函數:
在 authJWTHandler
物件中新增 protectedRoute
方法:
protectedRoute(req, res) {
res.json({ message: 'This is a protected route', user: req.user });
}
建立路由:
新建檔案 web/routes/authJWTRouter.js
:
const express = require('express');
const router = express.Router();
const authJWTHandler = require('../controllers/authJWTController');
router.post('/login', authJWTHandler.login);
router.post('/refresh', authJWTHandler.refresh);
router.get('/protected', authJWTHandler.insecureAuth, authJWTHandler.protectedRoute);
module.exports = router;
在主應用程式中使用路由:
在 web/server.js
中新增:
const authJWTRouter = require('./routes/authJWTRouter');
// ... 其他程式碼
app.use('/api/authJWT', authJWTRouter);
這個實作過程建立了一個基本的 JWT 認證系統,但故意包含了多個安全漏洞。這樣的設計目的是為了展示常見的錯誤做法,並在後續的學習中逐步改進這些問題。在實際的正式環境中,應該從一開始就實施安全的做法,而不是先實作不安全的版本再進行改進。
讓我們來看一個不安全的 JWT 實作範例:
const jwt = require('jsonwebtoken');
const db = require('../db/postgres');
const Secretkey = 'mysecret'; // 不安全的密鑰
const authJWTHandler = {
async login(req, res) {
const { username, password } = req.body;
try {
const user = await db.query('SELECT * FROM users WHERE username = $1', [username]);
if (user.rows.length === 0) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
const userObj = user.rows[0];
// 驗證密碼,不安全的寫法
const isPasswordValid = password === userObj.password;
if (!isPasswordValid) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
// 不安全寫法,將密碼也一起放入 token 中
const token = jwt.sign({ id: userObj.id, username: userObj.username, password: userObj.password }, Secretkey);
res.json({ message: '登入成功', token });
} catch (error) {
res.status(500).json({ error: error.message });
}
},
// ... 其他方法 ...
};
登入功能 (login):
Token 刷新功能 (refresh):
驗證中間件 (insecureAuth):
受保護的路由 (protectedRoute):
讓我們使用 curl 指令來測試這個不安全的 JWT 實作:
curl -s -X POST http://nodelab.feifei.tw/api/authJWT/login -H "Content-Type: application/json" -d '{"username":"testuser","password":"password123"}'
結果:
{"message":"登入成功","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoicGFzc3dvcmQxMjMiLCJpYXQiOjE3MjgxNDI0NTF9.8ahDgVhrLzitJmFt8JtuuPutHkDmU7m0eFhqBhliKfw"}
這個 lab 展示了如何取得 JWT。注意 token 是如何在登入成功後立即被回傳的。
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoicGFzc3dvcmQxMjMiLCJpYXQiOjE3MjgxNDI0NTF9.8ahDgVhrLzitJmFt8JtuuPutHkDmU7m0eFhqBhliKfw" | cut -d"." -f2 | base64 -d
結果:
{"id":1,"username":"testuser","password":"password123","iat":1728142451}
這個 lab 展示了 JWT 的內容是如何容易被解碼的。我們可以看到,密碼被明文包含在 token 中,這是一個嚴重的安全問題。
curl http://nodelab.feifei.tw/api/authJWT/protected -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoicGFzc3dvcmQxMjMiLCJpYXQiOjE3MjgxNDI0NTF9.8ahDgVhrLzitJmFt8JtuuPutHkDmU7m0eFhqBhliKfw"
結果:
{"message":"This is a protected route","user":{"id":1,"username":"testuser","password":"password123","iat":1728142451}}
這個 lab 展示了如何使用 JWT 存取受保護的路由。注意伺服器如何回傳完整的使用者資訊,包括密碼。
curl -X POST http://nodelab.feifei.tw/api/authJWT/refresh -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInBhc3N3b3JkIjoicGFzc3dvcmQxMjMiLCJpYXQiOjE3MjgxNDI0NTF9.8ahDgVhrLzitJmFt8JtuuPutHkDmU7m0eFhqBhliKfw"
結果:
{"message":"Token 更新成功","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsImlhdCI6MTcyODE0MjY0OH0.-OEQyI0swykczw64vePQKj1RmsRkuquaEIs06iBceuU"}
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const db = require('../db/postgres');
const SECRET_KEY = process.env.JWT_SECRET; // 從環境變數取得密鑰
const TOKEN_EXPIRY = '1h'; // token 有效期為 1 小時
const authJWTHandler = {
async login(req, res) {
const { username, password } = req.body;
try {
const user = await db.query('SELECT * FROM users WHERE username = $1', [username]);
if (user.rows.length === 0) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
const userObj = user.rows[0];
// 使用 bcrypt 驗證密碼
const isPasswordValid = await bcrypt.compare(password, userObj.password);
if (!isPasswordValid) {
return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
}
// 生成不包含敏感資訊的 token
const token = jwt.sign(
{ id: userObj.id, username: userObj.username },
SECRET_KEY,
{ expiresIn: TOKEN_EXPIRY }
);
res.json({ message: '登入成功', token });
} catch (error) {
res.status(500).json({ error: error.message });
}
},
// ... 其他方法的改進 ...
};
JWT 是一個強大的身分驗證工具,但必須謹慎實作以確保安全性。通過遵循最佳實作方法,如使用強密鑰、安全儲存密碼、避免在 token 中包含敏感資訊,以及實作適當的過期和撤銷機制,可以顯著提高 JWT 實作的安全性。
JWT 中的三個部分是什麼?
A) Header,Payload,Signature
B) Username,Password,Token
C) Encrypt,Decrypt,Verify
D) Login,Logout,Refresh
答案:A
解析:JWT(JSON Web Token)由三個部分組成:
為什麼在 JWT 中包含密碼是不安全的?
A) 會使 token 變得太長
B) 可能導致密碼被洩漏
C) 會降低系統效能
D) 會使 token 無法解碼
答案:B
解析:在 JWT 中包含密碼是極不安全的,因為:
使用環境變數儲存 JWT 密鑰的主要目的是什麼?
A) 提高系統效能
B) 方便更改密鑰
C) 增加安全性,避免密鑰被硬編碼
D) 減少程式碼量
答案:C
解析:使用環境變數儲存 JWT 密鑰主要是為了增加安全性:
bcrypt 用於什麼目的?
A) 加密 JWT
B) 生成隨機密鑰
C) 安全地儲存和驗證密碼
D) 加速資料庫查詢
答案:C
解析:bcrypt 主要用於安全地儲存和驗證密碼:
為什麼給 JWT 新增過期時間很重要?
A) 增加 token 的長度
B) 提高系統效能
C) 限制被盜用 token 的有效期
D) 使 token 更容易解碼
答案:C
解析:給 JWT 新增過期時間很重要,主要是為了安全考量: