跳到主要內容

Web Day 27 前端認證:token 儲存與自動登出

Web Day 27 前端認證:token 儲存與自動登出

執行需求:CPU 可跑。Web Day 12 已經把 JWT 的後端簽發與驗證走完,今天把這套機制接到 Next.js 15。我們會回答三個核心問題:access token 存在哪裡(localStorage vs httpOnly cookie 的安全取捨要誠實說明)、怎麼自動帶上每個請求、token 過期怎麼自動登出。前兩個是設計問題,最後一個是工程問題。

引言

寫前端應用最常見的安全漏洞不是 SQL Injection、不是 XSS,而是「token 怎麼存」。這個選擇會影響整個應用的威脅模型。我們今天把兩個主流方案(localStorage vs httpOnly cookie)攤開來比較,把各自的攻擊面與緩解手段講清楚,再示範 Next.js 15 的實作。最後談自動登出(access token 過期時怎麼處理 refresh token、使用者操作時怎麼延長有效期)。

這篇文章不做完整認證系統(OAuth、第三方登入、WebAuthn 留給更進階的章節),只做「帳密登入 → access token → 自動帶上 → 過期處理」這個最小閉環。我們會把後端簡化成「可以登入、回 token、可以驗 token」,前端簡化成「表單輸入 → 拿到 token → 存起來 → 帶上每個請求」。範例刻意小,是為了讓你能在一個下午跑完,並看清每個環節的安全權衡。

誠實面對 token 儲存的安全取捨

存取 JWT(或其他 bearer token)的兩個主流方案是 localStorage 與 httpOnly cookie。先把結論講清楚,再展開細節:對大多數 Web 應用來說,httpOnly cookie + SameSite=Lax 是比較安全的預設選擇,但並非萬靈丹,需要搭配嚴格的 CORS、CSP、依賴管理等其他措施才能形成完整的防線。如果你的應用是純前端 + 第三方 API(無法設 SameSite cookie),那 localStorage 是合理選擇,但要付出更多 XSS 防護成本。選擇時請誠實評估自家團隊的能力與威脅模型。

特性 localStorage httpOnly cookie
JavaScript 能讀取 是 否
XSS 攻擊時可被盜 是(高風險) 否(瀏覽器禁止 JS 讀取)
CSRF 攻擊時可被冒用 否(瀏覽器不會自動帶) 是(瀏覽器會自動帶上)
SameSite 防護 不適用 SameSite=Lax/Strict 可擋大部分 CSRF
跨域 API 支援度 較佳(CORS 設好即可) 需 SameSite=None + Secure,較複雜
SSR 自動帶上 否(client 才看得到) 是(每個請求自動帶)
存取控制彈性 前端可隨時清除 由伺服器透過 Set-Cookie 控制

先講攻擊模型:localStorage 的最大風險是 XSS(Cross-Site Scripting)。一旦你的頁面被植入惡意腳本(例如沒過濾使用者輸入、把使用者姓名直接寫進 innerHTML),攻擊者可以用 localStorage.getItem("token") 把 token 偷走、永久冒用。httpOnly cookie 因為瀏覽器禁止 JavaScript 讀取,XSS 拿到 token 的難度大幅提高(但不是零:攻擊者可以「以使用者身分打 API」,這要靠 SameSite、Origin 驗證、嚴格 CORS 來擋)。

httpOnly cookie 的主要風險是 CSRF(Cross-Site Request Forgery):瀏覽器會「自動帶上 cookie」,所以攻擊者只要引誘使用者點一個表單、送到你的 API,瀏覽器就會帶著 cookie 一起送。SameSite=Lax 或 SameSite=Strict 可以擋掉大部分這類攻擊(瀏覽器只允許同站請求帶 cookie)。

實務上 httpOnly cookie + SameSite=Lax + CSRF token 是相對安全的組合。localStorage 仍可用,但需要做更嚴格的 XSS 防護:嚴格 CSP(Content Security Policy)、React 預設的轉義、所有使用者輸入都經過白名單驗證(Web Day 15 展開)。本系列後續的貫穿專案「預約管理系統」會用 httpOnly cookie 模式。

