動詞實驗室 Logo動詞實驗室

【後端架構系列 04】前端生存指南:Axios 攔截器與無感換證 (Silent Refresh)

發布於 閱讀時間 10 分鐘

01. 前言:最後一哩路

在前面三篇,我們已經在後端搭建了一座堅固的堡壘:

  1. JWT 負責快速通關。
  2. Redis 負責即時撤銷。
  3. HttpOnly Cookie 保護 Refresh Token。

但這座堡壘如果不搭配正確的前端鑰匙,使用者體驗將會是一場災難。想像一下,使用者正在填寫一張長表單,Access Token 剛好在第 14 分鐘過期,他按送出,結果跳出 401 Unauthorized,畫面被強制轉回登入頁,表單資料全沒了。

這絕對是被開除的節奏。

今天我們要實作 「無感換證 (Silent Refresh)」:當 Access Token 過期,前端要在使用者毫無察覺的情況下,自動去換發新 Token,並 「重試 (Retry)」 剛剛失敗的請求。

02. 核心策略:記憶體與攔截器

回顧一下我們的儲存策略:

  • Refresh Token: 瀏覽器自動管理的 HttpOnly Cookie (前端 JS 碰不到)。
  • Access Token: 存放在 記憶體 (Variable / React Context) 中。

這意味著,當使用者按下 F5 重新整理網頁,記憶體裡的 Access Token 會消失。所以我們的 App 啟動流程必須是:

  1. App Init: 頁面載入。
  2. Check Auth: 嘗試呼叫 /auth/refresh API。
  3. Success: 後端驗證 Cookie,回傳新的 Access Token -> 存入記憶體 -> 顯示登入後畫面。
  4. Failure: 導向登入頁。

03. 實戰:Axios 攔截器 (Interceptor)

這是本篇的精華。我們需要兩個攔截器:

  1. Request Interceptor: 每個請求自動帶上 Access Token。
  2. Response Interceptor: 攔截 401 錯誤,換證並重試。

安裝

npm install axios

定義 API Client (src/api/axios.ts)

import axios, { AxiosRequestConfig, AxiosError } from 'axios';

// 1. 建立實例
const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3000/api',
  withCredentials: true, // 關鍵!讓瀏覽器自動帶上 HttpOnly Cookie
});

// 定義一個變數存 Access Token (取代 LocalStorage)
let accessToken = '';

export const setAccessToken = (token: string) => {
  accessToken = token;
};

