動詞實驗室 Logo動詞實驗室

【後端架構系列 03】雙 Token 機制與 Redis:打造可撤銷的完美驗證

發布於 閱讀時間 7 分鐘

01. 前言:魚與熊掌可以兼得

Ep.01 中,我們提到 JWT 的最大痛點是 「無法撤銷」。一旦你把有效期設為 30 天,這 30 天內使用者的帳號就像無人管理的後門,即使改了密碼也關不上。

但如果把有效期設為 15 分鐘?使用者一天要登入 100 次,這體驗簡直是災難。

解法就是「雙 Token 機制 (Dual Token Scheme)」。

這套架構的核心思想是:把「驗證身分 (Access)」和「維持登入 (Refresh)」職責分離。這也是 Google、Facebook 等巨頭目前採用的主流方案。

02. 架構設計:Access vs. Refresh

我們需要兩把鑰匙:

  1. Access Token (短效期,約 15 分鐘)

    • 性質:Stateless (JWT)。
    • 用途:存取 API 資源 (GET /users, POST /orders)。
    • 儲存:前端記憶體 (Memory) 或變數中。
    • 特點:因為效期短,就算被偷,駭客也只能用 15 分鐘。Server 驗證時不查資料庫,效能極高。
  2. Refresh Token (長效期,約 7 天)

    • 性質:Stateful (存於 Redis)。
    • 用途唯一用途是換取新的 Access Token
    • 儲存HttpOnly Cookie (防止 XSS 竊取)。
    • 特點:每次換發時,Server 會去 Redis 檢查這個 Token 是否還有效。如果使用者被封鎖或登出,Redis 紀錄被刪除,換發就會失敗。

03. 環境準備:Redis 登場

我們需要 Redis 來儲存 Refresh Token 的白名單(或是黑名單)。這裡採用 白名單策略:只有在 Redis 裡找得到的 Refresh Token 才是合法的。

npm install ioredis cookie-parser
npm install -D @types/cookie-parser

設定 Redis Client

// src/config/redis.ts
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD,
});

redis.on('error', (err) => console.error('Redis Client Error', err));
redis.on('connect', () => console.log('Redis Client Connected'));

export default redis;

04. 核心實作:登入、換發、登出

這段程式碼展示了如何將 Redis 整合進 JWT 流程中。

A. 工具函式更新

我們需要新的 helper function 來處理 Refresh Token。

// src/utils/jwt.utils.ts (新增)
import redis from '../config/redis';
// ... 原有的 signAccessToken ...

export const signRefreshToken = async (userId: string): Promise<string> => {
  // 1. 簽發 JWT (Payload 可以只放 userId)
  const token = jwt.sign({ userId }, config.jwt.secret, { 
    expiresIn: config.jwt.refreshExpiration 
  });
  
  // 2. 存入 Redis (Key: refreshToken, Value: userId, TTL: 7天)
  // 為了支援多裝置登入,可以將 Key 設計為 `refresh_token:${userId}:${deviceId}`
  // 這裡簡化為直接存 token
  await redis.set(`refresh_token:${token}`, userId, 'EX', 7 * 24 * 60 * 60);
  
  return token;
};

export const verifyRefreshToken = async (token: string): Promise<string | null> => {
  try {
    // 1. 驗證簽章
    const decoded = jwt.verify(token, config.jwt.secret) as TokenPayload;
    
    // 2. 檢查 Redis 白名單 (這是關鍵!)
    const storedUserId = await redis.get(`refresh_token:${token}`);
    
    if (!storedUserId || storedUserId !== decoded.userId) {
      return null; // Token 被撤銷或不匹配
    }
    
    return storedUserId;
  } catch (error) {
    return null;
  }
};

B. Auth Controller (業務邏輯)

這是整個機制的靈魂。特別注意 refreshlogout 的邏輯。

// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../utils/jwt.utils';
import redis from '../config/redis';

export const login = async (req: Request, res: Response) => {
  // ... (省略驗證帳號密碼邏輯) ...
  const userId = "user_123"; 

  // 1. 產出雙 Token
  const accessToken = signAccessToken({ userId, role: 'user', email: '[email protected]' });
  const refreshToken = await signRefreshToken(userId);

  // 2. 將 Refresh Token 寫入 HttpOnly Cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, // JS 讀不到,防 XSS
    secure: process.env.NODE_ENV === 'production', // HTTPS only
    sameSite: 'strict', // 防 CSRF
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  // 3. Access Token 回傳給前端 (存記憶體)
  res.json({ accessToken });
};

