iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

前言

昨天整理了密碼認證相關的 bcrypt,今天繼續整理註冊登入相關的 JWT 機制。

什麼是 JWT?

JSON Web Token (JWT) 是一個開放標準 (RFC 7519),定義了一種緊湊且自包含的方式,用於在各方之間以 JSON 物件的形式安全地傳輸資訊。

簡單來說,JWT 就是一種格式化的字串,用來在不同系統之間傳遞經過驗證的資訊。它最大的特色是自包含(self-contained)和無狀態(stateless)。

自包含 self-contained

自包含的意思是: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)
// 不需要查詢任何資料庫!

無狀態 Stateless

無狀態的意思是:伺服器不需要記住任何關於這個認證的資訊。

傳統 Session 的流程(有狀態)

1. 使用者登入
   伺服器:建立 session,存到記憶體/Redis/資料庫
   Session Store: { "abc123": { userId: 123, role: "admin" } }
   回傳給使用者:session ID = "abc123"

2. 使用者發送請求(帶著 session ID)
   伺服器:查詢 Session Store
   "abc123 對應到哪個使用者?有什麼權限?"
   
3. 使用者登出
   伺服器:從 Session Store 刪除這筆資料

JWT 的流程(無狀態)

1. 使用者登入
   伺服器:建立 JWT,簽名
   回傳給使用者:JWT

2. 使用者發送請求(帶著 JWT)
   伺服器:驗證簽名,讀取內容
   不需要查詢任何資料!直接從 JWT 知道使用者是誰

3. 使用者登出
   伺服器:什麼都不用做
   (JWT 自己會過期)

JWT 的結構

一個完整的 JWT 由三個部分組成,用點(.)分隔:

Header.Payload.Signature

例如:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30

1. Header(標頭)

Header 通常由兩個部分組成:

  • typ:令牌類型,固定為 JWT
  • alg:簽名演算法,例如 HMAC SHA256(HS256)或 RSA(RS256)

範例:

{
  "alg": "HS256",
  "typ": "JWT"
}

這個 JSON 會被 Base64Url 編碼,形成 JWT 的第一部分。

2. Payload(負載)

Payload 包含了 claims(聲明),也就是關於實體(通常是使用者)的陳述和其他資料。

Claims 分為三種類型:

Registered Claims(註冊聲明)

這些是預先定義的聲明,雖然不是強制的,但建議使用以提高互通性:

  • iss (issuer):發行者
  • exp (expiration time):過期時間(Unix timestamp)
  • sub (subject):主題,通常是使用者 ID
  • aud (audience):接收者
  • iat (issued at):發行時間
  • nbf (not before):在此時間之前不可用
  • jti (JWT ID):JWT 的唯一識別碼

注意:這些聲明名稱都只有三個字元,因為 JWT 被設計成要盡可能緊湊。

Public Claims(公開聲明)

使用 JWT 的人可以自由定義。但為了避免衝突,應該在 IANA JSON Web Token Registry 中註冊,或定義為包含防碰撞命名空間的 URI。

Private Claims(私有聲明)

雙方同意使用的自訂聲明,用於在特定上下文中共享資訊。

範例:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022,
  "exp": 1516242622
}

這個 JSON 也會被 Base64Url 編碼,形成 JWT 的第二部分。

3. Signature(簽名)

簽名用於驗證訊息在傳輸過程中沒有被更改,並且在使用私鑰簽名的情況下,可以驗證 JWT 的發送者身分。

建立簽名的方式:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

如果使用對稱演算法(如 HS256),會用一個密鑰(secret)來簽名和驗證。

如果使用非對稱演算法(如 RS256),會用私鑰簽名,用公鑰驗證。

簽名也會被 Base64Url 編碼,形成 JWT 的第三部分。

編碼、解碼與驗證的差異

Encoding(編碼)

編碼是將 Header 和 Payload 轉換成緊湊的、URL 安全的格式。

步驟:

  1. 將 Header 和 Payload 轉換為 JSON
  2. 用 Base64Url 編碼這兩個部分
  3. 用點(.)連接這兩個編碼後的部分
  4. 使用指定的演算法和密鑰生成簽名
  5. 將簽名也進行 Base64Url 編碼
  6. 最終得到 Header.Payload.Signature 格式的字串

Decoding(解碼)

解碼是編碼的反向操作,將 Base64Url 編碼的 Header 和 Payload 轉換回 JSON。

重點:任何人都可以解碼 JWT 並讀取其內容,不需要密鑰。

這就是為什麼你絕對不能在 JWT 中存放敏感資訊(如密碼、信用卡號)。

Verification(驗證)

