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) | HS256、RS256 |
| 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+、FastAPI、PyJWT,並以 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為私有工具函式,統一處理exp與type。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) |
產生 Access 與 Refresh 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 時,我們需要:
產生 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在程式中載入金鑰
# 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 secret、Kubernetes secret 或 HashiCorp Vault。 |
| 未設定 HTTPS | Token 在傳輸過程被截取。 | 必須在生產環境強制使用 TLS(HTTPS)。 |
| 未使用 CSRF 防護(對於 Web 前端) | 若 token 存在 cookie 中,仍可能受 CSRF 攻擊。 | 建議將 token 放在 Authorization Header,或使用 SameSite 與 CSRF token 結合。 |
最佳實踐總結
- 最小權限:在 Payload 中加入
role或scope,在每個路由依據權限判斷。 - 金鑰輪換(Key Rotation):定期更新金鑰,舊金鑰保留一段時間以容許已簽發的 token 繼續驗證。
- 黑名單/撤銷機制:將已登出的 refresh token 加入 Redis 黑名單,防止重複使用。
- 日誌與監控:記錄 token 的驗證失敗、過期、異常 IP 等資訊,結合 SIEM 監控可快速偵測攻擊。
實際應用場景
| 場景 | 為何使用 JWT | 實作要點 |
|---|---|---|
| 單頁應用(SPA) + FastAPI 後端 | 前端(React/Vue)只需在 localStorage 或 sessionStorage 保存 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 提供可靠的身分驗證與授權保護。祝開發順利!