本文 AI 產出,尚未審核

FastAPI – 安全性 (Security)

主題:JWT 驗證(PyJWT)


簡介

在現代的 Web 應用程式中,身分驗證授權是不可或缺的基礎建設。若沒有妥善的驗證機制,API 很容易成為駭客攻擊的目標,導致資料外洩或服務中斷。
JSON Web Token(簡稱 JWT)是目前最流行的無狀態(stateless)認證方式之一,它以 自包含(self‑contained) 的形式攜帶使用者資訊,讓伺服器不必在每一次請求都去資料庫查詢 session,從而提升效能與擴展性。

FastAPI 本身支援多種安全機制,而 PyJWT 是 Python 生態系中最常用的 JWT 編解碼套件。透過本篇教學,我們將一步步建立 JWT 發行、驗證、刷新 的完整流程,並說明在實務上如何安全地使用它。


核心概念

1️⃣ JWT 的結構與原理

JWT 由三個部份組成,使用點 (.) 分隔:

header.payload.signature
部分 內容 常見演算法
Header 說明 Token 使用的類型(typ)與簽名演算法(alg) HS256RS256
Payload 搭載 claims(聲明),如 sub(使用者 ID)、exp(過期時間)等 無演算法限制
Signature 透過 Header + Payload + 秘密金鑰(或私鑰)產生的簽名,用於驗證 Token 是否被竄改 HMAC / RSA

重點:Payload 並非加密的,任何人都可以解碼取得裡面的資訊;因此不要在裡面放置機密資料(如密碼)。


2️⃣ 為什麼選擇 PyJWT

  • 輕量、純 Python,不依賴外部 C 函式庫。
  • 完整支援 HS(對稱)與 RS(非對稱)簽名演算法。
  • API 設計直觀:jwt.encode()jwt.decode()

3️⃣ FastAPI 與依賴注入(Dependency Injection)結合

FastAPI 允許我們把驗證邏輯寫成 依賴(dependency),然後在路由函式上使用 Depends 直接套用。這樣的寫法:

  • 保持程式碼乾淨:驗證邏輯集中管理。
  • 易於測試:可以在測試時注入 mock 物件。

程式碼範例

以下範例將一步步展示 建立 JWT、驗證 JWT、保護路由、刷新 Token 的完整流程。所有程式碼均使用 Python 3.9+FastAPIPyJWT,並以 HS256 為簽名演算法。

:實務上建議使用 RS256(非對稱金鑰)以提升安全性,範例會在最後補充說明。

3.1 初始化專案與安裝套件

# 建立虛擬環境
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate

# 安裝 FastAPI、Uvicorn、PyJWT、python-multipart(處理表單)等
pip install fastapi uvicorn pyjwt[crypto] python-multipart

3.2 設定環境變數

.env(或其他安全儲存方式)中放入 密鑰

# .env
SECRET_KEY=your_super_secret_key_please_change
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7

最佳實踐:切勿把密鑰寫死在程式碼裡,使用環境變數或 secret manager。


3.3 建立 auth.py:產生與驗證 JWT

# auth.py
import os
import datetime
from typing import Optional

import jwt
from fastapi import HTTPException, status
from pydantic import BaseModel

# 讀取環境變數
SECRET_KEY = os.getenv("SECRET_KEY", "fallback_secret")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))


class TokenPayload(BaseModel):
    """JWT Payload 內的資料結構"""
    sub: str          # 使用者唯一識別(通常是 user_id)
    exp: int          # 到期時間(Unix timestamp)
    type: str         # token 類型:access / refresh


def _create_token(data: dict, expires_delta: datetime.timedelta) -> str:
    """
    產生 JWT,內部使用 `jwt.encode`。
    - data: 需要放入 payload 的字典(會自動加入 `exp`、`type`)。
    - expires_delta: 有效期限。
    """
    to_encode = data.copy()
    expire = datetime.datetime.utcnow() + expires_delta
    to_encode.update({"exp": expire, "type": data.get("type", "access")})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


def create_access_token(user_id: str) -> str:
    """產生 Access Token(短期)"""
    return _create_token(
        {"sub": user_id, "type": "access"},
        datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )


