本文 AI 產出,尚未審核

FastAPI – Session 與 Cookie 管理

主題:簽章 Cookie(Secure Cookie)


簡介

在 Web 應用程式中,Cookie 是最常見的用戶端儲存機制之一。它可以用來保存使用者的登入資訊、偏好設定,甚至是跨請求的狀態(session)。然而,普通的 Cookie 僅是明文儲存,若被竊取或竄改,將直接危及應用程式的安全性。

簽章 Cookie(Signed Cookie) 透過在 Cookie 值上加入 HMAC 簽名,使得伺服器在每次收到 Cookie 時都能驗證其完整性與來源。結合 SecureHttpOnlySameSite 等屬性,便能打造既 安全易於維護 的認證機制。

本篇文章將以 FastAPI 為例,說明如何在 Python 生態系統中正確產生、驗證與管理簽章 Cookie,並提供實作範例、常見陷阱與最佳實踐,幫助你在真實專案中即時上手。


核心概念

1. 為什麼需要簽章 Cookie?

  • 防止竄改:未簽章的 Cookie 可以被瀏覽器外的工具(如開發者工具)直接編輯。加入簽章後,若內容被改動,簽名驗證會失敗,伺服器即可拒絕請求。
  • 減少伺服器端儲存:傳統的 session 需要在伺服器端保留資料表或快取;簽章 Cookie 把資料直接放在客戶端,伺服器只需要保存簽名金鑰即可。
  • 跨服務一致性:在微服務或多個子域名的環境中,只要金鑰一致,各服務都能驗證同一個 Cookie,降低同步成本。

2. 簽章的基本原理

  1. 產生資料(如 user_idexp 等)
  2. 將資料序列化(JSON → Base64)
  3. 使用 HMAC‑SHA256 與密鑰產生簽名
  4. payload.signature. 為分隔組合成最終 Cookie 值

收到請求時,伺服器會:

  1. 把 Cookie 拆成 payloadsignature
  2. 用相同金鑰重新計算簽名
  3. 比對兩個簽名是否相等,若相等則視為有效。

3. FastAPI 中的實作方式

FastAPI 本身不提供簽章 Cookie 的工具,但可以結合 itsdangerous(Flask 的簽章套件)或 python‑josecryptography 來完成。以下示範使用 itsdangerous.URLSafeTimedSerializer,因為它同時支援 時效(Timed)與 URL 安全(Base64 URL safe)編碼。


程式碼範例

以下範例均基於 FastAPI 0.110+Python 3.11+
為了方便說明,所有範例放在同一個 main.py,實際專案建議依功能拆分模組。

1️⃣ 基本設定與工具函式

# main.py
from fastapi import FastAPI, Request, Response, HTTPException, Depends
from fastapi.responses import JSONResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from datetime import timedelta

app = FastAPI()

# 產生簽章用的密鑰,請務必從環境變數或安全管理系統讀取
SECRET_KEY = "YOUR_SUPER_SECRET_KEY_CHANGE_ME"
COOKIE_NAME = "session_token"
# 1 天過期
COOKIE_MAX_AGE = 60 * 60 * 24

serializer = URLSafeTimedSerializer(SECRET_KEY)

2️⃣ 產生簽章 Cookie

def create_signed_cookie(data: dict, max_age: int = COOKIE_MAX_AGE) -> str:
    """
    把任意字典資料簽章後回傳字串,適合作為 Cookie value。
    """
    # data 會自動序列化為 JSON → Base64
    token = serializer.dumps(data)
    return token

3️⃣ 驗證簽章 Cookie

def verify_signed_cookie(token: str, max_age: int = COOKIE_MAX_AGE) -> dict:
    """
    驗證 Cookie 是否未被竄改且未過期,成功回傳原始資料。
    若驗證失敗會拋出例外。
    """
    try:
        data = serializer.loads(token, max_age=max_age)
        return data
    except SignatureExpired:
        raise HTTPException(status_code=401, detail="Cookie 已過期")
    except BadSignature:
        raise HTTPException(status_code=401, detail="Cookie 簽章無效")

4️⃣ 登入端點 – 發送簽章 Cookie

@app.post("/login")
async def login(response: Response, username: str, password: str):
    """
    假設有一個簡易的驗證機制,成功後回傳簽章 Cookie。
    """
    # 這裡僅示範,實務請使用資料庫與安全的密碼雜湊驗證
    if username != "admin" or password != "secret":
        raise HTTPException(status_code=401, detail="帳號或密碼錯誤")

    # 建立要放入 Cookie 的資料,通常只放最小必要資訊
    payload = {
        "sub": username,                # 使用者代號
        "role": "admin",                # 角色資訊(視需求自行決定)
        "exp": (datetime.utcnow() + timedelta(seconds=COOKIE_MAX_AGE)).timestamp(),
    }

    token = create_signed_cookie(payload)

    # 設定 Secure、HttpOnly、SameSite 等屬性
    response.set_cookie(
        key=COOKIE_NAME,
        value=token,
        max_age=COOKIE_MAX_AGE,
        httponly=True,          # 防止 JavaScript 讀取
        secure=True,           # 只在 HTTPS 下傳送
        samesite="lax",        # 防止 CSRF(根據需求可改為 strict)
    )
    return {"msg": "登入成功"}