// 2. Request 攔截器:注入 Token
api.interceptors.request.use(
  (config) => {
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// ... 下面是重頭戲 ...

04. 解決「併發請求」的地獄 (The Race Condition)

這是 90% 前端工程師會踩的坑。

情境: 頁面初始化時,同時發出了 5 個 API 請求 (Get User, Get News, Get Config...),此時 Access Token 剛好過期。

錯誤的實作: 5 個請求都收到 401 -> 觸發 5 次 /refresh API -> 災難發生!

因為我們在後端做了 Token Rotation (旋轉機制),第一個 Refresh 請求成功後,舊的 Refresh Token 就失效了。第二個 Refresh 請求帶著舊 Token 到達 Server,Server 判斷為 「盜用 (Replay Attack)」,直接封鎖該用戶。

正確的實作 (Mutex Lock): 當第一個 401 發生時,開啟鎖 (isRefreshing = true),後續的 401 全部暫停,加入一個 佇列 (Queue) 等待。等第一個換證成功後,再依序重試。

完整 Response 攔截器實作

// 是否正在換證中
let isRefreshing = false;
// 失敗請求的佇列
let failedQueue: any[] = [];

const processQueue = (error: any, token: string | null = null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token);
    }
  });
  failedQueue = [];
};

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };

    // 如果是 401 且不是「換證API」本身失敗 (避免無窮迴圈)
    if (error.response?.status === 401 && !originalRequest._retry) {
      
      if (isRefreshing) {
        // 如果正在換證,將請求加入佇列
        return new Promise(function (resolve, reject) {
          failedQueue.push({
            resolve: (token: string) => {
              originalRequest.headers!.Authorization = `Bearer ${token}`;
              resolve(api(originalRequest));
            },
            reject: (err: any) => {
              reject(err);
            },
          });
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // 呼叫後端 Refresh Endpoint (瀏覽器會自動帶 Cookie)
        const { data } = await api.post('/auth/refresh');
        
        const newToken = data.accessToken;
        setAccessToken(newToken);
        
        // 處理佇列中的請求
        processQueue(null, newToken);
        
        // 重試原本失敗的請求
        originalRequest.headers!.Authorization = `Bearer ${newToken}`;
        return api(originalRequest);
        
      } catch (refreshError) {
        // 換證失敗 (Refresh Token 也過期了,或是被後端封鎖)
        processQueue(refreshError, null);
        // 導向登入頁
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default api;

👨‍💻 筆者點評: 這段 Code 是許多企業級專案的標準配置。 請特別注意 failedQueue 的設計。它利用了 Promise 將那些「暫時失敗」的請求 resolve 函式存起來。一旦拿到新 Token,我們就執行這些 resolve,讓那些卡住的請求帶著新 Token 再次出發。

05. React 整合 (App 初始化)

在 React 的根元件,我們需要做初始化的檢查。

// src/context/AuthContext.tsx
import React, { useEffect, useState } from 'react';
import api, { setAccessToken } from '../api/axios';

export const AuthProvider: React.FC = ({ children }) => {
  const [isLoading, setIsLoading] = useState(true);
  const [user, setUser] = useState(null);

  useEffect(() => {
    const initAuth = async () => {
      try {
        // App 啟動時,嘗試用 Cookie 換 Access Token
        const { data } = await api.post('/auth/refresh');
        setAccessToken(data.accessToken);
        // 接著可以拿 Token 去抓使用者資料
        // const userRes = await api.get('/users/me');
        // setUser(userRes.data);
      } catch (error) {
        // 沒登入或是 Token 過期,不做事,讓使用者留在 Public 頁面或登入頁
        console.log("No valid session");
      } finally {
        setIsLoading(false);
      }
    };

    initAuth();
  }, []);

  if (isLoading) return <div>Loading...</div>;

  return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
};

06. 系列總結:安全性是全端工程

恭喜你!跟隨這四篇文章的腳步,你已經從零打造了一套 金融級別 的身分驗證系統。

我們回顧一下這趟旅程:

  1. [Ep.01]:理解了為什麼 JWT 適合微服務,但 Session 適合單體,以及為什麼我們選擇「混合架構」。
  2. [Ep.02]:學會了不依賴 process.env 的型別安全配置,並避開了 JWT 的加密誤區。
  3. [Ep.03]:後端實作了 雙 Token + Redis 黑名單 + Token Rotation,解決了 JWT 無法撤銷的痛點。
  4. [Ep.04]:前端實作了 Axios 攔截器與請求佇列,完美處理了併發換證的難題。

這套架構雖然複雜,但它兼顧了 安全性 (Security)效能 (Performance)使用者體驗 (UX)

希望這份筆記能成為你開發職涯中的一塊基石。未來的架構之路還很長,我們下一個系列見!


同場加映:JWT 的軍火庫與除錯神器

在結束這系列之前,筆者想針對 JWT 的 「加密算法選擇」「除錯工具」 做最後的補充。很多開發者只知道 HS256,但在微服務或高安規場景下,選錯算法可能會導致架構上的麻煩。

1. JWT 簽章算法演化論

JWT 的 Header 中 alg 欄位決定了生死。常見的有以下幾種:

算法類型適用場景✅ 優點❌ 缺點
HS256對稱式 (Symmetric)單體架構、內部系統速度快,運算成本極低,適合高吞吐量。鑰匙管理難:Server 需共用同一把 Secret,一旦洩漏可隨意偽造 Token。
RS256非對稱式 (Asymmetric)微服務、第三方登入 (Auth0)安全性高:資源伺服器 (Resource Server) 只需公鑰即可驗證,無需持有私鑰。速度較慢,RSA 運算複雜度較高,Token 長度較長。
ES256非對稱式 (Elliptic Curve)行動裝置、IoT、高流量微服務極快且安全:使用橢圓曲線,Key 長度比 RSA 短,運算效能更好。相容性:極舊版系統或舊版 Library 可能不支援。

👨‍💻 筆者建議: 如果你的專案只有一個後端,用 HS256 絕對夠用。但如果你在做微服務,或者需要讓第三方驗證你的 Token,請務必使用 RS256ES256

2. JWS vs. JWE:別再以為 Base64 是加密

我們常用的 JWT 其實標準名稱是 JWS (JSON Web Signature)

  • JWS:內容是公開的 (Base64Url),只保證「不被竄改」。
  • JWE (JSON Web Encryption):內容是加密的,沒有密鑰完全看不到 Payload。

絕大多數 Web 應用使用 JWS 就夠了(前提是 Payload 不放敏感資料)。只有在極少數需要透過 Token 傳遞「身分證字號」或「私密資料」的場景,才需要用到 JWE。

3. 瀏覽器支援程度 (Browser Compatibility)

JWT 本質上就是一個 「很長的字串」。 因此,所有瀏覽器(包含 IE6)都支援 JWT

瀏覽器的差異僅在於「儲存方式」與「加密 API」:

  • HttpOnly Cookie:所有瀏覽器皆支援 (最推薦)。
  • LocalStorage:IE8+ 支援。
  • Web Crypto API:如果你需要在前端解密 JWE 或驗證簽章(極少見),需要現代瀏覽器 (Chrome, Firefox, Edge, Safari)。

4. 推薦工具:動詞實驗室 JWT Debugger

開發過程中,我們常需要檢查:「這個 Token 到底過期了沒?」或是「裡面的 role 到底有沒有寫進去?」。

這裡推薦一個筆者開發的線上工具:動詞實驗室 JWT Utils

不同於其他複雜的工具,這個工具保持了極簡與直覺:

  1. 即時解析:貼上 Token,瞬間解碼 Header 與 Payload。
  2. 隱私安全:純前端解析,不會將你的 Token 傳送到後端伺服器(這一點對測試 Production Token 至關重要)。

將這個連結加入你的書籤,它會是你 Debug 401 錯誤時的好幫手。


驗證系統只是後端架構的冰山一角。如果你對這系列有興趣,或許我們下次可以聊聊「分散式鎖 (Distributed Lock)」或是「高併發下的庫存扣減」?歡迎留言敲碗。


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