def create_refresh_token(user_id: str) -> str:
    """產生 Refresh Token(長期)"""
    return _create_token(
        {"sub": user_id, "type": "refresh"},
        datetime.timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
    )


def decode_token(token: str, token_type: str = "access") -> TokenPayload:
    """
    解碼與驗證 JWT。
    - token_type: 限制只能解碼指定類型的 token,避免把 refresh token 當作 access token 使用。
    """
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        token_payload = TokenPayload(**payload)
        if token_payload.type != token_type:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=f"Invalid token type: expected {token_type}",
            )
        return token_payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token has expired",
        )
    except (jwt.InvalidTokenError, ValueError):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
        )

說明

  • _create_token 為私有工具函式,統一處理 exptype
  • decode_token 同時驗證 簽名過期時間、以及 token 類型,確保安全性。

3.4 建立 FastAPI 應用與驗證依賴

# main.py
import os
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.responses import JSONResponse

from auth import (
    create_access_token,
    create_refresh_token,
    decode_token,
    TokenPayload,
)

app = FastAPI(title="FastAPI JWT Demo")

# OAuth2PasswordBearer 只會解析 Authorization: Bearer <token>
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")


# ------------------------------
# 1️⃣ 使用者登入(模擬驗證)
# ------------------------------
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """
    接收 `username` 與 `password`(此範例直接接受,實務上需查 DB)。
    成功後回傳 access_token、refresh_token。
    """
    # ----- 假設的驗證流程 -----
    # 請自行替換成資料庫或 LDAP 驗證
    if form_data.username != "alice" or form_data.password != "wonderland":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
        )
    user_id = "user-123"   # 這裡通常是 DB 中的 primary key

    access_token = create_access_token(user_id)
    refresh_token = create_refresh_token(user_id)

    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_type": "bearer",
    }


# ------------------------------
# 2️⃣ 依賴:取得目前使用者
# ------------------------------
async def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenPayload:
    """
    這個依賴會自動從 Authorization Header 取出 token,
    並呼叫 `decode_token` 驗證。若失敗則拋出 401。
    """
    return decode_token(token, token_type="access")


# ------------------------------
# 3️⃣ 保護的 API 範例
# ------------------------------
@app.get("/users/me")
async def read_current_user(current_user: TokenPayload = Depends(get_current_user)):
    """
    只有持有有效 Access Token 的請求才能呼叫此端點。
    `current_user.sub` 即為登入時的 user_id。
    """
    return {"user_id": current_user.sub}


# ------------------------------
# 4️⃣ Refresh Token 流程
# ------------------------------
@app.post("/token/refresh")
async def refresh_token(refresh_token: str = Form(...)):
    """
    使用 Refresh Token 換取新的 Access Token。
    - Refresh Token 必須是 type = "refresh"
    - 若過期或被竄改,同樣回傳 401。
    """
    payload = decode_token(refresh_token, token_type="refresh")
    new_access_token = create_access_token(payload.sub)
    return {
        "access_token": new_access_token,
        "token_type": "bearer",
    }


# ------------------------------
# 5️⃣ 例外處理(美化錯誤訊息)
# ------------------------------
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail, "path": request.url.path},
    )

關鍵點說明

範例 目的
login(/token) 產生 AccessRefresh Token,示範授權流程。
get_current_user 依賴注入,將驗證邏輯抽離,使其他路由只需要 Depends(get_current_user) 即可保護。
/users/me 示範如何取得 token 中的 sub(使用者 ID)。
/token/refresh 利用 Refresh Token 換新 Access Token,實作 無狀態 的會話續期。
exception_handler 統一回傳 JSON 格式的錯誤訊息,提升前端開發體驗。

3.5 使用 RS256(非對稱金鑰)

