跳到主要內容

Web Day 36 專案:認證與多角色

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

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...