5️⃣ 受保護的 API – 讀取並驗證 Cookie

def get_current_user(request: Request) -> dict:
    """
    依賴注入函式,從 Cookie 讀取使用者資訊。
    """
    token = request.cookies.get(COOKIE_NAME)
    if not token:
        raise HTTPException(status_code=401, detail="未提供認證 Cookie")
    return verify_signed_cookie(token)

@app.get("/profile")
async def read_profile(user: dict = Depends(get_current_user)):
    """
    只有在簽章驗證通過後才會執行,回傳使用者資訊。
    """
    return JSONResponse(content={"username": user["sub"], "role": user["role"]})

6️⃣ 登出端點 – 清除 Cookie

@app.post("/logout")
async def logout(response: Response):
    """
    透過設定過期時間為 0,讓瀏覽器刪除 Cookie。
    """
    response.delete_cookie(key=COOKIE_NAME, httponly=True, secure=True, samesite="lax")
    return {"msg": "已登出"}

7️⃣ 使用 SameSite=None 時的額外設定(跨子域名)

# 若前端與 API 分別部署在不同子域名,且需要跨站傳送 Cookie,使用 SameSite=None
response.set_cookie(
    key=COOKIE_NAME,
    value=token,
    max_age=COOKIE_MAX_AGE,
    httponly=True,
    secure=True,          # SameSite=None 必須同時設定 Secure
    samesite="none",
)

常見陷阱與最佳實踐

陷阱 說明 解決方案
金鑰外洩 SECRET_KEY 被泄漏,攻擊者可自行簽發合法 Cookie。 使用環境變數、Vault 或 KMS 儲存金鑰;定期輪換金鑰並提供舊金鑰驗證過渡期。
過長的 Cookie 把過多資料放入 Cookie 會導致請求頭過大,瀏覽器甚至拒絕發送。 只放最小必要資訊(如使用者 ID、過期時間、角色代號),其餘資訊可放在資料庫或快取。
未設定 Secure 在 HTTP 下傳送 Cookie 會被明文截取。 永遠設定 secure=True,且在測試環境使用自行簽章的 HTTPS。
忘記 HttpOnly JavaScript 可讀取 Cookie,容易被 XSS 攻擊盜走。 設定 httponly=True,除非真的需要前端讀取(如前端框架的 CSRF token)。
SameSite 設定不當 SameSite=Lax 在某些跨站 POST 情境下會被瀏覽器阻擋。 根據業務需求選擇 LaxStrictNone,但 None 必須同時啟用 Secure
時效驗證錯誤 使用 itsdangerous 時忘記傳入 max_age,導致永遠不會過期。 明確傳入 max_age,或自行在 payload 中加入 exp 並在驗證時檢查。
多服務金鑰不一致 微服務間金鑰不同,導致無法互相驗證。 使用集中式金鑰管理,或在部署腳本中同步金鑰。

最佳實踐小結

  1. 金鑰管理:使用安全的儲存方式,並支援金鑰輪換。
  2. 最小化資料:只把「身份識別」與「過期時間」放入 Cookie。
  3. 屬性設定Secure + HttpOnly + SameSite 為基本安全三劍客。
  4. 時效機制:結合 itsdangerous 的 timed serializer 或自行檢查 exp
  5. 測試:在本機使用自簽 HTTPS 測試 SecureSameSite=None 行為。

實際應用場景

場景 為什麼適合使用簽章 Cookie
單頁應用 (SPA) + API 前端不需要自行管理 token,只要瀏覽器自動帶上 Cookie,後端即可驗證使用者身份。
微服務架構 多個子服務共享同一個簽章金鑰,使用者只登入一次即可在不同服務間通行。
無狀態 API 免除伺服器端 Session 資料庫,減少記憶體與 I/O 負載。
第三方 SSO 整合 在 SSO 成功後產生簽章 Cookie,讓內部服務能快速驗證使用者。
行動端 WebView 行動應用內嵌的 WebView 只要支援 Cookie,便能使用相同的認證機制。

範例:在一個電商平台中,使用者登入後產生 session_token,前端透過 axios 自動攜帶 Cookie 呼叫 /order, /profile 等保護路由;若使用者在另一個子域名(如 admin.example.com)登入管理介面,仍能使用同一個 Cookie 進行驗證,因為所有服務共用相同的 SECRET_KEY


總結

簽章 Cookie 為 FastAPI 應用提供了一條 輕量且安全 的認證道路。透過 itsdangerous(或類似的 HMAC 套件)可以快速完成 資料簽名、時效驗證與完整性檢查;再加上 SecureHttpOnlySameSite 等屬性的正確設定,便能大幅降低 XSS、CSRF 與 session 劫持的風險。

本文從 概念說明完整程式範例常見陷阱最佳實踐實務應用場景,提供了一套可直接套用於專案的解決方案。只要遵守以下幾點:

  1. 保管好金鑰,並支援金鑰輪換。
  2. 只放最小必要資訊 在 Cookie 中。
  3. 設定 Secure + HttpOnly + SameSite
  4. 使用時效驗證 防止永久有效的 Cookie。

就能在 FastAPI 中構建一個既 安全高效 的 Session 管理機制,讓開發者把更多精力放在業務功能上,而非認證細節。

祝你在 FastAPI 專案中玩得開心,寫出更安全、更可靠的 Web 服務! 🚀