FastAPI – 安全性(Security)
token 驗證與過期機制
簡介
在 Web API 中,最常見的授權方式就是 JWT(JSON Web Token)。它不僅能攜帶使用者的身份資訊,還能在不需要每次都查詢資料庫的情況下完成授權驗證,極大提升效能。
然而,若沒有妥善處理 token 的驗證與過期,就會出現安全漏洞,例如被盜用的 token 永遠有效、或是過期的 token 仍被接受,進而造成資料外洩或服務被濫用。
本單元將說明在 FastAPI 中如何實作 token 的簽發、驗證、以及自動過期,並提供完整範例、常見陷阱與最佳實踐,讓你在開發 API 時能快速且安全地使用 JWT。
核心概念
1. JWT 的基本結構
JWT 由三個部份組成,使用 . 分隔:
header.payload.signature
| 部份 | 內容 | 說明 |
|---|---|---|
| header | 例如 {"alg":"HS256","typ":"JWT"} |
指定簽名演算法 |
| payload | 例如 {"sub":"user_id","exp":1700000000} |
放置使用者資訊與過期時間 (exp) |
| signature | HMAC SHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret ) | 防止資料被竄改 |
重點:
exp(Expiration Time)是 JWT 內建的過期欄位,FastAPI 會依此欄位自動判斷 token 是否已過期。
2. 為什麼要設定過期時間?
- 降低被盜用的風險:即使攻擊者取得 token,也只能在有效期間內使用。
- 強制使用者重新驗證:過期後必須重新登入或使用 refresh token 取得新 token。
- 符合資安合規:許多法規(如 GDPR、PCI‑DSS)要求「最小權限」與「最短存活時間」的認證機制。
3. 常見的驗證流程
- 使用者登入 → 後端驗證帳號/密碼。
- 後端產生 JWT(包含
sub、exp、iat等欄位),回傳給前端。 - 前端將 token 存於 Authorization Header:
Bearer <token>。 - 每次呼叫受保護的 API,FastAPI 會解析 Header、驗證簽名與過期時間。
- 若驗證成功,
Depends會把使用者資訊注入路由函式;失敗則拋出 401。
程式碼範例
以下範例採用 Python 3.9+、FastAPI、PyJWT(python-jose)以及 Passlib 來完成完整的登入、產生 token、驗證以及過期機制。
3.1 安裝必要套件
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt]
3.2 建立 auth.py:Token 產生與驗證工具
# auth.py
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
# ------------------------------------------------------------
# 設定
# ------------------------------------------------------------
SECRET_KEY = "YOUR_SUPER_SECRET_KEY_CHANGE_THIS" # 請務必使用環境變數
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 # Access Token 有效期 30 分鐘
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# ------------------------------------------------------------
# 密碼雜湊與驗證
# ------------------------------------------------------------
def get_password_hash(password: str) -> str:
"""將明文密碼雜湊"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""驗證明文密碼與雜湊是否相符"""
return pwd_context.verify(plain_password, hashed_password)
# ------------------------------------------------------------
# JWT 產生
# ------------------------------------------------------------
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
建立 JWT。
- `data` 必須包含 `sub`(使用者唯一識別碼)。
- `expires_delta` 若未提供,預設使用 ACCESS_TOKEN_EXPIRE_MINUTES。
"""
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# ------------------------------------------------------------
# JWT 驗證
# ------------------------------------------------------------
def decode_access_token(token: str) -> dict:
"""
解析 JWT,若驗證失敗或已過期會拋出 JWTError。
回傳 payload(已解碼的字典)。
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError as e:
raise e
3.3 建立 main.py:FastAPI 路由與依賴
# main.py
from datetime import timedelta
from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Optional
from auth import (
get_password_hash,
verify_password,
create_access_token,
decode_access_token,
)
app = FastAPI(title="FastAPI JWT 範例")
# ------------------------------------------------------------
# 模擬資料庫(實務上請改成真實 DB)
# ------------------------------------------------------------
fake_users_db = {
"alice@example.com": {
"username": "alice",
"full_name": "Alice Wonderland",
"hashed_password": get_password_hash("secret123"),
"disabled": False,
}
}
# ------------------------------------------------------------
# OAuth2 設定(使用 Bearer Token)
# ------------------------------------------------------------
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
def get_current_user(token: str = Depends(oauth2_scheme)):
"""
依賴:從 Header 取得 token,驗證簽名與過期時間,
若成功回傳使用者資訊,失敗則拋出 401。
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="無效的驗證資訊",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except Exception:
raise credentials_exception
user = fake_users_db.get(username)
if user is None:
raise credentials_exception
return user
# ------------------------------------------------------------
# 登入端點:取得 Access Token
# ------------------------------------------------------------
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""
使用者以 `username` + `password` 登入,
若驗證成功回傳 JWT(access_token)。
"""
user_dict = fake_users_db.get(form_data.username)
if not user_dict:
raise HTTPException(status_code=400, detail="使用者不存在")
if not verify_password(form_data.password, user_dict["hashed_password"]):
raise HTTPException(status_code=400, detail="密碼錯誤")
access_token_expires = timedelta(minutes=30)
access_token = create_access_token(
data={"sub": user_dict["username"]},
expires_delta=access_token_expires,
)
return {"access_token": access_token, "token_type": "bearer"}
# ------------------------------------------------------------
# 受保護的範例 API
# ------------------------------------------------------------
@app.get("/users/me")
async def read_current_user(current_user: dict = Depends(get_current_user)):
"""
只有持有有效且未過期的 token 才能呼叫此端點。
"""
return {"username": current_user["username"], "full_name": current_user["full_name"]}
# ------------------------------------------------------------
# 測試過期機制:手動產生已過期的 token
# ------------------------------------------------------------
@app.get("/test/expired")
async def test_expired_token():
"""
產生一個已過期的 token(-5 分鐘),用於測試 401 回應。
"""
expired_token = create_access_token(
data={"sub": "alice"},
expires_delta=timedelta(minutes=-5),
)
return {"expired_token": expired_token}
3.4 使用 uvicorn 啟動服務
uvicorn main:app --reload
測試流程
取得 token
curl -X POST "http://127.0.0.1:8000/token" -H "Content-Type: application/x-www-form-urlencoded" -d "username=alice@example.com&password=secret123"會得到類似:
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","token_type":"bearer"}呼叫受保護 API
curl -H "Authorization: Bearer <access_token>" http://127.0.0.1:8000/users/me驗證過期
取得/test/expired回傳的 token,帶入同樣的 Header,會收到 401 Unauthorized,證明過期機制生效。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 建議的最佳實踐 |
|---|---|---|
使用固定的 SECRET_KEY |
攻擊者若取得程式碼即能偽造所有 token。 | 將 secret 存於環境變數或密鑰管理服務(如 AWS Secrets Manager)。 |
未設定 exp 欄位 |
token 永遠有效,風險極高。 | 必須在產生 JWT 時加入 exp,且設定合理的存活時間(如 15~60 分鐘)。 |
| 過長的過期時間 | 使用者長時間未登出仍可使用舊 token。 | 根據業務需求調整過期時間,搭配 refresh token。 |
在前端將 token 存於 localStorage |
容易受到 XSS 攻擊。 | 使用 HttpOnly、Secure 的 Cookie,或確保前端避免 XSS。 |
未驗證 sub 欄位 |
可能會把錯誤的使用者資訊注入系統。 | 驗證 payload 中的 sub 是否對應真實使用者,且檢查 iss、aud 等欄位(若有需求)。 |
| 使用同步的密碼雜湊 | 大量登入請求時會阻塞事件迴圈。 | 使用 passlib 的 async 版本或在背景執行緒中處理。 |
其他安全建議
- HTTPS:所有 API 必須走 TLS,防止 token 被竊聽。
- CORS 設定:僅允許可信任的前端來源。
- Rate Limiting:對登入端點實施速率限制,降低暴力破解風險。
- Refresh Token:使用短期 Access Token 搭配長期 Refresh Token,提升使用者體驗與安全性。
- Token 黑名單:在使用者登出或密碼變更時,將舊 token 加入黑名單(如 Redis),即使未過期也會被拒絕。
實際應用場景
企業內部系統
員工登入後取得 30 分鐘的 Access Token,前端每次呼叫 API 時自動帶入。若超過期限,前端使用 Refresh Token 取得新 Access Token,無需再次輸入密碼。行動 App
手機端儲存 JWT 於 Secure Enclave,並在每次 API 呼叫時加上 Bearer Header。若使用者切換設備,舊 token 立即失效(透過黑名單機制),新設備必須重新授權。微服務間授權
服務 A 產生 JWT(包含roles權限),服務 B 在收到請求時驗證 token 並根據roles決定是否允許存取特定資源,確保服務間的最小權限原則。
總結
- JWT 是 FastAPI 中最常見且高效的授權方式。
- 過期機制 (
exp) 是保護 token 不被長期濫用的關鍵,務必在產生時明確設定。 - 透過 依賴注入 (
Depends) 搭配OAuth2PasswordBearer,可以在路由層級輕鬆完成驗證。 - 最佳實踐 包括使用環境變數管理密鑰、HTTPS、短效 Access Token + Refresh Token、以及必要的黑名單機制。
掌握以上概念與範例後,你就能在 FastAPI 專案中安全、快速地實作 token 驗證與過期機制,為 API 提供可靠的保護層。祝開發順利,系統安全無虞!