FastAPI 安全性教學:OAuth2PasswordBearer 與 OAuth2PasswordRequestForm
簡介
在 Web API 開發中,認證與授權是不可或缺的環節。即使在微服務或前後端分離的架構下,若沒有妥善的安全機制,資料洩漏、未授權存取等風險將隨時威脅系統的完整性。FastAPI 內建了與 OAuth2 兼容的工具,其中最常見的兩個組件是 OAuth2PasswordBearer 與 OAuth2PasswordRequestForm。它們讓開發者能夠以極少的程式碼,實作 「使用者名稱+密碼」 的登入流程,並在之後的請求中透過 Bearer Token 進行身份驗證。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後延伸到實務應用場景,帶領 初學者 甚至 中階開發者 完整掌握這兩個工具的使用方式。
核心概念
1. OAuth2 與 Bearer Token 基礎
OAuth2 是一套 授權框架,允許第三方應用在不直接取得使用者密碼的情況下,取得存取資源的權限。
在最簡單的 「密碼模式」(Password Grant)裡,使用者會把 username 與 password 交給 API,API 驗證成功後回傳一個 access token(通常是 JWT),之後的每一次請求都在 Authorization: Bearer <token> 標頭中帶上這個 token,API 再根據 token 內的資訊判斷使用者身份與權限。
FastAPI 透過 OAuth2PasswordBearer 產生一個 依賴項(dependency),負責從請求標頭中抽取 token;而 OAuth2PasswordRequestForm 則是 表單資料解析器,自動把 grant_type=password&username=...&password=... 轉成 Python 物件,讓你在登入路由中直接使用。
2. OAuth2PasswordBearer 的工作原理
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
# 建立一個「Bearer token」的依賴項
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
tokenUrl必須指向 發行 token(也就是登入) 的路由路徑,FastAPI 會在自動產生的 OpenAPI 文件中顯示這個資訊,讓前端或 API 文件工具(如 Swagger UI)知道要向哪裡送出認證請求。
在需要驗證身份的路由裡,只要把 oauth2_scheme 以 Depends 注入,即可取得 原始 token:
@app.get("/users/me")
async def read_current_user(token: str = Depends(oauth2_scheme)):
# token 變數即為 "Bearer" 標頭裡的字串
...
3. OAuth2PasswordRequestForm 的使用方式
from fastapi import Form
from fastapi.security import OAuth2PasswordRequestForm
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# form_data.username、form_data.password、form_data.scopes 等屬性皆可直接使用
...
OAuth2PasswordRequestForm 會自動解析 application/x-www-form-urlencoded 表單,並將下列欄位映射為屬性:
| 欄位名稱 | 說明 |
|---|---|
grant_type |
固定為 password(FastAPI 內部會自動檢查) |
username |
使用者名稱 |
password |
使用者密碼 |
scope |
權限範圍(可選) |
client_id / client_secret |
若使用 client 認證,可自行擴充 |
程式碼範例
以下示範 三個完整且常見的實作情境,從最基礎的登入驗證到結合 JWT 與資料庫的完整流程。
範例 1:最簡單的「硬編碼」認證
只適合教學或測試環境,切勿 在正式專案中使用。
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
app = FastAPI()
# 1️⃣ 建立 Bearer token 依賴項,tokenUrl 必須指向登入路由
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
# 2️⃣ 簡易的使用者資料(硬編碼)
FAKE_USERS_DB = {
"alice": {"username": "alice", "password": "wonderland"},
"bob": {"username": "bob", "password": "builder"},
}
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""
接收 username、password,驗證成功後直接回傳「假 token」。
"""
user = FAKE_USERS_DB.get(form_data.username)
if not user or user["password"] != form_data.password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# 在實務上會回傳 JWT,此處僅回傳簡易字串作為示範
return {"access_token": f"token-for-{user['username']}", "token_type": "bearer"}
@app.get("/users/me")
async def read_current_user(token: str = Depends(oauth2_scheme)):
"""
只要請求帶上正確的 Bearer token,即可取得使用者資訊。
"""
# 解析 token(此例僅簡易切割字串)
username = token.replace("token-for-", "")
if username not in FAKE_USERS_DB:
raise HTTPException(status_code=401, detail="Invalid token")
return {"username": username}
說明
OAuth2PasswordBearer會自動檢查Authorization標頭,若缺少或格式錯誤會拋出 401。OAuth2PasswordRequestForm把表單資料轉成form_data,讓我們只需檢查form_data.username、form_data.password。- 這個範例展示了 依賴注入(Dependency Injection)的威力:路由本身不需要關心 token 的取得方式。
範例 2:使用 JWT(PyJWT)產生安全的 access token
這是最常見的實務做法。JWT 可自行攜帶使用者 ID、過期時間與權限資訊。
# main_jwt.py
import time
from datetime import datetime, timedelta
from typing import Optional
import jwt # pip install PyJWT
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
app = FastAPI()
SECRET_KEY = "YOUR_SUPER_SECRET_KEY" # 請務必放在環境變數或密鑰管理服務
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
FAKE_USERS_DB = {
"alice": {"username": "alice", "hashed_password": "$2b$12$KIX/..."}, # 假設已使用 bcrypt 加密
# ... 其他使用者
}
def verify_password(plain_password: str, hashed_password: str) -> bool:
# 這裡使用 bcrypt、argon2 等安全雜湊演算法
# 省略實作,示意返回 True 表示驗證成功
return True
def get_user(username: str) -> Optional[dict]:
return FAKE_USERS_DB.get(username)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""
產生 JWT,data 必須包含 "sub"(subject)欄位,通常放使用者 ID。
"""
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = get_user(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data={"sub": user["username"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}
def get_current_user(token: str = Depends(oauth2_scheme)):
"""
從 JWT 解析出使用者資訊,若驗證失敗則拋出 401。
"""
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
user = get_user(username)
if user is None:
raise credentials_exception
return user
@app.get("/users/me")
async def read_current_user(current_user: dict = Depends(get_current_user)):
return {"username": current_user["username"]}
重點說明
| 步驟 | 目的 |
|---|---|
create_access_token |
把使用者資訊(sub)與過期時間 (exp) 編碼成 JWT |
jwt.decode |
於每次受保護的請求中驗證簽名、過期與 payload |
OAuth2PasswordBearer + Depends(get_current_user) |
把 token 抽取、驗證與使用者查詢封裝為一個依賴,路由只需要 current_user 參數即可 |
範例 3:結合資料庫(SQLModel)與角色(Scope)授權
演示如何使用
scope(權限範圍)來限制不同 API 的存取。
# main_scope.py
from typing import List, Optional
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes
from sqlmodel import Field, Session, SQLModel, create_engine, select
import jwt
from datetime import datetime, timedelta
app = FastAPI()
# ---------- 資料庫模型 ----------
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
hashed_password: str
scopes: str = Field(default="") # 逗號分隔的權限字串,例如 "read,write"
# 建立 SQLite 記憶體資料庫(實務上換成 PostgreSQL、MySQL 等)
engine = create_engine("sqlite:///:memory:")
SQLModel.metadata.create_all(engine)
# ---------- 安全設定 ----------
SECRET_KEY = "CHANGE_ME_TO_A_STRONG_RANDOM_VALUE"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/token",
scopes={"read": "Read access", "write": "Write access"},
)
def get_user_by_username(username: str) -> Optional[User]:
with Session(engine) as session:
statement = select(User).where(User.username == username)
return session.exec(statement).first()
def verify_password(plain: str, hashed: str) -> bool:
# 這裡簡化為直接比較,實務請使用 bcrypt/argon2
return plain == hashed
def create_access_token(*, data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# ---------- 登入 ----------
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = get_user_by_username(form_data.username)
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token_scopes = form_data.scopes.split()
access_token = create_access_token(
data={"sub": user.username, "scopes": token_scopes},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}
# ---------- 取得當前使用者 ----------
def get_current_user(
security_scopes: SecurityScopes,
token: str = Depends(oauth2_scheme),
) -> User:
"""
依據 token 內的 scopes 與路由宣告的 scopes 進行比對。
"""
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = "Bearer"
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
token_scopes: List[str] = payload.get("scopes", [])
if username is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
user = get_user_by_username(username)
if user is None:
raise credentials_exception
# 檢查 token 是否擁有路由所要求的 scope
for scope in security_scopes.scopes:
if scope not in token_scopes:
raise HTTPException(
status_code=403,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user
# ---------- 受保護的 API ----------
@app.get("/items/", dependencies=[Security(get_current_user, scopes=["read"])])
async def read_items():
"""
只有擁有 `read` scope 的 token 才能存取。
"""
return [{"item_id": "foo"}, {"item_id": "bar"}]
@app.post("/items/", dependencies=[Security(get_current_user, scopes=["write"])])
async def create_item(item: dict):
"""
只有擁有 `write` scope 的 token 才能新增。
"""
return {"msg": "Item created", "item": item}
關鍵點
SecurityScopes:FastAPI 會把路由宣告的scopes透過Security傳入,讓我們在驗證階段比對 token 內的 scope。OAuth2PasswordRequestForm.scopes:客戶端可以在登入時請求特定的權限,這在 細粒度授權(Fine‑grained Authorization)時非常有用。- 資料庫存取:使用
SQLModel(結合 Pydantic 與 SQLAlchemy)示範如何把使用者資訊、已授權的 scopes 儲存於持久層。
常見陷阱與最佳實踐
| 陷阱 | 為何會發生 | 解決方案 |
|---|---|---|
忘記在 OAuth2PasswordBearer 指定 tokenUrl |
OpenAPI 產生的文件會缺少登入說明,前端開發者不知要向哪裡送 token。 | 務必在建立 OAuth2PasswordBearer 時填入正確的路徑,例如 tokenUrl="/token"。 |
| 直接回傳明文密碼 | 安全漏洞,任何攔截請求的第三方都能取得使用者密碼。 | 永遠使用 bcrypt / argon2 等單向雜湊演算法,且只儲存雜湊值。 |
JWT 沒有設定 exp(過期時間) |
Token 永遠有效,若密鑰外洩會造成長期風險。 | 使用 datetime.utcnow() + timedelta(minutes=…) 設定 exp,並在驗證時檢查過期。 |
在 Depends(get_current_user) 中直接返回 None |
FastAPI 會把 None 當作合法回傳值,導致未授權的資源被誤放行。 |
若驗證失敗,拋出 HTTPException(401/403),不要回傳 None。 |
把 scopes 寫成字串而非 list |
比對時會出現類型錯誤或永遠不相等。 | 統一使用 List[str],在 token 內與資料庫皆保持相同結構。 |
在測試環境使用相同的 SECRET_KEY |
產生的測試 token 可能被拿去正式環境使用。 | 為每個環境(開發、測試、正式)使用 不同的密鑰,並透過環境變數管理。 |
最佳實踐
- 使用 HTTPS:OAuth2 的所有流程都依賴傳輸過程的安全,務必在生產環境配置 TLS。
- 將密鑰與設定抽離到環境變數或機密管理服務(如 AWS Secrets Manager、HashiCorp Vault)。
- 限制 token 的存活時間,並提供 refresh token 機制,以降低長期 token 被盜的風險。
- 日誌與監控:每次登入失敗或 token 解析錯誤都應寫入安全日誌,方便事後調查。
- 使用 Pydantic Model 產生的
OAuth2PasswordRequestForm,可以直接與 FastAPI 的自動文件(Swagger UI)整合,讓前端開發者在 UI 上直接測試認證流程。
實際應用場景
| 場景 | 為什麼適合使用 OAuth2PasswordBearer / OAuth2PasswordRequestForm |
|---|---|
| 行動 App 與後端 API | 手機端以 username/password 登入取得 JWT,之後每次呼叫 API 都只需帶 Bearer token,省去每次重新驗證的成本。 |
| 微服務間的授權 | 服務 A 先向認證服務請求 token,取得後在呼叫服務 B 時以 Authorization: Bearer <token> 傳遞,服務 B 只需要 OAuth2PasswordBearer 即可驗證。 |
| 管理後台(Admin Dashboard) | 後台使用者登入後,根據 token 中的 scopes(如 admin, editor)決定可見的功能與 UI。 |
| 第三方合作夥伴 API | 透過 client_id / client_secret 搭配 grant_type=password(或改用 client_credentials),讓合作夥伴在安全的環境下取得 token,並以此存取限定資源。 |
| IoT 裝置 | 裝置在首次連線時以預先設定的憑證換取 token,之後的資料上傳皆使用 token,減少每次都要驗證裝置密碼的開銷。 |
總結
OAuth2PasswordBearer與OAuth2PasswordRequestForm是 FastAPI 內建的兩個核心安全工具,分別負責 抽取 Bearer token 與 解析登入表單。- 透過 依賴注入(Dependency Injection),我們可以把驗證、授權與使用者查詢等邏輯抽離成獨立函式,讓每條路由保持乾淨、易於測試。
- JWT 為最常見的 token 形式,結合 過期時間 (
exp)、簽名 (HS256或 RSA) 與 自訂 claim(如sub,scopes),即可在無狀態的情況下完成身份驗證。 - Scope 機制讓我們能夠實作 細粒度授權,只要在路由上使用
Security(..., scopes=["read"]),即可自動比對 token 中的權限。 - 實務上必須注意 密碼雜湊、HTTPS、密鑰管理、Token 過期與刷新 等安全要點,並在開發與測試環境分別使用不同的
SECRET_KEY。
掌握這套流程後,你就能在 FastAPI 中快速建置安全、可擴充的認證系統,無論是單體應用、微服務或是行動/IoT 平台,都能以一致的方式管理使用者身份與授權。祝開發順利 🎉!