本文 AI 產出,尚未審核
FastAPI 課程 – Session 與 Cookie 管理
主題:Session ID 與 Server‑Side Session 儲存
簡介
在 Web 應用程式中,使用者的登入狀態、購物車內容或個人化設定 必須在多個請求之間保留。最常見的做法是透過 Cookie 搭配 Session ID,將唯一的辨識碼儲存在使用者端,並在伺服器端維護真正的資料。
FastAPI 本身是無狀態(stateless)的框架,若直接使用 Request.session 之類的功能會失去 FastAPI 輕量、非同步的特性。因此,我們需要自行設計或套用成熟的 server‑side session 解決方案,才能在保持效能的同時,提供安全、可擴充的 Session 管理。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步在 FastAPI 中完成 Session ID + Server‑Side Session 的完整流程,讓你的 API 能安全地記住使用者狀態。
核心概念
1. Session ID 與 Cookie 的關係
| 角色 | 位置 | 內容 | 目的 |
|---|---|---|---|
| Session ID | 只存於 Cookie(或 URL) | 一串唯一的隨機字串(如 a3f9c2e5...) |
作為伺服器端 Session 資料的索引鍵 |
| Cookie | 使用者瀏覽器 | Set-Cookie: session_id=...; HttpOnly; Secure; SameSite=Lax |
把 Session ID 傳回伺服器,讓伺服器能找回對應的 Session 內容 |
重點:Session ID 不應直接包含使用者資料,所有敏感資訊必須存放在伺服器端。
2. 為什麼要使用 Server‑Side Session
- 安全性:即使 Cookie 被竊取,攻擊者只能取得 Session ID,若伺服器端實作了過期、IP/UA 檢查,仍能降低風險。
- 資料容量:Cookie 大小受限(約 4KB),而伺服器端可以儲存任意大小的資料(如購物車清單)。
- 集中管理:在分散式環境(多台 FastAPI 實例)下,只要使用共享的 Session 儲存(Redis、Memcached),即可保持一致性。
3. Session 的生命週期
- 產生:使用者第一次登入或訪問需要 Session 的路由,伺服器產生唯一的 Session ID,並將空的 Session 物件寫入儲存後端。
- 存取:每次請求攜帶 Cookie 時,FastAPI 透過 Session ID 從儲存後端讀取對應的 Session 資料。
- 更新:在請求處理完畢前,若有變更(如加入購物車),會把 Session 重新寫回儲存後端。
- 銷毀:使用者登出或 Session 超時時,刪除對應的 Session 並清除 Cookie。
程式碼範例
以下範例採用 Redis 作為 Session 後端,並使用 fastapi、uvicorn、redis-py。若沒有 Redis 環境,可改用 MemoryStore(僅開發測試用)。
3.1 安裝必要套件
pip install fastapi uvicorn redis python-multipart
3.2 建立 Session 中介層(Middleware)
# session_middleware.py
import uuid
import json
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
import redis
# ---------- Redis 連線 ----------
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
# ---------- Helper ----------
def generate_session_id() -> str:
"""產生唯一的 Session ID(UUID4)"""
return str(uuid.uuid4())
def get_session_data(session_id: str) -> dict:
"""從 Redis 取得 Session 資料,若不存在回傳空 dict"""
raw = redis_client.get(session_id)
return json.loads(raw) if raw else {}
def save_session_data(session_id: str, data: dict, ttl: int = 1800):
"""寫入 Session,預設 30 分鐘過期"""
redis_client.setex(session_id, ttl, json.dumps(data))
def delete_session(session_id: str):
redis_client.delete(session_id)
# ---------- Middleware ----------
class SessionMiddleware(BaseHTTPMiddleware):
"""FastAPI Middleware,負責讀寫 Cookie 與 Redis Session"""
def __init__(self, app, cookie_name: str = "session_id", max_age: int = 1800):
super().__init__(app)
self.cookie_name = cookie_name
self.max_age = max_age # 秒
async def dispatch(self, request: Request, call_next: Callable):
# 1️⃣ 讀取 Cookie 中的 session_id
session_id = request.cookies.get(self.cookie_name)
# 2️⃣ 若不存在,產生新的 Session ID
if not session_id:
session_id = generate_session_id()
session_data = {}
else:
session_data = get_session_data(session_id)
# 把 session_data 暴露給 downstream handler
request.state.session = session_data
request.state.session_id = session_id
# 3️⃣ 呼叫實際路由
response: Response = await call_next(request)
# 4️⃣ 若 session 有變更,寫回 Redis
if getattr(request.state, "session_modified", False):
save_session_data(session_id, request.state.session, ttl=self.max_age)
# 5️⃣ 設定 Cookie(若是新產生的或要延長有效期)
response.set_cookie(
key=self.cookie_name,
value=session_id,
max_age=self.max_age,
httponly=True,
secure=True, # 僅在 HTTPS 下傳送
samesite="lax",
)
return response
說明
request.state.session為本次請求的 Session 物件,路由可以直接讀寫。request.state.session_modified必須在改變資料時手動設為True,讓 Middleware 知道需要儲存。
3.3 建立 FastAPI 應用並掛載 Middleware
# main.py
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
from session_middleware import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, max_age=1800) # 30 分鐘
# ---------- 依賴注入取得 Session ----------
def get_session(request: Request):
return request.state.session
def mark_modified(request: Request):
request.state.session_modified = True
# ---------- 登入路由 ----------
@app.post("/login")
async def login(username: str, password: str, request: Request):
# 這裡僅示範,實務請使用資料庫與 hash 驗證
if username == "admin" and password == "secret":
# 把使用者資訊寫入 Session
request.state.session["user"] = {"username": username}
request.state.session_modified = True
return JSONResponse({"msg": "登入成功"})
raise HTTPException(status_code=401, detail="帳號或密碼錯誤")
# ---------- 需要驗證的路由 ----------
def require_login(session: dict = Depends(get_session)):
if "user" not in session:
raise HTTPException(status_code=401, detail="未登入")
return session["user"]
@app.get("/profile")
async def profile(user: dict = Depends(require_login)):
return {"username": user["username"], "role": "admin"}
# ---------- 加入購物車 ----------
@app.post("/cart/add")
async def add_to_cart(item_id: int, request: Request, user: dict = Depends(require_login)):
cart = request.state.session.get("cart", [])
cart.append(item_id)
request.state.session["cart"] = cart
request.state.session_modified = True
return {"msg": f"商品 {item_id} 已加入購物車", "cart": cart}
# ---------- 登出 ----------
@app.post("/logout")
async def logout(request: Request):
session_id = request.state.session_id
# 清除 Redis 中的 Session
from session_middleware import delete_session
delete_session(session_id)
# 清除 Cookie
response = JSONResponse({"msg": "已登出"})
response.delete_cookie(key="session_id")
return response
重點
require_login透過 依賴注入(Dependency Injection)取得 Session,確保所有需要驗證的路由都能共用同一段檢查程式碼。add_to_cart示範 修改 Session 時必須手動設定request.state.session_modified = True,否則 Middleware 不會寫回 Redis。
3.4 使用 MemoryStore(開發測試)
如果你沒有 Redis,可以把 session_middleware.py 中的 Redis 部分改為簡易的字典:
# 改寫為全域 dict(僅開發使用)
_memory_store = {}
def get_session_data(session_id: str) -> dict:
return _memory_store.get(session_id, {})
def save_session_data(session_id: str, data: dict, ttl: int = 1800):
_memory_store[session_id] = data
# TTL 省略,開發階段不需要
def delete_session(session_id: str):
_memory_store.pop(session_id, None)
3.5 完整測試腳本(使用 httpx)
# test_client.py
import httpx
BASE = "http://127.0.0.1:8000"
def main():
with httpx.Client(base_url=BASE, follow_redirects=True) as client:
# 1. 登入
r = client.post("/login", data={"username": "admin", "password": "secret"})
print(r.json())
# 2. 取得個人資料(會自動帶上 cookie)
r = client.get("/profile")
print(r.json())
# 3. 加入購物車
r = client.post("/cart/add", json={"item_id": 42})
print(r.json())
# 4. 登出
r = client.post("/logout")
print(r.json())
if __name__ == "__main__":
main()
執行 uvicorn main:app --reload 後,跑 python test_client.py 即可看到完整的 Session 流程。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| Session ID 生成不夠隨機 | 使用時間戳或簡易字串容易被猜測。 | 使用 UUID4 或 secrets.token_urlsafe(),確保熵值足夠。 |
Cookie 未設定 HttpOnly / Secure |
前端 JavaScript 可讀取,增加 XSS 風險。 | 在 set_cookie 時一定加上 httponly=True、secure=True(HTTPS)以及適當的 SameSite。 |
| Session 永不過期 | 造成資源泄漏,且被盜用後無自動失效機制。 | 為每筆 Session 設定 TTL(Redis EXPIRE),並在每次存取時刷新過期時間。 |
| 在多個實例間 Session 不同步 | 只使用本機記憶體會導致使用者在不同節點間失去 Session。 | 使用 共享儲存(Redis、Memcached)或 分散式 Session 方案(如 DynamoDB、MongoDB)。 |
忘記標記 session_modified |
改變 Session 內容後未寫回,導致資料遺失。 | 把「修改 Session」的程式碼封裝成 helper function,自動設定 session_modified=True。 |
| Session 內存儲過多資料 | 把大型檔案或大量商品資訊放入 Session,會導致 Redis 爆炸。 | 只儲存必要的鍵值(例如 user_id、權限、簡短的購物車 ID 列表),其餘資料透過資料庫查詢。 |
最佳實踐清單
- 使用
secrets套件產生 Session ID:secrets.token_urlsafe(32)。 - 設定 Cookie 為
SameSite=Lax(或Strict),防止 CSRF。 - 在每次請求結束時更新 TTL,讓活躍使用者的 Session 不會因為短暫的閒置而過期。
- 將 Session 相關程式碼抽離為獨立模組,方便在不同專案中重複使用。
- 監控 Redis 記憶體與過期鍵:使用
INFO、KEYS或MONITOR觀察 Session 成長趨勢,適時調整maxmemory-policy。
實際應用場景
| 場景 | 為什麼需要 Server‑Side Session |
|---|---|
| 使用者登入 + JWT 佈署 | 即使使用 JWT 作為 API 授權,仍可透過 Session 存放 一次性驗證碼(如 2FA)或 短期授權,避免把所有資訊寫入 Token。 |
| 購物車 | 把商品 ID 暫存於 Session,使用者未結帳前不必寫入資料庫,減少寫入壓力;結帳時再一次性寫入持久化資料。 |
| 多步驟表單(Wizard) | 每一步的暫存資料放在 Session,使用者可以在不同頁面間切換,最後一次提交完成。 |
| 權限切換 | 管理員在切換成普通使用者模式時,僅在 Session 中暫存 original_role,不必重新登入。 |
| 防止重複提交 | 在 Session 中記錄最近一次的請求指紋(如 request_id),若相同指紋再次到來則直接回傳成功或錯誤,避免重複寫入資料庫。 |
總結
- Session ID + Cookie 為前端與伺服器之間的橋樑,所有敏感或大量資料皆應放在 server‑side(如 Redis)。
- 透過 Middleware 把 Session 的讀寫抽象化,結合 依賴注入,可以在 FastAPI 中以極低的耦合度使用 Session。
- 安全性(
HttpOnly、Secure、SameSite)與 資源管理(TTL、共享儲存)是實務上不可忽視的要點。 - 只要把關鍵概念與實作範例內化,無論是登入驗證、購物車、或是多步驟流程,都能在 FastAPI 中以 簡潔、可擴充 的方式完成。
祝你在 FastAPI 專案中玩得開心,寫出安全、可靠且易於維護的 Session 管理程式!