驗證是確認 JWT 的簽名是否有效,以及內容是否被竄改。

步驟:

  1. 解碼 Header 和 Payload
  2. 使用 Header 中指定的演算法和密鑰重新計算簽名
  3. 比對重新計算的簽名與 JWT 中的簽名是否一致
  4. 檢查 exp(過期時間)等聲明

只有擁有正確密鑰的人才能驗證簽名。

重要觀念:JWT 不是加密

很多人誤以為 JWT 是加密的,但實際上:

  • Header 和 Payload 只是編碼(encoded),不是加密(encrypted)
  • 任何人都可以用 Base64Url 解碼來讀取內容
  • 簽名只能確保內容沒有被竄改,但無法隱藏內容

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

實際的 HTTP 請求範例

GET /api/protected-resource HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

注意 Bearer 這個前綴,這是標準的做法,可以參考mdn的文章。

為什麼要用 JWT?

前面有提到一些 Session 和 JWT 原理,這邊就在整理一次兩者差別。

傳統 Session 的問題:

  • 需要在伺服器端儲存 session 資料
  • 在分散式系統中需要共享 session(Redis、資料庫)
  • 增加伺服器的記憶體負擔

JWT 的優勢:

  • 不需要伺服器端儲存
  • 容易橫向擴展(任何伺服器都能驗證 JWT)
  • 減少資料庫查詢

2. 跨域和跨服務

JWT 可以輕鬆地在不同的網域和服務之間使用:

前端 (example.com) 
    ↓ JWT
API (api.example.com)
    ↓ JWT
微服務 A (service-a.internal)
    ↓ JWT  
微服務 B (service-b.internal)

所有服務都能用同一個 JWT,只要它們共享驗證的密鑰或公鑰。

3. 效能

  • JSON 比 XML 更輕量
  • 編碼後的 JWT 體積小
  • 不需要頻繁查詢資料庫驗證 session

4. 行動端友善

行動應用不適合使用 cookies,JWT 可以輕鬆地透過 HTTP headers 傳遞。

5. 單點登入(SSO)

使用者只需登入一次,就可以用同一個 JWT 存取多個應用程式。

JWT 的應用場景

1. API 認證

最常見的用途。RESTful API 通常是無狀態的,JWT 非常適合。

2. 微服務架構

在微服務之間傳遞認證資訊,無需每個服務都去查詢使用者資料庫。

3. 資訊交換

兩個系統之間安全地交換資訊。接收方可以驗證發送方的身分,並確保內容未被竄改。

安全性考量

不要存放敏感資訊

再次強調: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:短時間(15 分鐘到 1 小時)
  • Refresh Token:長時間(數天到數週)

使用 Refresh Token

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

永遠使用 HTTPS 傳輸 JWT,防止中間人攻擊竊取 token。

實作令牌撤銷

JWT 的無狀態特性也是一個缺點:一旦發出,在過期前無法撤銷。

解決方案:

  • 黑名單:維護一個已撤銷的 token 清單
  • 白名單:只有在清單中的 token 才有效
  • 版本控制:在 payload 中加入版本號,使用者登出時增加版本號
// 在 payload 中加入 token version
{
  "userId": 123,
  "tokenVersion": 5
}

// 使用者登出時,在資料庫中將 tokenVersion 增加
// 舊的 token 雖然簽名有效,但 version 不符,拒絕存取

對稱 vs 非對稱演算法

對稱演算法(HMAC)

  • 使用同一個密鑰來簽名和驗證
  • 常見:HS256、HS384、HS512
  • 速度快,但所有需要驗證的服務都要知道密鑰
// 簽名和驗證都用同一個 secret
const secret = 'your-256-bit-secret';

非對稱演算法(RSA、ECDSA)

  • 用私鑰簽名,用公鑰驗證
  • 常見:RS256、RS384、RS512、ES256
  • 私鑰只有發行者知道,公鑰可以分享給所有需要驗證的服務
// 簽名用私鑰
const privateKey = '...';

// 驗證用公鑰(可以公開分享)
const publicKey = '...';

什麼時候用非對稱?

  • 多個服務需要驗證 JWT,但不應該能發行新的 JWT
  • 第三方需要驗證你的 JWT

在 Node.js 中使用 JWT

安裝套件:

npm install jsonwebtoken

建立 JWT

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);

驗證 JWT

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 只是編碼而已

Express 中介軟體範例

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 
  });
});

參考

jwt.io


上一篇
Day18 - bcrypt - 密碼加密
下一篇
Day20 - Helmet - 安全標頭設定
系列文
欸欸!! 這是我的學習筆記20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言