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"的設定細節。
留言
張貼留言