本文 AI 產出,尚未審核

FastAPI 課程 – 安全性單元

主題:密碼雜湊(bcrypt、passlib)


簡介

在 Web 應用程式中,使用者密碼的安全存放是最基本也是最重要的防線。即使資料庫被入侵,若密碼是以明文或弱雜湊方式保存,攻擊者可以輕易還原出原始密碼,進一步造成帳號竊取、身份冒用等嚴重後果。

FastAPI 作為一個現代化的非同步 API 框架,內建支援多種安全機制,其中密碼雜湊是最常見的需求。本文將以 bcrypt 為例,說明如何透過 passlib 這個高階的雜湊函式庫,安全、簡潔地在 FastAPI 中完成密碼的加密、驗證與管理,適合剛接觸 FastAPI 的初學者,也能提供給中階開發者作為最佳實踐的參考。


核心概念

1. 為什麼選擇 bcrypt?

  • 計算成本可調:bcrypt 內建「工作因子」(cost factor),可以隨硬體提升而調高,使暴力破解成本隨之上升。
  • 內建鹽值 (salt):每一次雜湊都會自動產生唯一的鹽值,避免相同密碼產生相同雜湊。
  • 抗 GPU 暴力:相較於 MD5、SHA1 等快速雜湊演算法,bcrypt 設計上較不適合平行化運算,減少 GPU 暴力破解的威脅。

2. passlib 的角色

passlib 是 Python 生態系中最完整的密碼雜湊套件,提供:

  • 統一的 API(hash, verify, needs_update
  • 多種演算法的支援(bcrypt、argon2、pbkdf2 等)
  • 版本管理與自動升級檢測
  • 兼容舊有雜湊格式的遷移工具

在 FastAPI 中使用 passlib,可以把雜湊邏輯抽離成獨立的 服務層,保持路由 (router) 的簡潔與可測試性。

3. 基本流程

  1. 註冊:使用者提供明文密碼 → passlib 產生 bcrypt 雜湊 → 儲存雜湊於資料庫。
  2. 登入:使用者提交密碼 → 取出對應的雜湊 → passlib.verify 比對,成功則回傳 JWT 或 Session。
  3. 升級雜湊:若演算法或工作因子變更,needs_update 可偵測舊雜湊,需要時重新雜湊並更新資料庫。

程式碼範例

3.1 安裝相依套件

pip install fastapi[all] passlib[bcrypt] python-multipart

passlib[bcrypt] 會同時安裝 bcrypt 套件,確保雜湊運算可用。

3.2 建立 PasswordHasher

# utils/security.py
from passlib.context import CryptContext

# 設定 bcrypt,rounds=12 是常見的安全預設值
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)

