昨天整理了密碼認證相關的 bcrypt,今天繼續整理註冊登入相關的 JWT 機制。
JSON Web Token (JWT) 是一個開放標準 (RFC 7519),定義了一種緊湊且自包含的方式,用於在各方之間以 JSON 物件的形式安全地傳輸資訊。
簡單來說,JWT 就是一種格式化的字串,用來在不同系統之間傳遞經過驗證的資訊。它最大的特色是自包含(self-contained)和無狀態(stateless)。
自包含的意思是:JWT 本身就包含了所有必要的資訊,不需要去查詢資料庫或其他外部資源。
舉個例子:
// 傳統做法:Session ID,只是一個隨機字串,像鑰匙一樣
sessionId: "a1b2c3d4e5f6"
// 伺服器收到這個 Session ID,要去查詢:
// 1. 這個 session 是誰的?
// 2. 這個使用者有什麼權限?
// 3. 這個 session 什麼時候過期?
// JWT 做法:所有資訊都在裡面
{
"userId": 123,
"email": "user@example.com",
"role": "admin",
"exp": 1516242622,
"iat": 1516239022
}
// 伺服器收到 JWT,直接就知道:
// - 使用者是誰(userId: 123)
// - 使用者的權限(role: admin)
// - 什麼時候過期(exp)
// 不需要查詢任何資料庫!
無狀態的意思是:伺服器不需要記住任何關於這個認證的資訊。
1. 使用者登入
伺服器:建立 session,存到記憶體/Redis/資料庫
Session Store: { "abc123": { userId: 123, role: "admin" } }
回傳給使用者:session ID = "abc123"
2. 使用者發送請求(帶著 session ID)
伺服器:查詢 Session Store
"abc123 對應到哪個使用者?有什麼權限?"
3. 使用者登出
伺服器:從 Session Store 刪除這筆資料
1. 使用者登入
伺服器:建立 JWT,簽名
回傳給使用者:JWT
2. 使用者發送請求(帶著 JWT)
伺服器:驗證簽名,讀取內容
不需要查詢任何資料!直接從 JWT 知道使用者是誰
3. 使用者登出
伺服器:什麼都不用做
(JWT 自己會過期)
一個完整的 JWT 由三個部分組成,用點(.
)分隔:
Header.Payload.Signature
例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
Header 通常由兩個部分組成:
typ
:令牌類型,固定為 JWT
alg
:簽名演算法,例如 HMAC SHA256(HS256)或 RSA(RS256)範例:
{
"alg": "HS256",
"typ": "JWT"
}
這個 JSON 會被 Base64Url 編碼,形成 JWT 的第一部分。
Payload 包含了 claims(聲明),也就是關於實體(通常是使用者)的陳述和其他資料。
Claims 分為三種類型:
這些是預先定義的聲明,雖然不是強制的,但建議使用以提高互通性:
iss
(issuer):發行者exp
(expiration time):過期時間(Unix timestamp)sub
(subject):主題,通常是使用者 IDaud
(audience):接收者iat
(issued at):發行時間nbf
(not before):在此時間之前不可用jti
(JWT ID):JWT 的唯一識別碼注意:這些聲明名稱都只有三個字元,因為 JWT 被設計成要盡可能緊湊。
使用 JWT 的人可以自由定義。但為了避免衝突,應該在 IANA JSON Web Token Registry 中註冊,或定義為包含防碰撞命名空間的 URI。
雙方同意使用的自訂聲明,用於在特定上下文中共享資訊。
範例:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516242622
}
這個 JSON 也會被 Base64Url 編碼,形成 JWT 的第二部分。
簽名用於驗證訊息在傳輸過程中沒有被更改,並且在使用私鑰簽名的情況下,可以驗證 JWT 的發送者身分。
建立簽名的方式:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
如果使用對稱演算法(如 HS256),會用一個密鑰(secret)來簽名和驗證。
如果使用非對稱演算法(如 RS256),會用私鑰簽名,用公鑰驗證。
簽名也會被 Base64Url 編碼,形成 JWT 的第三部分。
編碼是將 Header 和 Payload 轉換成緊湊的、URL 安全的格式。
步驟:
.
)連接這兩個編碼後的部分Header.Payload.Signature
格式的字串解碼是編碼的反向操作,將 Base64Url 編碼的 Header 和 Payload 轉換回 JSON。
重點:任何人都可以解碼 JWT 並讀取其內容,不需要密鑰。
這就是為什麼你絕對不能在 JWT 中存放敏感資訊(如密碼、信用卡號)。
驗證是確認 JWT 的簽名是否有效,以及內容是否被竄改。
步驟:
exp
(過期時間)等聲明只有擁有正確密鑰的人才能驗證簽名。
很多人誤以為 JWT 是加密的,但實際上:
sequenceDiagram
participant Client as 客戶端
participant Server as 伺服器/API
Note over Client,Server: 階段 1: 登入與取得 JWT
Client->>Server: POST /login<br/>(帳號 + 密碼)
Server->>Server: 驗證帳號密碼
alt 驗證成功
Server->>Server: 建立 JWT<br/>(簽名 + 設定過期時間)
Server-->>Client: 回傳 JWT
Note over Client: 儲存 JWT<br/>(localStorage/memory)
else 驗證失敗
Server-->>Client: 401 Unauthorized
end
Note over Client,Server: 階段 2: 使用 JWT 存取資源
Client->>Server: GET /api/protected<br/>Authorization: Bearer {JWT}
Server->>Server: 驗證 JWT 簽名
Server->>Server: 檢查過期時間 (exp)
alt JWT 有效
Server->>Server: 解碼 Payload<br/>(取得使用者資訊)
Server-->>Client: 200 OK<br/>(回傳受保護的資源)
else JWT 無效/過期
Server-->>Client: 403 Forbidden
end
GET /api/protected-resource HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
注意 Bearer
這個前綴,這是標準的做法,可以參考mdn的文章。
前面有提到一些 Session 和 JWT 原理,這邊就在整理一次兩者差別。
傳統 Session 的問題:
JWT 的優勢:
JWT 可以輕鬆地在不同的網域和服務之間使用:
前端 (example.com)
↓ JWT
API (api.example.com)
↓ JWT
微服務 A (service-a.internal)
↓ JWT
微服務 B (service-b.internal)
所有服務都能用同一個 JWT,只要它們共享驗證的密鑰或公鑰。
行動應用不適合使用 cookies,JWT 可以輕鬆地透過 HTTP headers 傳遞。
使用者只需登入一次,就可以用同一個 JWT 存取多個應用程式。
最常見的用途。RESTful API 通常是無狀態的,JWT 非常適合。
在微服務之間傳遞認證資訊,無需每個服務都去查詢使用者資料庫。
兩個系統之間安全地交換資訊。接收方可以驗證發送方的身分,並確保內容未被竄改。
再次強調:JWT 的內容可以被任何人讀取!
// 錯誤示範
{
"userId": 123,
"password": "secret123",
"creditCard": "1234-5678-9012"
}
// 正確做法
{
"userId": 123,
"email": "user@example.com",
"role": "admin"
}
{
"userId": 123,
"iat": 1516239022,
"exp": 1516242622 // 1 小時後過期
}
建議:
Access Token 過期後,用 Refresh Token 換取新的 Access Token,不需要重新登入。
1. 使用者登入 → 取得 Access Token + Refresh Token
2. 使用 Access Token 存取 API
3. Access Token 過期
4. 用 Refresh Token 換取新的 Access Token
5. 繼續使用新的 Access Token
永遠使用 HTTPS 傳輸 JWT,防止中間人攻擊竊取 token。
JWT 的無狀態特性也是一個缺點:一旦發出,在過期前無法撤銷。
解決方案:
// 在 payload 中加入 token version
{
"userId": 123,
"tokenVersion": 5
}
// 使用者登出時,在資料庫中將 tokenVersion 增加
// 舊的 token 雖然簽名有效,但 version 不符,拒絕存取
// 簽名和驗證都用同一個 secret
const secret = 'your-256-bit-secret';
// 簽名用私鑰
const privateKey = '...';
// 驗證用公鑰(可以公開分享)
const publicKey = '...';
什麼時候用非對稱?
安裝套件:
npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const payload = {
userId: 123,
email: 'user@example.com',
role: 'admin'
};
const secret = process.env.JWT_SECRET;
// 簽名並設定過期時間
const token = jwt.sign(payload, secret, {
expiresIn: '1h',
issuer: 'my-app',
audience: 'my-api'
});
console.log(token);
const jwt = require('jsonwebtoken');
try {
const decoded = jwt.verify(token, secret, {
issuer: 'my-app',
audience: 'my-api'
});
console.log(decoded);
// { userId: 123, email: 'user@example.com', role: 'admin', ... }
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('Token 已過期');
} else if (err.name === 'JsonWebTokenError') {
console.log('無效的 Token');
}
}
// 這只會解碼,不會驗證簽名
const decoded = jwt.decode(token);
// 任何人都可以這樣做,因為 JWT 只是編碼而已
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // 將解碼後的資料附加到 request
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// 使用中介軟體保護路由
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: 'This is protected data',
user: req.user
});
});