在生產環境中,對稱金鑰(HS256) 風險較高,因為所有服務都需要持有同一把密鑰。改用 RS256 時,我們需要:

  1. 產生 RSA 金鑰對(私鑰與公鑰)

    openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
    openssl rsa -pubout -in private_key.pem -out public_key.pem
    
  2. 在程式中載入金鑰

    # auth_rsa.py(摘錄)
    with open("private_key.pem", "rb") as f:
        PRIVATE_KEY = f.read()
    with open("public_key.pem", "rb") as f:
        PUBLIC_KEY = f.read()
    
    ALGORITHM = "RS256"
    
    def _create_token(data: dict, expires_delta: datetime.timedelta) -> str:
        expire = datetime.datetime.utcnow() + expires_delta
        to_encode = {**data, "exp": expire}
        return jwt.encode(to_encode, PRIVATE_KEY, algorithm=ALGORITHM)
    
    def decode_token(token: str, token_type: str = "access") -> TokenPayload:
        try:
            payload = jwt.decode(token, PUBLIC_KEY, algorithms=[ALGORITHM])
            # 同上...
    

注意:私鑰只能放在安全的伺服器端,不可外洩;公鑰可以分發給需要驗證 token 的微服務或第三方。


常見陷阱與最佳實踐

陷阱 說明 解決方式
把機密資料放在 Payload JWT 只做簽名,不會加密,任何人都能解碼。 僅放置 sub、exp、role 等非機密資訊。若需加密,可在 Payload 中放置已加密的字串(如 AES),或改用 Opaque Token
使用過長的過期時間 失效的 token 仍可能被盜用。 Access Token 建議 5‑30 分鐘;Refresh Token 最長 7‑30 天,並配合 黑名單機制(如 Redis)撤銷。
未檢查 token 類型 攻擊者可能使用 refresh token 當 access token。 decode_token 中檢查 payload["type"],如上例所示。
密鑰硬編碼 程式碼洩漏即暴露金鑰。 使用 環境變數Docker secretKubernetes secretHashiCorp Vault
未設定 HTTPS Token 在傳輸過程被截取。 必須在生產環境強制使用 TLS(HTTPS)。
未使用 CSRF 防護(對於 Web 前端) 若 token 存在 cookie 中,仍可能受 CSRF 攻擊。 建議將 token 放在 Authorization Header,或使用 SameSiteCSRF token 結合。

最佳實踐總結

  1. 最小權限:在 Payload 中加入 rolescope,在每個路由依據權限判斷。
  2. 金鑰輪換(Key Rotation):定期更新金鑰,舊金鑰保留一段時間以容許已簽發的 token 繼續驗證。
  3. 黑名單/撤銷機制:將已登出的 refresh token 加入 Redis 黑名單,防止重複使用。
  4. 日誌與監控:記錄 token 的驗證失敗、過期、異常 IP 等資訊,結合 SIEM 監控可快速偵測攻擊。

實際應用場景

場景 為何使用 JWT 實作要點
單頁應用(SPA) + FastAPI 後端 前端(React/Vue)只需在 localStoragesessionStorage 保存 token,之後每次請求帶上 Authorization: Bearer <token> 設計 access token 5 分鐘refresh token 7 天,在 401 時自動呼叫 /token/refresh
微服務間的授權 多個服務只需要共享 公鑰,即可驗證 token,而不必每次都呼叫認證中心。 使用 RS256,將公鑰發佈至服務發現或 ConfigMap。
行動 App(iOS/Android) 手機端無法保持長連線,使用 JWT 可在斷線後快速恢復。 Token 儲存在安全的 Keychain/Keystore,刷新機制在背景執行。
第三方 API 授權 提供給合作夥伴的 API 需要驗證呼叫者身份,使用 JWT 可避免每次都查資料庫。 為每個合作夥伴產生 client_id + client_secret,再以 JWT 作為授權憑證。

總結

  • JWT無狀態跨平台 的驗證方案,適合 FastAPI 這類高效能的 ASGI 框架。
  • PyJWT 提供簡潔的編解碼 API,配合 FastAPI 的 依賴注入,可以把驗證邏輯抽離成可重用的模組。
  • 在實務上,務必注意 金鑰管理、過期時間、Token 類型驗證,以及 HTTPS最小權限 的原則。
  • 透過 Refresh Token黑名單 機制,可在保持使用者體驗的同時提升安全性。

掌握上述概念與範例後,你就能在 FastAPI 專案中安全、快速地實作 JWT 驗證,為 API 提供可靠的身分驗證與授權保護。祝開發順利!