def hash_password(password: str) -> str:
    """
    將明文密碼雜湊成 bcrypt 字串。
    """
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    比對明文密碼與雜湊值,回傳 True/False。
    """
    return pwd_context.verify(plain_password, hashed_password)

def needs_rehash(hashed_password: str) -> bool:
    """
    判斷現有雜湊是否需要升級(例如 rounds 提升)。
    """
    return pwd_context.needs_update(hashed_password)

3.3 FastAPI 註冊與登入路由

# main.py
from fastapi import FastAPI, HTTPException, Depends, status
from pydantic import BaseModel, EmailStr
from utils.security import hash_password, verify_password, needs_rehash
from typing import Dict

app = FastAPI()

# 模擬資料庫(實務上請使用 ORM)
fake_user_db: Dict[str, Dict] = {}

class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserLogin(BaseModel):
    email: EmailStr
    password: str

@app.post("/register", status_code=status.HTTP_201_CREATED)
def register(user: UserCreate):
    if user.email in fake_user_db:
        raise HTTPException(status_code=400, detail="Email already registered")
    hashed = hash_password(user.password)
    fake_user_db[user.email] = {"email": user.email, "hashed_password": hashed}
    return {"msg": "User created successfully"}

@app.post("/login")
def login(user: UserLogin):
    db_user = fake_user_db.get(user.email)
    if not db_user:
        raise HTTPException(status_code=400, detail="Invalid credentials")
    if not verify_password(user.password, db_user["hashed_password"]):
        raise HTTPException(status_code=400, detail="Invalid credentials")
    # 若雜湊過時,重新雜湊並寫回資料庫
    if needs_rehash(db_user["hashed_password"]):
        db_user["hashed_password"] = hash_password(user.password)
    # 這裡示範回傳簡易 token,實務上建議使用 JWT
    return {"access_token": "fake-token", "token_type": "bearer"}

重點說明

  • hash_password 只在註冊或雜湊升級時呼叫,永遠不要在驗證時重新雜湊
  • needs_rehash 讓我們在 演算法升級 時自動更新使用者的雜湊,無需強迫使用者更改密碼。

3.4 使用不同演算法的設定(進階)

有時候會需要同時支援 argon2pbkdf2_sha256,只要在 CryptContext 中列出即可:

# utils/security.py (進階版)
pwd_context = CryptContext(
    schemes=["argon2", "bcrypt"],
    deprecated="auto",
    # argon2 參數範例
    argon2__time_cost=2,
    argon2__memory_cost=102400,
    argon2__parallelism=8,
    # bcrypt 預設 12 rounds
    bcrypt__rounds=12,
)

# 呼叫方式保持不變

3.5 單元測試範例

# tests/test_security.py
from utils.security import hash_password, verify_password, needs_rehash

def test_hash_and_verify():
    pwd = "StrongP@ssw0rd!"
    hashed = hash_password(pwd)
    assert verify_password(pwd, hashed) is True
    assert verify_password("WrongPwd", hashed) is False

def test_needs_rehash():
    # 使用較低的 rounds 產生舊雜湊
    low_context = CryptContext(schemes=["bcrypt"], bcrypt__rounds=8)
    old_hash = low_context.hash("test")
    # 以目前的 12 rounds 判斷應該需要升級
    assert needs_rehash(old_hash) is True

常見陷阱與最佳實踐

陷阱 可能的後果 解決方案
直接儲存明文密碼 資料外洩即失去所有使用者信任 必須 使用 passlib 產生雜湊後再寫入資料庫
忽略鹽值 (salt) 的概念 相同密碼產生相同雜湊,易被彩虹表破解 bcrypt 內建鹽值,不要自行 生成或重用鹽
雜湊成本過低 (rounds 太小) 暴力破解速度過快 建議 bcrypt__rounds >= 12,根據硬體每年調整一次
在驗證時使用 hash 重建雜湊 每次驗證都產生不同雜湊,導致驗證失敗 使用 pwd_context.verify不要hash 再比對
未檢查雜湊升級需求 老舊雜湊仍被使用,安全性下降 登入成功後呼叫 needs_rehash,必要時重新雜湊並寫回
把雜湊寫入日誌或回傳給前端 泄漏雜湊資訊,給予攻擊者線索 永遠 只回傳成功/失敗訊息,絕不回傳雜湊字串

最佳實踐

  1. 統一管理:將所有雜湊相關函式封裝於 utils/security.py,保持路由層乾淨。
  2. 環境變數:將工作因子 (bcrypt__rounds) 以環境變數形式設定,方便在不同環境調整。
  3. 例外處理:使用 try/except 捕捉 passlib.exc.UnknownHashError,防止惡意提交不合法雜湊。
  4. 密碼政策:在前端加強密碼複雜度檢查,後端再以 pydantic 驗證最小長度與字元類型。
  5. 定期審計:使用工具(如 sqlmapbandit)掃描資料庫與程式碼,確保沒有明文密碼或硬編碼的鹽值。

實際應用場景

  1. 使用者註冊與登入

    • 透過上述範例,完成最基本的註冊、登入流程,配合 JWT 實作完整的認證系統。
  2. 第三方 OAuth 與本地帳號混合

    • 即使使用 Google、GitHub 登入,也建議為每個使用者產生本地的「虛擬密碼」或「一次性密碼」雜湊,以便未來支援本地密碼變更。
  3. 密碼重設流程

    • 產生一次性 token(如 JWT)寄送至使用者 email,使用者點擊連結後提供新密碼,再以 hash_password 更新雜湊。
  4. 多租戶 SaaS 系統

    • 每個租戶的使用者資料庫分表或加上租戶 ID 作為額外的「pepper」(系統全局的鹽值) 再雜湊,提高跨租戶的安全隔離。
  5. API 金鑰與服務帳號

    • 雖然不是使用者密碼,但同樣可以使用 passlib 產生雜湊的 API 金鑰,避免金鑰以明文存於資料庫。

總結

  • 密碼雜湊是防止資料外洩的第一道防線,選擇 bcrypt 搭配 passlib 能提供可調整的計算成本與自動鹽值管理。
  • 透過 CryptContext 的統一介面,我們可以輕鬆在 FastAPI 中完成 註冊、登入、雜湊升級 等全流程。
  • 避免常見的陷阱(明文儲存、錯誤的驗證方式、過低的工作因子),並遵循 最佳實踐(環境變數、例外處理、密碼政策)即可打造安全且可維護的認證系統。
  • 本文提供的範例與架構可以直接套用於實務專案,無論是簡單的單機應用,或是大型的多租戶 SaaS,都能確保使用者密碼的安全性。

祝開發順利,讓你的 FastAPI 應用在 安全效能 之間取得最佳平衡! 🚀