Web Day 36 專案:認證與多角色
執行需求:CPU 可跑。今天在 Day 35 的資料模型上實作認證與多角色:用 passlib 的 argon2id 雜湊密碼、用 PyJWT 簽發 access token、用 FastAPI 的 Depends 系統注入 current_user,再依角色限制 admin 與 customer 兩種身份能看到的端點。所有資料都是虛構的預約管理系統範例,email 與密碼都是測試用的假資料。今天寫完之後,後面 Day 37 的衝突檢查、Day 38 的通知、Day 39 的後台介面都會直接套用這層認證機制。
引言
認證是 Web 後端最容易被低估的一塊。Day 11 到 Day 15 我們學過密碼雜湊、JWT、OAuth2 等基本觀念,今天要把這些組合到預約管理系統裡。對一個有兩種角色的系統來說,認證不只是「讓使用者登入」,更要把「誰能做什麼」這件事清楚地寫在程式碼裡——否則半年後回來看,你會搞不清楚哪個 endpoint 是公開的、哪個需要 admin、哪個需要 customer 自己。很多資安事件就是因為「忘了在某個深處加權限檢查」造成的,與其事後補救,不如一開始就把權限設計在 dependency 層。
今天寫完之後,你會拿到一個能跑註冊、登入、拿 current_user、檢查角色的 FastAPI 應用,並且完整保留 Day 35 定義的 6 個 entity 與共用設定。我們會用 pytest 寫整合測試(透過 TestClient)、用 httpx 模擬外部請求、並用實際的 SQLModel session 驗證「真的有寫進資料庫」。今天沒有 Docker 也能跑——直接用 SQLite + 本機 venv 即可驗證所有行為。
從 Day 35 沿用什麼
今天的程式碼完全建立在 Day 35 的基礎上,引用以下東西:
- User model(含 email、password_hash、display_name、role、timezone)
- UserRole enum(admin / customer)
- session_scope context manager(用 SQLAlchemy session 做 transaction)
- Alembic 設定(繼續用同一份遷移)
如果你的本地端還沒跑過 `alembic upgrade head` 與 `python scripts/seed.py`,請先執行。seed 會建立 1 個 admin 帳號(email: admin@example.com)與 10 個 customer,這些帳號可以拿來登入測試。記得 seed 用的 password_hash 是 placeholder 字串,正式登入前要先把 admin 的密碼更新過,這部分會在「建立 admin 帳號」段落說明。
密碼雜湊:argon2id
密碼儲存的原則是「絕對不存明文」。argon2id 是 2025 年 7 月主流的雜湊演算法,passlib 的 CryptContext 提供完整支援。我們把密碼相關的操作封裝在一個 helper 模組,方便後面註冊、登入、改密碼等場景直接呼叫。對照其他常見演算法:bcrypt 仍然是好選擇但有 72 byte 密碼長度限制;scrypt 是另一個選項但參數難調;PBKDF2 在 NIST 已經不推薦。argon2id 是 OWASP 與多數資安組織在 2025 年的首選。
"""app/security.py:密碼雜湊與 JWT 工具。"""
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 12
def hash_password(plain: str) -> str:
"""把明文密碼雜湊成 argon2id 字串。"""
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""驗證明文密碼與雜湊值是否匹配。"""
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str, role: str, expires_delta: timedelta | None = None) -> str:
"""簽發 access token,subject 是 user id,role 用於後端快速判斷權限。"""
now = datetime.now(timezone.utc)
expire = now + (expires_delta or timedelta(hours=JWT_EXPIRE_HOURS))
payload = {"sub": subject, "role": role, "iat": int(now.timestamp()), "exp": int(expire.timestamp())}
secret = __import__("os").environ["JWT_SECRET"]
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""解碼並驗證 token,過期或簽章錯誤會拋例外。"""
secret = __import__("os").environ["JWT_SECRET"]
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
這份 helper 有四個關鍵設計:第一,CryptContext 用 `schemes=["argon2"]` 鎖定演算法,deprecated="auto" 讓未來換新演算法時自動轉移;第二,create_access_token 把 role 放進 payload,這樣後端不用每次查 DB 就知道使用者角色,省一次 query;第三,JWT_SECRET 從環境變數讀,避免 secret 寫進版控;第四,decode_access_token 過期或簽章錯誤會自動拋 InvalidTokenError,由 endpoint 的 exception handler 統一回 401。
JWT_SECRET 的設定範例(放進 .env):
openssl rand -hex 32
把這串輸出貼到 .env 的 JWT_SECRET。記得 .env 要放進 .gitignore(Day 30 的 .dockerignore 也已經涵蓋)。正式環境則用 docker secrets 或雲端的 secret manager 注入,避免 .env 檔本身被推到版控。
註冊 endpoint
註冊流程很單純:收 email、password、display_name,檢查 email 是否已被使用、雜湊密碼、建立 User 紀錄、回傳 access token。我們把 password 與 password 確認欄位都放進 Pydantic schema,由 Pydantic 自動驗證長度與一致。註冊流程看起來簡單,但實務上有不少細節:第一,email 要先 normalize(小寫化、去空白)再存,避免「Alice@example.com」與「alice@example.com」被當成兩個帳號;第二,註冊完成後直接登入(回 token)比導回登入頁更友善,但對要求「email 驗證後才能登入」的系統就要拆成兩步。
"""app/routers/auth.py:認證相關的 endpoint。"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import session_scope
from app.models import User, UserRole
from app.security import create_access_token, hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
password_confirm: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=80)
timezone: str = Field(default="Asia/Taipei")
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
role: UserRole
@router.post("/register", response_model=TokenResponse)
def register(payload: RegisterRequest) -> TokenResponse:
if payload.password != payload.password_confirm:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="密碼與確認不一致")
with session_scope() as session:
existing = session.execute(select(User).where(User.email == payload.email)).scalar_one_or_none()
if existing is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail="email 已被使用")
user = User(
email=payload.email,
password_hash=hash_password(payload.password),
display_name=payload.display_name,
role=UserRole.customer,
timezone=payload.timezone,
)
session.add(user)
session.flush()
token = create_access_token(user.id, user.role.value)
return TokenResponse(access_token=token, user_id=user.id, role=user.role)
註冊端點幾個關鍵設計:EmailStr 自動驗證 email 格式;密碼長度 8 到 128 是 Pydantic Field 限制;password 與 password_confirm 在 endpoint 內檢查一致;email 重複時回 409 Conflict 而不是 400,這是 REST 的標準做法。新註冊的使用者一律給 customer 角色,避免有人「透過註冊變成 admin」這條安全漏洞。如果要升級成 admin,必須由現有 admin 在後台手動改(Day 39 的後台介面會做)。
登入 endpoint
登入流程:收 email 與 password、用 email 查使用者、verify_password 檢查密碼、發 token。為了避免「email 存在與否」被攻擊者用 timing attack 探測出來,我們在查不到使用者時也跑一次 verify_password(傳一個 dummy hash)。timing attack 的原理是「成功的 query 比失敗的快幾毫秒」,攻擊者測量回應時間就能反推使用者是否存在。實務上 argon2 的 verify 時間通常 50 到 100 毫秒,差異不容易被探測,但加 dummy verify 是廉價的雙重保險。
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest) -> TokenResponse:
with session_scope() as session:
user = session.execute(select(User).where(User.email == payload.email)).scalar_one_or_none()
if user is None or not verify_password(payload.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="帳號或密碼錯誤")
if user.deleted_at is not None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="帳號已停用")
token = create_access_token(user.id, user.role.value)
return TokenResponse(access_token=token, user_id=user.id, role=user.role)
這個 login endpoint 把失敗訊息統一寫成「帳號或密碼錯誤」,不區分「帳號不存在」與「密碼錯誤」兩種情況,這是業界標準做法。timing attack 的部分因為 verify_password 內部已經有恆定時間比較,但「查使用者」這一步仍有可能洩漏差異,所以更高安全層級的系統會在 user is None 時也跑一次 verify_password。實務上對 side project 來說目前的寫法已經足夠;對金融等級系統要再強化。
current_user 與角色檢查
FastAPI 的 Depends 系統讓我們把「取得當前使用者」與「檢查角色」做成可重用的 dependency。下面這幾個 dependency 後面所有 endpoint 都會用到。Depends 的強大之處在於「dependency 可以巢狀組合」:require_admin 內部呼叫 get_current_user,這樣 endpoint 只要掛 require_admin 就會自動跑完整條鏈——先 OAuth2 解 token、再查 DB、最後檢查角色。這種「宣告式權限」是 FastAPI 在大型專案最受歡迎的設計之一。
"""app/deps.py:共用 dependency。"""
import os
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import session_scope
from app.models import User, UserRole
from app.security import decode_access_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_session() -> Session:
with session_scope() as session:
yield session
def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
try:
payload = decode_access_token(token)
except jwt.PyJWTError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=f"token 無效:{exc}")
user_id = payload["sub"]
user = session.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if user is None or user.deleted_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="使用者不存在或已停用")
return user
def require_admin(current: User = Depends(get_current_user)) -> User:
if current.role is not UserRole.admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="需要管理員權限")
return current
def require_customer(current: User = Depends(get_current_user)) -> User:
if current.role is not UserRole.customer:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="此 endpoint 僅限客戶使用")
return current
這份 dependency 有幾個關鍵設計:OAuth2PasswordBearer 讓 Swagger UI 自動產生「Authorize」按鈕;get_current_user 解 token、查 DB、檢查軟刪除;require_admin 與 require_customer 是兩個角色限制器,可以直接掛在 endpoint 上。`get_session` 用 generator 寫法配合 FastAPI 的 dependency 系統,session 在 request 結束時自動關閉。
注意 OAuth2PasswordBearer 的 `tokenUrl` 必須對應實際的 login endpoint URL(相對路徑或絕對路徑皆可)。這讓 Swagger UI 可以從 UI 直接拿到 token 來測試後續受保護的 endpoint,對開發體驗是大幅加分。
用 FastAPI Depends 把角色掛進 endpoint
有了 dependency 之後,endpoint 寫起來非常乾淨。下面是 Day 35 預約管理系統裡三種典型 endpoint:公開(看服務清單)、customer-only(下單)、admin-only(管理服務)。這種宣告式的寫法讓程式碼的可讀性大幅提升——讀 endpoint 第一行就能知道「這個 API 需要什麼身份」——同時也避免了「在某個深處 if 判斷 role」的隱藏風險。
"""app/routers/services.py:服務的讀寫 endpoint。"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.deps import get_session, require_admin
from app.models import Service, User
router = APIRouter(prefix="/services", tags=["services"])
@router.get("/")
def list_active_services(session: Session = Depends(get_session)) -> list[Service]:
stmt = select(Service).where(Service.is_active.is_(True), Service.deleted_at.is_(None))
return list(session.execute(stmt).scalars())
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_service(
payload: dict,
session: Session = Depends(get_session),
admin: User = Depends(require_admin),
) -> Service:
service = Service(**payload)
session.add(service)
session.flush()
return service
@router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service(
service_id: str,
session: Session = Depends(get_session),
admin: User = Depends(require_admin),
) -> None:
service = session.get(Service, service_id)
if service is None or service.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
service.deleted_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
session.flush()
這個 router 展示了三種 endpoint 的標準寫法:list_active_services 沒有掛任何角色 dependency,匿名使用者就能打;create_service 與 delete_service 都掛了 require_admin,未登入或非 admin 會直接 403 Forbidden。這種「宣告式權限」的寫法比在 endpoint 內部用 if 判斷更清楚、更不容易出錯。對大型專案還可以再加 `require_role(UserRole.admin, UserRole.staff)` 支援多重角色,今天先用簡單版本。
測試認證流程
認證這塊一定要寫整合測試,否則很容易在「改了 token 簽發邏輯但忘了同步更新測試」這種小地方出 bug。下面這組測試覆蓋註冊、登入、current_user、角色限制四個關鍵路徑。認證是系統的門檻,bug 會直接影響所有後續 endpoint,所以這層測試的覆蓋率應該盡量高,每一個錯誤路徑(過期 token、無效 token、停用帳號、權限不足)都該有對應的測試案例。
"""tests/test_auth.py:認證 endpoint 的整合測試。"""
import os
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture(autouse=True)
def env(monkeypatch):
monkeypatch.setenv("JWT_SECRET", "test-secret-32-bytes-long-aaaaaaaaaa")
monkeypatch.setenv("DATABASE_URL", "sqlite:///./test-auth.db")
@pytest.fixture
def client():
return TestClient(app)
def test_register_login_flow(client):
payload = {
"email": "alice@example.com",
"password": "alice-strong-pw",
"password_confirm": "alice-strong-pw",
"display_name": "Alice",
}
res = client.post("/auth/register", json=payload)
assert res.status_code == 200, res.text
body = res.json()
assert body["role"] == "customer"
token = body["access_token"]
res2 = client.post("/auth/login", json={"email": payload["email"], "password": payload["password"]})
assert res2.status_code == 200
assert res2.json()["access_token"]
def test_protected_endpoint_requires_token(client):
res = client.get("/services/")
assert res.status_code in (401, 403)
def test_admin_only_blocks_customer(client):
payload = {
"email": "bob@example.com",
"password": "bob-strong-pw",
"password_confirm": "bob-strong-pw",
"display_name": "Bob",
}
token = client.post("/auth/register", json=payload).json()["access_token"]
res = client.post(
"/services/",
headers={"Authorization": f"Bearer {token}"},
json={"name": "test", "duration_minutes": 30, "price_cents": 1000},
)
assert res.status_code == 403
這組測試用了 FastAPI 的 TestClient,這是 FastAPI 內建的測試工具(基於 httpx),可以在不啟動實際伺服器的狀況下跑 endpoint。`monkeypatch.setenv` 設定測試用的 JWT_SECRET,避免影響正式環境。SQLite 用 test-auth.db 與正式 db.db 隔離,避免測試污染開發資料。對 pytest 的 fixture 系統熟悉之後,這層測試組合可以延伸到所有 endpoint,把回歸測試時間壓在 30 秒內。
更新 admin 帳號密碼
Day 35 的 seed 腳本把 admin 的 password_hash 寫成 placeholder,正式環境必須更新。我們寫一支小工具,讀取環境變數裡的 admin 密碼、用 hash_password 雜湊、寫回資料庫。
"""scripts/set_admin_password.py:把 admin@example.com 的密碼更新成環境變數指定的值。"""
import os
import sys
from sqlalchemy import select
from app.db import session_scope
from app.models import User, UserRole
from app.security import hash_password
def main() -> int:
new_password = os.environ.get("ADMIN_PASSWORD")
if not new_password:
print("請設定 ADMIN_PASSWORD 環境變數", file=sys.stderr)
return 1
with session_scope() as session:
admin = session.execute(select(User).where(User.role == UserRole.admin)).scalar_one_or_none()
if admin is None:
print("找不到 admin 帳號,請先跑 seed 腳本", file=sys.stderr)
return 1
admin.password_hash = hash_password(new_password)
print(f"已更新 {admin.email} 的密碼")
return 0
if __name__ == "__main__":
sys.exit(main())
這支腳本用 ADMIN_PASSWORD 環境變數傳新密碼、用 hash_password 雜湊、用 session_scope 包成 transaction。實務上會在 Day 41 一鍵部署流程的 entrypoint 跑一次,確保正式環境的 admin 密碼是當下設定的,不是 placeholder 或 seed 預設值。對 side project 來說也推薦在第一次部署後立刻跑一次,避免忘記改密碼造成資安漏洞。
沒有 Docker 時的替代流程
今天的程式碼完全可以在本機用 SQLite + venv 跑:
uv venv
source .venv/bin/activate
uv pip install -e ".[dev]"
export DATABASE_URL="sqlite:///./booking.db"
export JWT_SECRET="$(openssl rand -hex 32)"
alembic upgrade head
python scripts/seed.py
ADMIN_PASSWORD="my-strong-pw" python scripts/set_admin_password.py
uvicorn app.main:app --reload
打開 http://127.0.0.1:8000/docs 可以看到 Swagger UI,先用 admin@example.com 與剛設定的密碼登入拿 token,之後就能用 Authorize 按鈕測試所有 endpoint。SQLite 模式要注意 enum 在 SQLite 下會被當成 VARCHAR 儲存,欄位值仍是字串但沒有 DB 層的型別保護,正式上線前還是要用 Day 31 的 docker compose 跑一次。
refresh token:延長登入狀態
access token 12 小時到期後,使用者必須重新登入。對常常來回的客戶來說這個體驗不好。我們加一個 refresh token 機制:登入時同時發 access token(短)與 refresh token(長),access token 過期後用 refresh token 換新 token。
"""app/security.py 延伸:refresh token 簽發與驗證。"""
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
REFRESH_EXPIRE_DAYS = 14
def create_refresh_token(subject: str) -> str:
now = datetime.now(timezone.utc)
expire = now + timedelta(days=REFRESH_EXPIRE_DAYS)
payload = {"sub": subject, "type": "refresh", "iat": int(now.timestamp()), "exp": int(expire.timestamp())}
secret = __import__("os").environ["JWT_SECRET"]
return jwt.encode(payload, secret, algorithm="HS256")
def verify_refresh_token(token: str) -> str:
secret = __import__("os").environ["JWT_SECRET"]
payload = jwt.decode(token, secret, algorithms=["HS256"])
if payload.get("type") != "refresh":
raise jwt.InvalidTokenError("token 不是 refresh token")
return payload["sub"]
@router.post("/refresh", response_model=TokenResponse)
def refresh(payload: dict) -> TokenResponse:
refresh_token = payload["refresh_token"]
user_id = verify_refresh_token(refresh_token)
with session_scope() as session:
user = session.get(User, user_id)
if user is None or user.deleted_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="使用者不存在")
new_access = create_access_token(user.id, user.role.value)
new_refresh = create_refresh_token(user.id)
return TokenResponse(
access_token=new_access,
token_type="bearer",
user_id=user.id,
role=user.role,
)
refresh token 與 access token 用同一個 secret 簽發,但用 type 欄位區分用途——這樣 client 拿到 token 後就可以靠 type 判斷怎麼用。refresh token 的 14 天有效期讓客戶可以兩週不重新登入,又不會因為太長而難以撤銷。如果使用者「忘記登出」或 admin 強制停用帳號,因為 token 內含 sub 可以被 server 拒絕(user.deleted_at is not None),撤銷機制仍然有效。
實務上 refresh token 要存進 httpOnly cookie 或 secure storage,避免被 XSS 偷走。我們這系列後端只負責發 token,前端怎麼存是 Day 27 的議題。今天先示範後端邏輯,前端串接之後可以做到「無感登入」。
常見錯誤與踩雷
JWT_SECRET 寫死在程式碼。我們的 helper 用 `os.environ["JWT_SECRET"]` 讀取,但很多教學會示範把 secret 直接寫死(例如 `"my-secret"`),這在開發階段沒問題,但一旦推到正式環境就是大漏洞。務必用 `openssl rand -hex 32` 產生一串隨機字串,並用 secret manager(Vault、AWS Secrets Manager)儲存。另一個常見錯誤是把 secret 寫進 .env.example,導致團隊成員以為那是範例值就直接用——.env.example 應該留空或放明顯的 placeholder。
token 過期時間設太長。預設的 JWT_EXPIRE_HOURS = 12 對客服人員已經很長;如果你的應用涉及敏感操作(轉帳、刪除個資),應該縮短到 1 小時並搭配 refresh token。Day 12 學過 refresh token 的概念,今天先用 access token 就好,refresh token 的實作可以在後續需要時再加。
忘記在 exception handler 把 JWT 例外轉 401。decode_access_token 會拋 jwt.PyJWTError,但我們在 get_current_user 用 try/except 把它轉成 HTTPException 401。如果忘記這個轉換,FastAPI 會回 500 內部錯誤而不是 401,使用者看到的訊息會很難懂。建議把所有 JWT 例外都集中在 dependency 層處理,endpoint 內不要再 try/except。
role 寫進 token 又即時從 DB 讀。我們的 token 把 role 放進 payload(避免每次都查 DB),但這代表「使用者升級成 admin 之後,要重發 token 才會生效」。解法有兩種:每次 request 都從 DB 讀 role(犧牲效能換正確性)、或者讓前端升級後主動呼叫 /auth/refresh 換新 token。今天選後者:給前端一個簡單的 refresh endpoint,升級後前端呼叫一次就能拿到新 token。對 side project 來說這已經夠用;對高安全性系統則要每次都查 DB。
效能與實務提醒
argon2id 的參數(m=65536, t=3, p=4)對正式環境合適,但在 CI 上會拖慢測試。我們可以在測試環境用較弱的參數(m=8192, t=2, p=1)讓註冊測試 0.5 秒內跑完,正式環境則用預設的強參數。passlib 的 CryptContext 支援環境變數調整,未來可以用 `os.environ.get("ARGON2_PARAMS")` 動態切換。
get_current_user 每次 request 都會查一次 DB,這對高 QPS 服務會是瓶頸。可以加一層 in-memory cache(用 cachetools 或 functools.lru_cache),key 是 user_id,TTL 設 30 秒。這層快取對 side project 不必要,但對中型應用就能明顯減少 DB load。實務上要注意:cache 期間使用者升級或停用的狀態不會即時反映,要嘛把 TTL 縮短到 30 秒內、要嘛在寫入時主動失效 cache。
token 不要放進 URL(query string),一定要放 Authorization header。URL 會被記進 nginx access log、瀏覽器歷史紀錄、CDN log 等多處,等於把 token 公開。Authorization header 至少不會被快取、被記錄的機率也低很多。如果一定要用 cookie 傳 token(Day 27 的前端整合會示範),務必設 httpOnly、Secure、SameSite=Strict,這三個 flag 是現代瀏覽器對 cookie 的基本保護。
refresh token 的儲存成本比 access token 高,因為它有效期限長(14 天)、被偷走的風險也大。實務上可以把 refresh token 的雜湊值存進 DB,client 端只持有明文;當使用者「登出」時把 DB 那筆刪掉,即使 client 端還留著 refresh token 也無法再用。這是「refresh token rotation」機制,業界主流做法。我們今天先用明文版本,rotation 機制可以在後續需要時加上。
小結
今天把 Day 11 到 Day 15 的認證觀念組合成實際可用的認證系統:argon2id 密碼雜湊、JWT 簽發與驗證、註冊/登入 endpoint、current_user dependency、角色限制 dependency、refresh token 機制。我們也寫了整合測試確保整條流程跑得通,並補上 admin 密碼更新工具。共用設定(UUID 主鍵、UTC 時間、軟刪除)繼續沿用 Day 35 的設計。後續 Day 37 會在 current_user 之上實作衝突檢查、Day 38 會用 require_customer 限制下單角色、Day 39 的後台介面會用 require_admin 鎖住管理頁面。今天的程式碼量看起來不少,但每一段都會在後續章節反覆出現,把這層基礎蓋好,後續每天的進度都會更順。Day 41 一鍵部署時,這套認證也會跟著容器化上線,記得在 entrypoint 用 ADMIN_PASSWORD 環境變數設定正式環境的管理員密碼。
結語
認證是後端系統的門檻,蓋好之後所有後續功能都能共用。今天寫的 dependency 與 token 機制會沿用整個貫穿專案。明天,我們會進入預約流程的核心:在 Day 35 的 Booking table 與 Day 36 的 current_user 之上,做出「同一時段不能被兩個人預約」的衝突檢查,這是預約管理系統最關鍵的業務邏輯。我們會用區間重疊演算法確保兩個 booking 不會撞時間,並用 transaction 確保 race condition 下也只有一個能成功。
延伸資源
- passlib argon2 文件:
https://passlib.readthedocs.io/en/stable/lib/passlib.hash.argon2.html - PyJWT 官方文件:
https://pyjwt.readthedocs.io/en/stable/ - FastAPI 安全性與 OAuth2 教學:
https://fastapi.tiangolo.com/tutorial/security/ - OWASP 密碼儲存建議:
https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - argon2 參數選擇指南:
https://github.com/P-H-C/phc-winner-argon2
留言
張貼留言