Web Day 13 OAuth2 與角色授權
執行需求:CPU 可跑。昨天我們用 JWT 做出「登入 → 發 token → 驗 token → 取得目前使用者」這套基本流程。今天要把昨天的基礎延伸成「角色授權」:讓 API 能區分「一般使用者」與「管理者」,並在端點上宣告「只有管理者能呼叫」。我們會用 FastAPI 的 Security 與 SecurityScopes 實作 OAuth2 scope 概念,並設計 require_role 與 require_admin 兩支可重複使用的相依性,後續的「預約管理系統」專案篇(Web Day 35+)會直接沿用這套設計。
引言
昨天的 JWT 認證解決了「你是誰」的問題,但還沒處理「你能做什麼」。在真實系統裡,使用者通常分幾種角色:訪客(未登入)、一般使用者(可以建立訂單、查自己的資料)、管理者(可以查所有訂單、刪除違規內容)。如果每個端點都自己寫「檢查使用者的角色」的程式碼,會出現幾個問題:第一,邏輯散落在各處,難以統一審查;第二,新人很容易在某個端點忘了檢查角色,導致權限漏洞;第三,OAuth2 規範定義的 scope 概念無法落實。
「授權」(authorization)和「認證」(authentication)是兩件事。認證是「你是誰」,授權是「你能做什麼」。昨天的 JWT 處理認證,今天處理授權。常見的授權模型有 RBAC(Role-Based Access Control)與 ABAC(Attribute-Based Access Control),前者以角色為中心、後者以屬性為中心。我們今天用 RBAC,因為它最直覺、SQL 表格也簡單,適用於大多數後端系統。RBAC 的優點是角色數量通常很少(管理員、一般使用者、編輯者等),新增角色只要加 enum 值;缺點是當業務複雜度提升,「角色」與「權限」變得多對多時,維護成本會迅速上升。ABAC 則是用條件表達式(例如「使用者只能改自己的資料」「管理者只能改同部門的資料」),靈活度高但實作複雜,適合企業級應用。多數新專案從 RBAC 開始,等真的碰到複雜情境再升級,是務實的選擇。
今天的目標有三個:第一,把昨天的 User 模型擴充成支援角色(用 enum);第二,設計 require_role(...) 與 require_admin 兩個可重複使用的相依性;第三,把 FastAPI 的 Security 與 SecurityScopes 整合進去,讓 Swagger UI 能正確顯示「這個端點需要 admin scope」。整篇文章的程式碼會在後續的預約管理系統中直接套用,特別是 Web Day 36 會再次用到今天的 require_admin 設計。
角色模型:把「身份」變成「權限」
角色(role)的本質是「一群權限的代稱」。我們用 Python 的 Enum 來定義,並把這個欄位加進昨天的 User 模型:
# app/models.py
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel
class UserRole(str, Enum):
# 使用者角色:admin 可以管理所有資料;user 只能存取自己的
ADMIN = "admin"
USER = "user"
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
password_hash: str
is_active: bool = Field(default=True)
# 新增角色欄位,預設為一般使用者
role: UserRole = Field(default=UserRole.USER)
created_at: datetime = Field(default_factory=datetime.utcnow)
用 str, Enum 的雙重繼承讓 SQLModel 與 Pydantic 都能正確序列化為字串。預設值是 UserRole.USER,這樣昨天寫的註冊端點不需要任何修改,新註冊的使用者自動成為一般使用者。資料庫存的是字串("admin" 或 "user"),與 enum 的 value 一致,避免序列化時的困擾。UserRole(str, Enum) 這種雙重繼承是 SQLModel 與 Pydantic v2 推薦的做法,str 讓 enum 成員可以直接當作字串使用(例如 role == "admin" 這種比較會通過),而 Enum 則提供型別安全與自動完成。實務上若忘了 str,序列化時 enum 會變成 UserRole.ADMIN 這種帶類別名稱的字串,前端拿到就會出問題。
接下來要做 Alembic 遷移(昨天學的)。我們在 User 上加了 role 欄位,需要一支遷移把它加進資料表:
alembic revision --autogenerate -m "add user role column"
alembic upgrade head
Alembic 會偵測到 role 欄位是新的,產生一支 add_column 動作的遷移檔。執行 upgrade head 後,所有既有使用者都會被自動填上預設值 "user",不會破壞資料。
設計 require_role 相依性
FastAPI 的相依性注入(Web Day 5)允許我們把「檢查權限」這件事包成一支可重複使用的 dependency。先擴充昨天的 app/dependencies.py:
# app/dependencies.py(接續昨天的版本)
from typing import Annotated
from fastapi import Depends, Request, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session, select
from app.db import get_session
from app.errors import ForbiddenError, UnauthorizedError
from app.models import User, UserRole
from app.tokens import decode_token
bearer_scheme = HTTPBearer(auto_error=False)
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: Session = Depends(get_session),
) -> User:
# 從 token 取使用者(昨天實作,這裡只列出重點)
if credentials is None or not credentials.credentials:
raise UnauthorizedError(message="缺少認證資訊")
try:
payload = decode_token(credentials.credentials, expected_type="access")
except ValueError as exc:
raise UnauthorizedError(message=str(exc))
user_id = int(payload["sub"])
user = session.get(User, user_id)
if user is None or not user.is_active:
raise UnauthorizedError(message="帳號不存在或已停用")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
def require_role(*allowed_roles: UserRole):
# 工廠函式:產生一支檢查角色的 dependency
# 用法:user: Annotated[User, Depends(require_role(UserRole.ADMIN))]
def _checker(user: CurrentUser) -> User:
if user.role not in allowed_roles:
raise ForbiddenError(
message="權限不足",
details={
"required": [r.value for r in allowed_roles],
"current": user.role.value,
},
)
return user
return _checker
# 常用的角色檢查工廠
require_admin = require_role(UserRole.ADMIN)
require_user = require_role(UserRole.USER, UserRole.ADMIN)
require_role 是一個工廠函式(factory function):呼叫它傳入「允許的角色清單」,它回傳一支相依性。這個設計的好處是「一個工廠、多種組合」:要「只有 admin」傳 UserRole.ADMIN、要「user 或 admin」傳兩個值,未來加新角色(例如 EDITOR)只要改 enum,工廠不用動。ForbiddenError 是 Web Day 10 定義的 403 例外,這裡接上昨天的統一錯誤結構。details 帶上「需要什麼角色」與「目前是什麼角色」,方便前端顯示對應訊息。
OAuth2 scope 與 SecurityScopes
FastAPI 對 OAuth2 的 scope 概念有原生支援:透過 Security 與 SecurityScopes,可以在 OpenAPI(Swagger)文件裡正確顯示「這個端點需要 admin scope」。這對前端串接與第三方整合特別重要:他們看文件就知道這個 API 要帶什麼權限。我們把昨天的設計升級成 OAuth2 相容版本:
# app/dependencies.py(OAuth2 升級版)
from typing import Annotated
from fastapi import Depends, Security
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
SecurityScopes,
)
from sqlmodel import Session
from app.db import get_session
from app.errors import ForbiddenError, UnauthorizedError
from app.models import User, UserRole
from app.tokens import decode_token
# scope 與角色對應:scope 是 OAuth2 標準術語,角色是內部模型
# 把 admin 對應到 "admin" scope,user 對應到 "user" scope
ROLE_TO_SCOPE = {
UserRole.ADMIN: "admin",
UserRole.USER: "user",
}
bearer_scheme = HTTPBearer(
auto_error=False,
scopes={
"admin": "管理者權限",
"user": "一般使用者權限",
},
)
def get_current_user(
security_scopes: SecurityScopes,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: Session = Depends(get_session),
) -> User:
# 驗證 token 並檢查 scope
if credentials is None or not credentials.credentials:
raise UnauthorizedError(message="缺少認證資訊")
try:
payload = decode_token(credentials.credentials, expected_type="access")
except ValueError as exc:
raise UnauthorizedError(message=str(exc))
user_id = int(payload["sub"])
user = session.get(User, user_id)
if user is None or not user.is_active:
raise UnauthorizedError(message="帳號不存在或已停用")
# 檢查 scope:使用者角色必須涵蓋端點要求的所有 scope
if security_scopes.scopes:
user_scope = ROLE_TO_SCOPE.get(user.role)
if user_scope is None or user_scope not in security_scopes.scopes:
raise ForbiddenError(
message="權限不足",
details={
"required_scopes": security_scopes.scopes,
"current_scope": user_scope,
},
)
return user
CurrentUser = Annotated[User, Security(get_current_user)]
def require_role(*allowed_roles: UserRole):
# 同昨天的設計
def _checker(user: CurrentUser) -> User:
if user.role not in allowed_roles:
raise ForbiddenError(
message="權限不足",
details={
"required": [r.value for r in allowed_roles],
"current": user.role.value,
},
)
return user
return _checker
require_admin = require_role(UserRole.ADMIN)
require_user = require_role(UserRole.USER, UserRole.ADMIN)
這段有幾個關鍵升級。第一,HTTPBearer(scopes=...) 把 scope 宣告在 security scheme 裡,這樣 OpenAPI 文件會顯示「這個 API 用 Bearer token,且支援 admin/user scope」。第二,get_current_user 多了 security_scopes: SecurityScopes 參數,FastAPI 會把端點要求的 scope 清單自動傳進來,我們用它和使用者的角色對比。第三,CurrentUser 用 Security(...) 而非 Depends(...),讓 OpenAPI 文件知道這是「需要授權」的端點。
在端點上宣告需要的權限
現在來看一支需要管理者權限的端點。我們擴充昨天的英雄路由,加入「列出所有英雄(管理者專用)」與「刪除英雄(管理者專用)」:
# app/routers/heroes.py(新增管理者端點)
from typing import Annotated
from fastapi import APIRouter, Depends, Security, status
from sqlmodel import Session, select
from app.db import get_session
from app.dependencies import CurrentUser, require_admin, require_user
from app.errors import ForbiddenError, NotFoundError
from app.models import Hero, User
from app.schemas import HeroCreate
router = APIRouter(prefix="/heroes", tags=["heroes"])
@router.get("/")
def list_heroes(
user: Annotated[User, Security(require_user)],
session: Session = Depends(get_session),
) -> list[Hero]:
# 任何登入的使用者都可以列出英雄
heroes = session.exec(select(Hero)).all()
return heroes
@router.delete("/{hero_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_hero(
hero_id: int,
user: Annotated[User, Security(require_admin)],
session: Session = Depends(get_session),
) -> None:
# 只有管理者能刪除英雄
hero = session.get(Hero, hero_id)
if hero is None:
raise NotFoundError(
message=f"找不到 ID 為 {hero_id} 的英雄",
details={"hero_id": hero_id},
)
session.delete(hero)
session.commit()
這裡用 Security(require_admin) 把權限需求掛在端點上。FastAPI 會自動做兩件事:第一,OpenAPI 文件會顯示「這個端點需要 admin scope」;第二,當 request 進來時,get_current_user 會被呼叫,security_scopes.scopes 會包含 ["admin"],我們用它和使用者角色對比,不符就回 403。整個授權檢查完全自動,端點程式碼只關心業務邏輯。
新增角色的管理工具
實務上需要一支「管理者才能呼叫」的端點來變更其他使用者的角色。我們在 app/routers/admin.py 裡集中放管理者工具:
# app/schemas.py(新增)
from pydantic import BaseModel
from app.models import UserRole
class UpdateUserRoleRequest(BaseModel):
role: UserRole
class UserSummary(BaseModel):
id: int
email: str
role: UserRole
is_active: bool
管理者路由:
# app/routers/admin.py
from typing import Annotated
from fastapi import APIRouter, Depends, Security
from sqlmodel import Session, select
from app.db import get_session
from app.dependencies import require_admin
from app.errors import NotFoundError
from app.models import User
from app.schemas import UpdateUserRoleRequest, UserSummary
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
)
@router.get("/users", response_model=list[UserSummary])
def list_users(session: Session = Depends(get_session)) -> list[User]:
# 列出所有使用者(僅管理者)
users = session.exec(select(User)).all()
return users
@router.patch("/users/{user_id}/role", response_model=UserSummary)
def update_user_role(
user_id: int,
payload: UpdateUserRoleRequest,
session: Session = Depends(get_session),
) -> User:
# 變更使用者角色(僅管理者)
user = session.get(User, user_id)
if user is None:
raise NotFoundError(message="使用者不存在")
user.role = payload.role
session.add(user)
session.commit()
session.refresh(user)
return user
注意 router = APIRouter(..., dependencies=[Depends(require_admin)]):把 require_admin 掛在整個 router 上,這個 router 底下的所有端點都自動需要管理者權限,不必每支端點都加一次。這種「群組權限」設計對大型 API 很方便:可以把所有管理端點集中在 admin router,一次設定全部生效。
建立第一個管理者的工具腳本
因為註冊端點預設給一般使用者角色,第一個管理者要怎麼產生?我們寫一支獨立腳本,繞過註冊流程直接建立管理者帳號:
# scripts/create_admin.py
# 用法:python -m scripts.create_admin alice@example.com MyP@ssw0rd-2025
import sys
from datetime import datetime
from sqlmodel import Session, select
from app.db import engine
from app.models import User, UserRole
from app.security import hash_password
def main() -> None:
if len(sys.argv) != 3:
print("用法:python -m scripts.create_admin <email> <password>")
sys.exit(1)
email, password = sys.argv[1], sys.argv[2]
with Session(engine) as session:
existing = session.exec(
select(User).where(User.email == email)
).first()
if existing is not None:
print(f"使用者 {email} 已存在,更新為 admin")
existing.role = UserRole.ADMIN
session.add(existing)
else:
user = User(
email=email,
password_hash=hash_password(password),
role=UserRole.ADMIN,
created_at=datetime.utcnow(),
)
session.add(user)
session.commit()
print(f"完成:{email} 現在是 admin")
if __name__ == "__main__":
main()
這支腳本透過命令列參數建立或升級管理者帳號,正式環境通常會把這個流程改成「由現有管理者透過後台變更」或「透過一次性邀請碼」。但開發階段用 CLI 腳本最快。注意 hash_password 是昨天的工具,雜湊與角色設定都在同一支腳本裡。
驗證完整流程
啟動服務(命令列):
# 建立管理者
python -m scripts.create_admin admin@example.com AdminP@ss-2025
# 建立一般使用者
python -m scripts.create_admin alice@example.com AliceP@ss-2025
# 啟動服務
uvicorn app.main:app --reload --port 8000
用 httpx 驗證三種情境:
# test_roles.py
import httpx
BASE = "http://127.0.0.1:8000/api"
def login(client: httpx.Client, email: str, password: str) -> str:
r = client.post(
"/auth/login", json={"email": email, "password": password}
)
r.raise_for_status()
return r.json()["access_token"]
with httpx.Client(base_url=BASE, timeout=10.0) as client:
# 兩種使用者登入
admin_token = login(client, "admin@example.com", "AdminP@ss-2025")
user_token = login(client, "alice@example.com", "AliceP@ss-2025")
admin_headers = {"Authorization": f"Bearer {admin_token}"}
user_headers = {"Authorization": f"Bearer {user_token}"}
# 情境 1:一般使用者列出英雄(200,允許)
r = client.get("/heroes/", headers=user_headers)
print(f"user GET /heroes/:{r.status_code}")
# 情境 2:一般使用者刪英雄(403,權限不足)
r = client.delete("/heroes/1", headers=user_headers)
print(f"user DELETE /heroes/1:{r.status_code}", r.json())
# 輸出(範例):
# 403 {'error': {'code': 'FORBIDDEN', ...}}
# 情境 3:管理者刪英雄(204,成功)
r = client.delete("/heroes/1", headers=admin_headers)
print(f"admin DELETE /heroes/1:{r.status_code}")
# 輸出(範例):204
# 情境 4:未登入存取管理者端點(401)
r = client.get("/admin/users")
print(f"未登入 GET /admin/users:{r.status_code}", r.json())
# 輸出(範例):
# 401 {'error': {'code': 'UNAUTHORIZED', ...}}
# 情境 5:使用者存取管理者端點(403)
r = client.get("/admin/users", headers=user_headers)
print(f"user GET /admin/users:{r.status_code}", r.json())
# 輸出(範例):
# 403 {'error': {'code': 'FORBIDDEN', ...}}
五個情境完整覆蓋認證 + 授權的所有路徑。每一個錯誤都走 Web Day 10 設計的 UnauthorizedError 或 ForbiddenError,回應結構一致。前端可以根據 code 與 status_code 決定行為:401 走「重新登入」流程,403 顯示「權限不足」訊息。如果你想看更細的錯誤結構,可以把 details 印出來:它會帶上「需要 admin scope」與「目前是 user scope」這類資訊,前端可以針對性地顯示「請聯絡管理員開通權限」這類提示文字。
另一個值得驗證的是 Swagger UI:開啟 http://127.0.0.1:8000/docs,點開 DELETE /heroes/{hero_id} 會看到右上角有個小鎖頭圖示,標示「需要 admin scope」。這就是 OAuth2 SecurityScopes 的功用:讓文件本身就說明權限需求。同時,Swagger UI 還提供「Authorize」按鈕,讓你可以貼上一個 admin token,整個 API 文件就會自動帶這個 token 測試所有端點。這個對前端串接、後端測試、第三方整合都是極大的便利,是 FastAPI 文件系統在業界受歡迎的主因之一。
常見錯誤與踩雷
第一個常見的踩雷是「把角色檢查寫在每個端點裡」。新手常這樣寫:
# 錯誤示範
@router.delete("/heroes/{hero_id}")
def delete_hero(hero_id: int, user: CurrentUser, session: Session = Depends(get_session)):
if user.role != "admin":
raise HTTPException(403, "需要管理員權限")
# ... 業務邏輯
這有兩個問題:第一,邏輯散落在各處,新人容易在某個端點忘了檢查;第二,OpenAPI 文件不會顯示這個端點需要權限。我們用 require_role 工廠與 Security 註冊解決這個問題。
第二個是「忘了升級 Alembic 遷移」。當我們在 User 加了 role 欄位後,必須跑 alembic revision --autogenerate 並 alembic upgrade head,否則正式環境的資料表沒有這個欄位,註冊或登入時就會出錯。Web Day 9 建立的遷移流程正是為這種情境設計的。
第三個是「scope 名稱與角色名稱混淆」。OAuth2 的 scope 是「資源的權限單位」(例如 read:heroes、write:heroes),角色是「使用者的身份」(admin、user)。兩者不一定一對一:一個 admin 可能有多個 scope,一個 scope 可能被多個角色擁有。今天的範例為了簡化把兩者對應起來(admin 角色 → admin scope),但實務上要分開:scope 表達「能做什麼事」,角色表達「是什麼人」,中間用一個 mapping 串起來。
第四個是「忘記 Security 與 Depends 的差別」。Depends 只是單純的相依性注入;Security 除了注入之外,還會把這個 dependency 標記為「需要授權」,反映在 OpenAPI 文件上。要讓 Swagger UI 顯示鎖頭圖示,必須用 Security 而非 Depends。
效能與實務提醒
角色檢查的效能成本極低(只是 enum 成員的比較),但「每次請求都查一次 User 表」是常見的瓶頸。如果你的 API 高頻呼叫且對 latency 敏感,可以考慮兩個方向:第一,把使用者物件(含角色)放進 Redis 快取,5 秒過期;第二,把 role 放進 JWT 的 payload,這樣驗 token 時順便拿到角色,不必額外查資料庫。第二個做法的權衡是「角色變更無法即時生效」,因為已經發出去的 token 還帶著舊角色。實務上很少需要即時變更角色(管理者降級一般使用者通常是「等使用者重新登入」這個情境),所以這個權衡在大多數小型到中型的系統是可接受的,這個範圍涵蓋大多數的實務情境。另外,role 放進 payload 還有一個好處是「跨服務一致」:如果未來你有多個微服務各自驗證 JWT,把角色放在 token 裡就不必每個服務各自去查 User 表,統一的權限來源就在 token 裡。當然,這個做法的前提是 access token 有效期要短(昨天的 30 分鐘設計就符合),否則角色變更要等 token 過期才生效,會讓管理操作變得遲鈍。
另一個議題是「角色數量」。我們今天只有 admin 與 user 兩種,簡單明瞭。但真實系統可能有十幾種角色(editor、viewer、billing、support 等等),這時 RBAC 會變得難以維護,因為角色與權限的對應變成多對多。實務上到了這個規模,建議改用 ABAC 或引入權限管理系統(例如 Casbin、Oso)。我們今天先求正確理解 RBAC,未來若角色數量膨脹再升級。
最後提醒:「SecurityScopes 的 scope 宣告要與 HTTPBearer(scopes=...) 一致」。如果在 HTTPBearer 沒宣告某個 scope,但端點用 Security(... scopes=["xxx"]) 要求它,OpenAPI 文件會出現奇怪的顯示。維護這份對應是必要的:每次新增 scope 要同步更新 HTTPBearer(scopes=...) 與 ROLE_TO_SCOPE。
小結
今天把昨天的 JWT 認證升級成完整的角色授權系統。我們用 UserRole enum 與 role 欄位把角色帶進 User 模型,並用 Alembic 遷移更新資料庫;require_role 工廠與 require_admin 把「檢查權限」變成可重複使用的相依性;SecurityScopes 與 Security 則把 OAuth2 scope 概念整合進 FastAPI,讓 Swagger UI 正確顯示權限需求。所有錯誤(401 未登入、403 權限不足)都走 Web Day 10 設計的統一 JSON 結構,前端可以無歧義地處理。這套架構會在後續的預約管理系統(Web Day 35+)直接套用,特別是「管理者後台」與「使用者只能改自己的預約」這兩個情境。貫穿整個安全主題,我們已經完成四個關鍵設計:密碼以 argon2id 單向雜湊(Web Day 11)、JWT 認證與無狀態 token(Web Day 12)、OAuth2 角色授權(Web Day 13)、明天會做的輸入防護(CORS、SQL 注入、XSS)。這四個支柱放在一起,就構成了一個對中型 API 來說已經足夠的資安基礎;更高階的威脅模型(rate limiting、audit log、機密偵測)會在品質與上線篇章陸續補上。
結語
今天完成了 OAuth2 與角色授權,安全主題的四大支柱(密碼雜湊、JWT 認證、OAuth2 角色、輸入防護)已經做了一半。明天進入「檔案上傳與靜態檔案」:你會學到 FastAPI 的 UploadFile 與 File 怎麼處理使用者上傳的頭像、怎麼限制檔案大小與 MIME 型別、怎麼用 StaticFiles 把上傳後的圖片透過 URL 對外提供,以及資安上該注意的「不存可執行檔、隨機檔名、過濾 magic bytes」等實務守則。這是後續「預約管理系統」中客戶頭像與預約附件功能的基礎。
延伸資源
- FastAPI 官方文件:安全性與 OAuth2(0.116,2025):
https://fastapi.tiangolo.com/tutorial/security/ - FastAPI 官方文件:SecurityScopes(0.116,2025):
https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/ - OAuth 2.0 RFC 6749(2025):
https://datatracker.ietf.org/doc/html/rfc6749 - OWASP 存取控制 cheat sheet(2025):
https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
留言
張貼留言