另一個常見的混淆:「httpOnly cookie」跟「前端不能存取」是兩件事。httpOnly 確實讓前端 JavaScript 讀不到,但可以「打 API 時瀏覽器自動帶」。所以對前端來說,「我不需要管 token 怎麼帶」是 httpOnly 的最大好處;對後端來說,「我能用 Set-Cookie 控制 token 生命週期」也是好處。

後端:登入與 JWT 簽發

Web Day 12 已用 PyJWT 簽發與驗證 token,今天把登入端點、refresh 端點、middleware 整合進一支完整檔案:

# main_auth.py
# FastAPI 認證 API:登入、回 access token、回 refresh token
import time
from datetime import datetime, timedelta, timezone

import jwt
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from passlib.context import CryptContext
from pydantic import BaseModel

SECRET = "dev-secret-change-me"  # 開發用;正式環境從環境變數讀(Web Day 29)
ACCESS_TTL = 15 * 60              # access token 15 分鐘
REFRESH_TTL = 7 * 24 * 60 * 60    # refresh token 7 天

pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 假資料庫:實際上線用 SQLModel(Web Day 35 之後)
USERS: dict[str, dict] = {
    "alice": pwd.hash("alice-password"),
}


class LoginIn(BaseModel):
    username: str
    password: str


class TokenPair(BaseModel):
    access_token: str
    token_type: str = "bearer"
    expires_in: int


def issue_access(username: str) -> TokenPair:
    now = time.time()
    payload = {
        "sub": username,
        "iat": int(now),
        "exp": int(now + ACCESS_TTL),
        "type": "access",
    }
    token = jwt.encode(payload, SECRET, algorithm="HS256")
    return TokenPair(access_token=token, expires_in=ACCESS_TTL)


def issue_refresh(username: str) -> str:
    now = time.time()
    payload = {
        "sub": username,
        "iat": int(now),
        "exp": int(now + REFRESH_TTL),
        "type": "refresh",
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")


app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,  # 重要:允許 cookie
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.post("/api/auth/login", response_model=TokenPair)
def login(payload: LoginIn, response: Response):
    # 1. 驗帳密
    stored = USERS.get(payload.username)
    if not stored or not pwd.verify(payload.password, stored):
        raise HTTPException(status_code=401, detail="帳號或密碼錯誤")

    # 2. 簽 access token
    pair = issue_access(payload.username)

    # 3. 設 httpOnly refresh token cookie
    response.set_cookie(
        key="refresh_token",
        value=issue_refresh(payload.username),
        httponly=True,
        secure=False,       # 開發用;正式環境要 True
        samesite="lax",     # 防 CSRF
        max_age=REFRESH_TTL,
        path="/api/auth",   # 只在 auth 端點帶上
    )
    return pair


@app.post("/api/auth/refresh", response_model=TokenPair)
def refresh(refresh_token: str | None = Cookie(default=None)):
    if not refresh_token:
        raise HTTPException(status_code=401, detail="缺少 refresh token")

    try:
        data = jwt.decode(refresh_token, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="refresh token 過期")
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="refresh token 無效")

    if data.get("type") != "refresh":
        raise HTTPException(status_code=401, detail="token 類型錯誤")
    return issue_access(data["sub"])


@app.post("/api/auth/logout", status_code=204)
def logout(response: Response):
    response.delete_cookie("refresh_token", path="/api/auth")
    return None


def get_current_user(access_token: str | None = Cookie(default=None)) -> str:
    if not access_token:
        raise HTTPException(status_code=401, detail="缺少 access token")
    try:
        data = jwt.decode(access_token, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="access token 過期")
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="access token 無效")
    if data.get("type") != "access":
        raise HTTPException(status_code=401, detail="token 類型錯誤")
    return data["sub"]