export const refresh = async (req: Request, res: Response) => {
  // 1. 從 Cookie 拿 Token
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) return res.status(401).json({ message: '請重新登入' });

  // 2. 驗證 Token + Redis 檢查
  const userId = await verifyRefreshToken(refreshToken);
  
  if (!userId) {
    // ⚠️ 安全警報:這可能是重放攻擊
    // 進階策略:如果是已知的惡意 Token,可以清除該用戶所有 Token
    res.clearCookie('refreshToken');
    return res.status(403).json({ message: 'Token 無效或已過期' });
  }

  // 3. Token Rotation (旋轉機制) - 安全性大升級
  // 舊的 Refresh Token 用過即丟,防止被攔截重複使用
  await redis.del(`refresh_token:${refreshToken}`);
  
  const newAccessToken = signAccessToken({ userId, role: 'user', email: '...' });
  const newRefreshToken = await signRefreshToken(userId);

  // 4. 發新的 Cookie
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken: newAccessToken });
};

export const logout = async (req: Request, res: Response) => {
  const refreshToken = req.cookies.refreshToken;
  
  if (refreshToken) {
    // 1. 從 Redis 移除,讓它即刻失效
    await redis.del(`refresh_token:${refreshToken}`);
  }

  // 2. 清除 Cookie
  res.clearCookie('refreshToken');
  res.status(200).json({ message: '登出成功' });
};

05. 筆者觀點:Token Rotation 的必要性

你可能會問:「為什麼在 refresh 接口,我不只發新的 Access Token,連 Refresh Token 都要換新的(Rotation)?」

👨‍💻 筆者深度解析: 想像一下,如果你的 Refresh Token 有效期是 7 天,且從不更換。 一旦駭客偷到了這個 Token(例如透過惡意瀏覽器插件),他在這 7 天內可以無限次申請 Access Token,完全接管你的帳號。

Token Rotation (旋轉) 是為了縮小攻擊視窗。 每次換發時,舊的 Refresh Token 立刻作廢。如果駭客偷了舊的去用,會發現失效。如果駭客偷了新的,但他用了一次後,合法使用者也要用,這時會有兩個人拿著同一個「家族」的 Token 來換發。

進階防禦: Server 偵測到「重複使用舊 Token」的行為時,視為帳號被盜,立即撤銷該 User ID 下所有的 Refresh Token,強迫所有裝置重登。這才是銀行等級的安全防護。

06. 結論:架構的最後一塊拼圖

至此,我們的後端驗證架構已經非常完整:

  1. Stateless 的高效:API 請求只需驗證簽章,不查庫。
  2. Stateful 的安全:Redis 把守 Refresh 關口,隨時能踢人。
  3. HttpOnly 的防護:Cookie 保護 Refresh Token 不被 JS 竊取。

但這一切都需要前端配合。前端要怎麼處理 Access Token 過期時的 401 錯誤?怎麼在使用者無感的情況下發送 Refresh 請求?

在系列文的最終章 【Ep.04:前端生存指南 - Axios 攔截器與無感換證】,我們將切換視角,從前端工程師的角度,寫出優雅的 API Client 封裝。


Redis 雖然快,但也是成本。如果你的專案只是小型內部系統,用資料庫 (SQL) 替代 Redis 存 Refresh Token 也是完全可以接受的權衡。


Ken Huang

關於作者

Ken Huang

擁有超過 9 年軟體開發經驗的資深工程師,現任 APP 工程師,專精於 Android / iOS 雙平台開發。同時持續拓展後端與雲端技術範疇,致力於朝向全端架構師的領域邁進。

具備完整的專案生命週期實戰經驗:

  • 新創開發:主導多個從零到一的新專案規劃、技術選型與開發落地。
  • 系統維護與重構:擁有維護與升級 10 年以上大型歷史專案(Legacy Code)的豐富經驗,擅長進行代碼重構與效能優化。

身為知名 PTT 第三方 APP BePTT 的開發者,致力於將 Clean Architecture、Scrum 敏捷開發與現代化軟體工程原則應用於產品中,打造極致的使用者體驗。

verb.tw 是我紀錄技術軌跡與架構思考的基地,內容涵蓋 App 開發實戰、全端技術整合與軟體工程最佳實踐。

Senior App EngineerAndroid/iOS ExpertBePTT CreatorSystem Architecture