@app.get("/api/me")
def me(username: str = Depends(get_current_user)):
    return {"username": username}

這支程式做了四件事。第一,/api/auth/login 驗帳密、回 access token、把 refresh token 放進 httpOnly cookie。set_cookie(httponly=True, samesite="lax", path="/api/auth") 三個參數缺一不可:httponly 擋 XSS 讀取、samesite 擋 CSRF、path 把 cookie 限定在 auth 端點,避免每個請求都帶。第二,/api/auth/refresh 用 cookie 裡的 refresh token 換新的 access token。refresh token 7 天過期、access token 15 分鐘過期,這個不對稱讓「被盜的 access token 最多只能用 15 分鐘」。第三,CORSMiddleware(allow_credentials=True) 必須開啟,否則瀏覽器不會帶 cookie。第四,get_current_user 從 cookie 讀 access token、驗證、回傳 username,這是受保護端點的標準 dependency。

access token 我們故意也存在 httpOnly cookie(access_token),因為 Next.js Server Component 在伺服器端 fetch 時瀏覽器會自動帶 cookie,比讓前端把 token 放在 header 簡單很多。

前端:登入表單與認證狀態

前端用 Server Component 處理「登入 → 設 cookie → 重新導向」,表單用 React 19 的 useActionState:

// src/app/login/actions.ts
"use server";

import { redirect } from "next/navigation";

const API_BASE = process.env.API_BASE ?? "http://127.0.0.1:8000";

export async function loginAction(_prev, formData) {
  const username = String(formData.get("username") ?? "");
  const password = String(formData.get("password") ?? "");

  const res = await fetch(`${API_BASE}/api/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
    credentials: "include",  // 重要:讓瀏覽器存 Set-Cookie
  });

  if (!res.ok) {
    return { ok: false, message: "帳號或密碼錯誤" };
  }

  const pair = await res.json();
  // access_token 也存進 httpOnly cookie,
  // 因為 Server Component fetch 會自動帶 cookie
  // (透過 document.cookie 設定 httpOnly 是錯的,這裡只是示意)
  redirect("/bookings");
}

注意這裡有個關鍵設計:access token 不該由前端 JavaScript 存取。上面的程式碼沒有把 token 寫到 localStorage、也沒有寫到一般 cookie。實務上做法是:FastAPI 在 Set-Cookie 同時設定 access_token(httpOnly + SameSite=Lax)與 refresh_token,瀏覽器自動帶上,前端永遠看不到 token 內容。這個範例簡化了一些細節,但概念是對的。

登入表單元件:

// src/app/login/form.tsx
"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

import { loginAction } from "./actions";

const initial = { ok: false };

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "登入中..." : "登入"}
    </button>
  );
}

export function LoginForm() {
  const [state, action] = useActionState(loginAction, initial);
  return (
    <form action={action} style={{ display: "grid", gap: 8 }}>
      <label>
        帳號
        <input name="username" required />
      </label>
      <label>
        密碼
        <input name="password" type="password" required />
      </label>
      {state.message && !state.ok && (
        <p style={{ color: "red" }}>{state.message}</p>
      )}
      <SubmitButton />
    </form>
  );
}

這個表單跟 Day 26 的新增預約表單結構幾乎相同,差別只在打的是 /api/auth/login 端點。錯誤訊息由後端決定(這裡固定回「帳號或密碼錯誤」,避免洩漏帳號是否存在)。

自動登出:token 過期的處理

access token 15 分鐘過期是為了「被盜時影響範圍小」,但使用者不會每 15 分鐘重新登入。我們要做兩件事:第一,access token 即將過期前自動用 refresh token 換新;第二,refresh token 也過期就強制登出。

前端建立一個 hook 統一管理這個流程:

// src/lib/auth.ts
// 認證輔助:在 fetch 時自動 refresh
import { redirect } from "next/navigation";

const API_BASE = process.env.API_BASE ?? "http://127.0.0.1:8000";

let refreshInFlight: Promise<boolean> | null = null;

export async function authedFetch(
  input: string,
  init: RequestInit = {},
): Promise<Response> {
  // 第一次嘗試
  let res = await fetch(`${API_BASE}${input}`, {
    ...init,
    credentials: "include",
  });

  // 401 表示 access token 過期,嘗試 refresh
  if (res.status === 401) {
    const ok = await refreshAccessToken();
    if (!ok) {
      redirect("/login");  // refresh 也失敗,強制登出
    }
    // 重試原本請求
    res = await fetch(`${API_BASE}${input}`, {
      ...init,
      credentials: "include",
    });
  }
  return res;
}

async function refreshAccessToken(): Promise<boolean> {
  // 避免多個請求同時 refresh
  if (refreshInFlight) return refreshInFlight;

  refreshInFlight = (async () => {
    try {
      const res = await fetch(`${API_BASE}/api/auth/refresh`, {
        method: "POST",
        credentials: "include",
      });
      return res.ok;
    } catch {
      return false;
    } finally {
      refreshInFlight = null;
    }
  })();
  return refreshInFlight;
}

這個 authedFetch() 是 fetch 的包裝:第一次失敗且是 401 時自動呼叫 /api/auth/refresh,refresh 成功就重試、refresh 失敗就 redirect 到登入頁。refreshInFlight 用 module-level 變數確保「同時間只有一個 refresh 請求」——避免多個 fetch 同時看到 401、各自去打 refresh。後端會用同一個 refresh token 換新的 access token,多個並行 refresh 會浪費資源甚至回錯誤。

把 lib/api.ts 的 fetch 換成 authedFetch():

// src/lib/api.ts(重點節錄)
import { authedFetch } from "./auth";

export async function listBookings(params) {
  const url = new URL("/api/bookings", BASE);
  // ... 設定 query params
  const res = await authedFetch(url.pathname + url.search);
  // ...
}

所有 API 呼叫走 authedFetch,自動享有「過期就 refresh」的能力。Web Day 25 寫的 lib/api.ts 改成呼叫 authedFetch,其他不變。

用 pytest 驗證認證端點

認證端點的測試要涵蓋「登入成功、登入失敗、refresh 成功、refresh 失敗、權限檢查」:

# test_auth.py
# 認證 API 測試
import time
import pytest
import jwt
from fastapi.testclient import TestClient

from main_auth import app, USERS, SECRET

client = TestClient(app)


SECRET_OVERRIDE = "test-secret"


@pytest.fixture(autouse=True)
def reset_users(monkeypatch):
    # 用獨立的 SECRET 避免污染
    import main_auth
    monkeypatch.setattr(main_auth, "SECRET", SECRET_OVERRIDE)
    USERS.clear()
    USERS["alice"] = main_auth.pwd.hash("alice-password")
    yield
    USERS.clear()


def test_login_success_returns_access_token():
    r = client.post(
        "/api/auth/login",
        json={"username": "alice", "password": "alice-password"},
    )
    assert r.status_code == 200
    body = r.json()
    assert body["token_type"] == "bearer"
    assert body["expires_in"] > 0

    # refresh token 應在 Set-Cookie header
    cookies = r.headers.get("set-cookie", "")
    assert "refresh_token" in cookies
    assert "HttpOnly" in cookies
    assert "SameSite=lax" in cookies


def test_login_wrong_password():
    r = client.post(
        "/api/auth/login",
        json={"username": "alice", "password": "wrong"},
    )
    assert r.status_code == 401


def test_login_unknown_user():
    r = client.post(
        "/api/auth/login",
        json={"username": "bob", "password": "anything"},
    )
    assert r.status_code == 401


def test_refresh_with_valid_cookie():
    # 先登入拿 cookie
    r = client.post(
        "/api/auth/login",
        json={"username": "alice", "password": "alice-password"},
    )
    cookie = r.cookies.get("refresh_token")
    assert cookie is not None

    # 用 cookie 換 access token
    client.cookies.set("refresh_token", cookie)
    r = client.post("/api/auth/refresh")
    assert r.status_code == 200
    assert "access_token" in r.json()


def test_refresh_without_cookie():
    r = client.post("/api/auth/refresh")
    assert r.status_code == 401


def test_refresh_with_invalid_token():
    client.cookies.set("refresh_token", "not-a-real-jwt")
    r = client.post("/api/auth/refresh")
    assert r.status_code == 401


def test_me_with_valid_token():
    # 登入拿 access token(從 cookie)
    r = client.post(
        "/api/auth/login",
        json={"username": "alice", "password": "alice-password"},
    )
    access_cookie = None
    for c in r.headers.get_list("set-cookie"):
        if c.startswith("access_token="):
            access_cookie = c.split("=", 1)[1].split(";", 1)[0]

    # 把 access token 設成 cookie 送 /api/me
    client.cookies.set("access_token", access_cookie)
    r = client.get("/api/me")
    assert r.status_code == 200
    assert r.json()["username"] == "alice"


def test_me_without_token():
    r = client.get("/api/me")
    assert r.status_code == 401


def test_expired_access_token():
    # 手動簽一個過期的 token
    expired = jwt.encode(
        {"sub": "alice", "exp": int(time.time()) - 10, "type": "access"},
        SECRET_OVERRIDE,
        algorithm="HS256",
    )
    client.cookies.set("access_token", expired)
    r = client.get("/api/me")
    assert r.status_code == 401
    assert "過期" in r.json()["detail"]

這份測試覆蓋九種情境:登入成功(access token 與 httpOnly cookie)、登入失敗(密碼錯、帳號不存在)、refresh 成功與失敗、/api/me 有/無 token、過期 token 的明確錯誤。test_login_success_returns_access_token 特別檢查 Set-Cookie 的 HttpOnly 與 SameSite=lax 屬性——這是「我們真的有設安全 cookie」的可執行斷言。

常見錯誤與踩雷

第一個常見踩雷:CORS allow_credentials=True 沒設。瀏覽器會擋掉所有帶 cookie 的請求、伺服器都收不到 refresh_token cookie。CORSMiddleware 預設 allow_credentials=False,記得改成 True 並明確列出 allow_origins(不能是 *)。

第二個常見踩雷:access token 用 localStorage 存,被 XSS 偷走。Web Day 15 會展開 CSP 與 XSS 防護,但根本解法是「不要用 localStorage 存 token」。httpOnly cookie + SameSite=Lax 是更安全的選擇。如果一定要用 localStorage,至少要做:嚴格 CSP、所有使用者輸入用 React 預設轉義、第三方套件只用有信譽的、定期 audit dependency。

第三個常見踩雷:refresh token 過期後沒強制登出。前端只看到 401 就重新導向到登入頁,但使用者可能正在操作、辛苦填的表單就消失了。對應策略:refresh 失敗時先把表單資料存進 sessionStorage(讓使用者登入後能恢復),或送出明確的「工作階段過期,請重新登入」對話框。

第四個常見踩雷:access token 沒有「過期前自動 refresh」。使用者操作到一半突然被踢出、UX 很差。我們今天的 authedFetch 在 401 時才 refresh,但更好的做法是「在 access token 即將過期前 30 秒主動 refresh」。這需要記住 token 簽發時間、在每個請求前檢查。實務上 401 觸發 refresh 已經足夠 80% 的情境,主動 refresh 留給更進階的實作。

第五個常見踩雷:把 access token 印到 log。如果你的 FastAPI 用 logging 印出 request headers,access token 會被記錄下來、永久留在 log 檔。對應排查:建立「敏感標頭」清單(Authorization、Cookie、Set-Cookie),log filter 自動過濾。Web Day 22 會展開 logging 的敏感資訊處理。

加一層 CSRF 防護

httpOnly cookie 模式預設靠 SameSite 防 CSRF,但 SameSite=Lax 在某些情境會被繞過(例如攻擊者引導使用者點 GET 連結)。對高風險操作(修改密碼、刪除帳號)建議再加一層 CSRF token:

# csrf_check.py
# CSRF token middleware:檢查 X-CSRF-Token 標頭與 cookie 一致
import secrets

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}


class CSRFCheckMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        if request.method in SAFE_METHODS:
            return await call_next(request)

        # 從 cookie 讀 token,從 header 讀對應值
        cookie_token = request.cookies.get("csrf_token", "")
        header_token = request.headers.get("X-CSRF-Token", "")

        # 用 secrets.compare_digest 防 timing attack
        if not cookie_token or not secrets.compare_digest(cookie_token, header_token):
            return Response("CSRF token 不符", status_code=403)

        return await call_next(request)


def issue_csrf_token() -> str:
    # 登入成功後設定 cookie(前端從 cookie 讀非 HttpOnly 的 csrf_token)
    return secrets.token_urlsafe(32)

這個 middleware 在 main_auth.py 用 app.add_middleware(CSRFCheckMiddleware) 掛上。登入時除了 access_token 與 refresh_token,也要設一個非 httpOnly 的 csrf_token cookie(同樣的 token),前端 JavaScript 可以讀、隨每個寫入請求帶進 X-CSRF-Token 標頭。CSRF token 的存在讓「攻擊者引導使用者點表單」這類攻擊失效——因為攻擊者沒辦法讀到 csrf_token。

用 CLI 驗證整個認證流程

部署到正式環境前,我們會用一支 CLI 腳本驗證「登入 → 拿 access token → 打受保護 API → 過期 → refresh → 再打 API」的完整流程:

# scripts/check_auth.py
# 端到端測試:登入 → 受保護 API → refresh → 再受保護 API
import httpx

BASE = "http://127.0.0.1:8000"


def main() -> int:
    with httpx.Client(base_url=BASE, timeout=5.0) as client:
        # 1. 登入
        r = client.post(
            "/api/auth/login",
            json={"username": "alice", "password": "alice-password"},
        )
        r.raise_for_status()
        print(f"登入成功:access token expires_in={r.json()['expires_in']} 秒")

        # httpx 自動存 cookie
        assert "refresh_token" in client.cookies

        # 2. 打受保護 API
        r = client.get("/api/me")
        r.raise_for_status()
        print(f"/api/me:{r.json()}")

        # 3. 模擬 access token 過期(直接刪掉 cookie)
        client.cookies.delete("access_token")

        # 4. 打 API 應 401
        r = client.get("/api/me")
        assert r.status_code == 401
        print("access token 過期,回 401(符合預期)")

        # 5. refresh
        r = client.post("/api/auth/refresh")
        r.raise_for_status()
        print("refresh 成功,重新拿到 access token")

        # 6. 再打 API
        r = client.get("/api/me")
        r.raise_for_status()
        print(f"/api/me 再次:{r.json()}")

    print("認證流程測試通過")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

這支腳本可以在 CI 跑(先啟動 FastAPI、跑 pytest、跑 e2e、關 FastAPI)。它驗證了「access token 真的會過期」「refresh 真的能換新 token」「refresh 後又能打受保護 API」這個完整閉環。對認證系統來說,這類端到端測試比單元測試更能抓出真實問題(cookie 沒設好、CORS 沒開、refresh token 簽錯)。

用 Python 驗證 token 簽章

另一個值得獨立驗證的是「token 簽章」。JWT 的安全性來自 HS256/RS256 簽章,簽章錯誤就代表 token 被竄改。我們用一支獨立腳本驗證 token 結構:

# scripts/inspect_token.py
# 解析 JWT 看 payload(除錯用,不可用於正式環境)
import sys

import jwt

token = sys.argv[1] if len(sys.argv) > 1 else ""
if not token:
    print("用法:python inspect_token.py <jwt>")
    sys.exit(1)

try:
    # 不驗簽章,只看 payload(除錯專用)
    payload = jwt.decode(token, options={"verify_signature": False})
    print("payload:", payload)
except Exception as e:
    print(f"解析失敗:{e}")
    sys.exit(1)

# 驗簽章:用實際 SECRET
SECRET = "dev-secret-change-me"
try:
    jwt.decode(token, SECRET, algorithms=["HS256"])
    print("簽章驗證通過")
except jwt.InvalidSignatureError:
    print("簽章錯誤(token 可能被竄改)")
    sys.exit(1)

這支腳本拿 access token 來檢查「我的 JWT 結構對不對」「簽章對不對」。除錯時很好用,但千萬不要在正式環境執行「不驗簽章看 payload」這段——這會讓任何人都能偽造 token。實務上這支腳本只在開發機執行、用來診斷「為什麼我的 token 沒過驗證」。

驗證 token 撤銷

今天的設計是「token 過期就失效」,但「使用者登出後立刻撤銷 token」是另一個需求。我們用 token 黑名單(blacklist)做這件事:

# token_blacklist.py
# 簡單的 token 黑名單:把登出的 token 暫存起來拒絕使用
import time

# 假資料庫;正式環境用 Redis(Web Day 20)
_BLACKLIST: dict[str, float] = {}


def revoke(jti_or_token: str, ttl_seconds: int) -> None:
    # 記錄 token + 過期時間,過期後自動清除
    _BLACKLIST[jti_or_token] = time.time() + ttl_seconds


def is_revoked(jti_or_token: str) -> bool:
    expiry = _BLACKLIST.get(jti_or_token)
    if expiry is None:
        return False
    if expiry < time.time():
        _BLACKLIST.pop(jti_or_token, None)
        return False
    return True

把這個模組整合進 get_current_user dependency:

# 在 main_auth.py 加進 get_current_user
from token_blacklist import is_revoked


def get_current_user(access_token: str | None = Cookie(default=None)) -> str:
    if not access_token:
        raise HTTPException(status_code=401, detail="缺少 access token")
    if is_revoked(access_token):
        raise HTTPException(status_code=401, detail="token 已被撤銷")
    try:
        data = jwt.decode(access_token, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="access token 過期")
    except jwt.PyJWTError:
        raise HTTPException(status_code=401, detail="access token 無效")
    if data.get("type") != "access":
        raise HTTPException(status_code=401, detail="token 類型錯誤")
    return data["sub"]


@app.post("/api/auth/logout", status_code=204)
def logout(response: Response, access_token: str | None = Cookie(default=None)):
    if access_token:
        revoke(access_token, ttl_seconds=ACCESS_TTL)
    response.delete_cookie("access_token")
    response.delete_cookie("refresh_token", path="/api/auth")
    return None

登出時把 access_token 加進黑名單(TTL 設成 access token 的剩餘有效期,過了自動清掉),之後任何帶這個 token 的請求都會被擋。黑名單的儲存選擇很多:記憶體 dict 適合單機開發、Redis 適合多機部署、PostgreSQL 表適合需要審計的場景。Web Day 20 會示範 Redis 版。

測試黑名單與撤銷

# test_revoke.py
# 驗證 token 撤銷流程
from fastapi.testclient import TestClient

from main_auth import app

client = TestClient(app)


def test_logout_revokes_token():
    # 登入
    r = client.post(
        "/api/auth/login",
        json={"username": "alice", "password": "alice-password"},
    )
    access_cookie = None
    for c in r.headers.get_list("set-cookie"):
        if c.startswith("access_token="):
            access_cookie = c.split("=", 1)[1].split(";", 1)[0]

    client.cookies.set("access_token", access_cookie)
    r = client.get("/api/me")
    assert r.status_code == 200

    # 登出
    r = client.post("/api/auth/logout")
    assert r.status_code == 204

    # 再打 /api/me 應該 401
    r = client.get("/api/me")
    assert r.status_code == 401
    assert "撤銷" in r.json()["detail"]

這份測試驗證「登出後 access token 真的失效了」。沒有黑名單時,登出只是刪 cookie,攻擊者如果之前偷到 token 仍能繼續用;有黑名單後,伺服器主動拒絕被撤銷的 token,安全性更高。Web Day 36 進入「預約管理系統」的認證時,黑名單會改用 Redis 儲存(多機部署共用),撤銷機制也會擴充為「撤銷整個使用者的所有 token」。

效能與實務提醒

access token 的 TTL 是安全與 UX 的取捨。15 分鐘很常見,但對「內部系統、信任度高」可以拉到 1 小時;對「公開服務、高風險」建議 5 分鐘。Refresh token 7 天也是常見值,金融級應用會縮短到 1 天並要求每次操作都驗證。

refresh token 的儲存不只是「httpOnly cookie」一個選項。有些團隊會把 refresh token 存進資料庫(與使用者綁定),這樣「登出所有裝置」功能就能直接從資料庫撤銷。Web Day 13 的 OAuth 章節會展開「refresh token rotation」:每次 refresh 就換新 token、舊的標記失效,被盜用時更容易察覺。

最後一個提醒:認證只是安全的一環。前端的 XSS、後端的 SQL Injection、CORS 設定、依賴套件的漏洞、部署環境的 HTTPS 設定,都會讓「完美的 token 機制」破功。本系列在 Web Day 13 與 Web Day 15 會展開 OAuth 與輸入防護,請記得整套讀。

小結

今天把「前端認證」的安全取捨與實作攤開來。我們誠實比較了 localStorage(XSS 高風險)與 httpOnly cookie(CSRF 風險但 SameSite 可擋)的差異,選了後者做示範。後端用 FastAPI 簽 access + refresh token、httpOnly cookie 設定;前端用 useActionState 做登入表單、用 authedFetch 自動 refresh、過期強制登出。pytest 涵蓋九種情境。今天最重要的觀念是「token 怎麼存」不是技術選擇而是風險評估——不同威脅模型需要不同方案。明天我們進入 WebSocket:當需要「伺服器主動通知前端」時,怎麼在 FastAPI 與 Next.js 之間建立即時通道。

結語

今天的重點是「token 儲存的安全取捨要誠實面對」。我們比較了 localStorage 與 httpOnly cookie 的攻擊面(XSS vs CSRF),選了 httpOnly cookie + 嚴格 SameSite 做示範。後端示範了 FastAPI 的 access/refresh token 模式,前端示範了 authedFetch 自動 refresh、過期重新導向。讀完這篇你應該能回答:localStorage 與 httpOnly cookie 各自的風險是什麼?為什麼 CORSMiddleware 要 allow_credentials=True?refreshInFlight 為什麼要 module-level?

明天,我們進入 WebSocket 即時更新。Web Day 24 的輪詢是「前端定時打伺服器拿最新資料」,今天看到的認證機制是「前端帶 token 存取資源」。明天會處理「伺服器主動推送資料給前端」這個場景:FastAPI 的 WebSocket 端點怎麼寫、瀏覽器怎麼訂閱、怎麼做心跳、怎麼處理斷線。WebSocket 是「預約管理系統」即時通知的核心,今天先建好,明天讓它活起來。

延伸資源

  • PyJWT 官方文件(2025):https://pyjwt.readthedocs.io/,jwt.encode/decode、ExpiredSignatureError、HS256/RS256 的選擇。
  • passlib 官方文件(2025):https://passlib.readthedocs.io/,bcrypt 雜湊、CryptContext 的版本管理。
  • OWASP JWT 安全指南(2024):https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html,token 儲存、過期、撤銷的權威建議。
  • MDN HTTP Cookie(2025):https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Cookies,HttpOnly、SameSite、Secure 的語意。
  • MDN CORS(2025):https://developer.mozilla.org/zh-TW/docs/Web/HTTP/CORS,credentials: "include" 的設定細節。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...