Web Day 11 密碼雜湊與註冊流程
執行需求:CPU 可跑。昨天我們把 API 的錯誤回應統一成結構化的 JSON,今天進入安全主題的第一篇:密碼處理與註冊流程。你會學到為什麼不能直接儲存明文密碼、bcrypt 與 argon2 的差異、passlib 在 2024 年之後的維護狀態、以及如何用 argon2-cffi 把密碼正確地雜湊並驗證;接著設計一支「註冊端點」,並把昨天設計的 ConflictError 與 ValidationFailedError 實際接上,整個註冊流程會成為後續認證(Web Day 12、13)的基礎。
引言
寫後端系統時,「使用者密碼怎麼存」是資安設計的第一道關卡。常見的錯誤做法有三種:第一,直接把明文密碼寫進資料庫,資料庫被入侵時所有帳號直接被盜用;第二,用 SHA-256 之類的快速雜湊函式,但這類函式對專門設計的「彩虹表」與 GPU 暴力破解毫無抵抗力,攻擊者只要拿到雜湊值就能在合理時間內反推原文;第三,用單一固定的鹽(salt),等於把整個系統的密碼安全降級到「最弱那一個密碼的水準」。這三種做法每天都出現在新手作品裡,也是資安新聞裡「XX 網站個資外洩」的常見原因之一。
今天的目標是建立一套「密碼處理的標準流程」。讀完之後,你會了解正確的密碼雜湊應該具備哪些特性(慢、鹽、動態成本、可調參數),知道 bcrypt 與 argon2 的差異與選用建議,並能用 FastAPI 寫出一支「註冊端點」,把電子郵件格式檢查、密碼強度檢查、雜湊儲存、重複註冊錯誤統一處理。這支註冊端點會在 Web Day 12 與登入端點銜接,組成完整的認證流程。
密碼雜湊的關鍵特性
密碼雜湊跟一般雜湊(例如檔案 checksum、訊息認證碼)完全不同。後者的設計目標是「算得快、抗碰撞」,因為它們要處理大量資料或即時通訊;前者反過來,要「算得慢、不可並行化」,因為這是它唯一能抵禦暴力破解的方式。一個好的密碼雜湊演算法必須具備四個特性:
第一,「慢」。這聽起來違反工程師直覺,但密碼雜湊就是要慢。bcrypt 預設一輪需要幾十毫秒到一百多毫秒;攻擊者每試一個密碼都要等這麼久,硬體再快也無法把暴力破解時間壓到合理範圍。第二,「自動加鹽」。每次雜湊時自動產生隨機鹽,鹽值與雜湊結果一起儲存。這樣即使兩位使用者選了同一個密碼,雜湊結果也不同,無法用單一彩虹表一次破解所有帳號。第三,「成本可調」。隨著硬體變快,可以提高成本參數(例如 bcrypt 的 rounds 或 argon2 的 time/memory cost),讓演算法在十年後仍然安全。第四,「抗並行化」。演算法本身不能單純靠「加核心數」就變快,這正是 GPU 與 ASIC 攻擊的核心防線。
bcrypt 從 1999 年發表至今仍是廣泛使用的選擇,幾乎所有語言都有實作。它的 rounds 參數控制成本,每加 1 輪計算量大約變為 2 倍;2025 年的硬體,建議至少 12 輪(約 250 毫秒)。argon2 是 2015 年的 PHC(Password Hashing Competition)冠軍,它比 bcrypt 多了一個「記憶體成本」參數,可以強迫攻擊者使用大量記憶體,進一步抵禦 GPU/ASIC 攻擊。argon2 有三個變體:argon2d(資料並行,抗 GPU 攻擊弱)、argon2i(對側通道攻擊有抵抗力)、argon2id(混合,推薦使用)。
那要選哪一個?2025 年的主流建議是:argon2id 優先,bcrypt 次之。理由是 argon2 對 GPU 攻擊有更強的抵抗力,而且參數更彈性(可以獨立調整時間與記憶體)。但 bcrypt 也不是過時,社群成熟、實作品多、攻擊研究也最完整,新專案選哪一個都合理。重點是「不要自己發明、不要用 MD5/SHA-1/SHA-256」。
passlib 與 argon2-cffi 的取捨
Python 社群最常用的密碼雜湊函式庫有兩個:passlib 與 argon2-cffi。passlib 是個高階介面,支援多種演算法(bcrypt、argon2、scrypt、pbkdf2 等),切換演算法只要改設定;argon2-cffi 則是 argon2 演算法的 Python 綁定,介面較低階,但對 argon2 的支援最完整。
2025 年中前後,passlib 1.7.4 之後就沒有正式釋出,部分社群討論它在某些 Python 版本上會出現維護問題(這是 2024–2025 年期間社群廣泛討論的議題)。對新專案而言,主流建議是「直接用 argon2-cffi 處理 argon2id,或用 bcrypt 套件處理 bcrypt」;保留 passlib 當作介面層則是可以做、但要留意維護狀態。今天我們選 argon2-cffi,因為它是 argon2id 的官方 Python 綁定,由 PyCA 維護,2025 年 7 月仍在活躍更新。
安裝(命令列):
# 安裝 argon2-cffi
# 使用 uv
uv add "argon2-cffi==23.1.0"
# 或使用 pip
pip install "argon2-cffi==23.1.0"
用 argon2-cffi 雜湊與驗證密碼
argon2-cffi 提供兩個核心 API:PasswordHasher(用來產生與驗證雜湊)與 hash_password / verify 函式。實務上 PasswordHasher 比較好用,因為它可以一次管理多個參數(時間成本、記憶體成本、平行度),還能自動處理「舊參數升級」的情境。先寫一支 app/security.py:
# app/security.py
# 密碼雜湊與驗證工具
from argon2 import PasswordHasher
from argon2.exceptions import (
InvalidHashError,
VerifyMismatchError,
)
# PasswordHasher 的參數選用 argon2id 的社群建議值
# time_cost:每輪時間成本
# memory_cost:每輪記憶體成本(KB)
# parallelism:平行度
# hash_len:雜湊長度
# salt_len:鹽長度
ph = PasswordHasher(
time_cost=3,
memory_cost=64 * 1024, # 64 MB
parallelism=4,
hash_len=32,
salt_len=16,
)
def hash_password(plain: str) -> str:
# 把明文密碼雜湊成可儲存的字串
# 回傳值包含演算法、參數、鹽、雜湊值,以 $ 分隔
return ph.hash(plain)
def verify_password(stored_hash: str, plain: str) -> bool:
# 驗證明文密碼是否符合已儲存的雜湊
try:
ph.verify(stored_hash, plain)
return True
except (VerifyMismatchError, InvalidHashError):
return False
def needs_rehash(stored_hash: str) -> bool:
# 檢查現有雜湊是否用了舊參數(需要重新雜湊)
return ph.check_needs_rehash(stored_hash)
這段有幾個重點:PasswordHasher 的參數選擇參考 argon2 官方文件 2025 年版的建議值:time_cost=3、memory_cost=64MB、parallelism=4,對一般伺服器單次運算約 100 毫秒。回傳的雜湊字串已經把演算法、參數、鹽、雜湊值都打包進去(例如 $argon2id$v=19$m=65536,t=3,p=4$...),所以資料庫只需要存一個字串欄位,不需要分開存鹽與參數。verify_password 把例外轉成布林值,方便端點使用。needs_rehash 則是「參數升級」機制:當我們日後把 time_cost 調高,可以用這個函式在使用者登入時順便把舊雜湊換成新參數的雜湊。
驗證一下這支工具是否正常運作:
# test_security.py
from app.security import (
hash_password,
verify_password,
needs_rehash,
)
# 雜湊一個密碼
hashed = hash_password("MyP@ssw0rd-2025")
print(hashed)
# 輸出(範例):
# $argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHRzYWx0$
# (實際的鹽與雜湊值每次執行都不同,這裡僅示意格式)
# 驗證正確密碼
print(verify_password(hashed, "MyP@ssw0rd-2025"))
# 輸出:True
# 驗證錯誤密碼
print(verify_password(hashed, "wrong-password"))
# 輸出:False
# 檢查是否需要重新雜湊
print(needs_rehash(hashed))
# 輸出:False
注意每次雜湊的結果都不同(鹽隨機),這正是我們要的特性。同一個密碼「MyP@ssw0rd-2025」被雜湊 100 次會得到 100 個不同的雜湊值,全部都能驗證成功,但攻擊者無法從兩個雜湊值反推「它們是同一個密碼」。
使用者模型:把密碼欄位加進 SQLModel
接下來把使用者模型擴充。先建立 app/models.py,加入 User 這張表:
# app/models.py
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
# 使用者表:儲存帳號與雜湊後的密碼
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
# 雜湊後的密碼;永遠不存明文
password_hash: str
# 是否啟用(停用帳號時設為 False,仍可保留資料)
is_active: bool = Field(default=True)
# 建立時間
created_at: datetime = Field(default_factory=datetime.utcnow)
這個模型只存 password_hash,絕對不存明文。即使資料庫被入侵,攻擊者拿到的也只是 argon2id 的雜湊值,破解成本極高(每個密碼的單次嘗試成本就超過 100 毫秒)。is_active 欄位預留給「停用帳號」用,未來如果需要「軟刪除」或「管理者封鎖」功能,不必改 schema。created_at 則在後續做「30 天未登入提醒」之類功能時會用到。
註冊端點:完整實作
現在寫一支註冊端點,把上述東西串起來。建立 app/schemas.py 定義請求與回應的 Pydantic 模型:
# app/schemas.py
import re
from pydantic import BaseModel, EmailStr, field_validator
class RegisterRequest(BaseModel):
email: EmailStr
password: str
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
# 密碼長度至少 8 字元
if len(v) < 8:
raise ValueError("密碼長度至少需要 8 字元")
# 密碼必須同時包含字母與數字
if not re.search(r"[A-Za-z]", v):
raise ValueError("密碼必須包含至少一個英文字母")
if not re.search(r"\d", v):
raise ValueError("密碼必須包含至少一個數字")
return v
class RegisterResponse(BaseModel):
id: int
email: str
created_at: datetime
EmailStr 是 Pydantic v2 內建的電子郵件驗證型別,會檢查是否符合 RFC 5322 的基本格式(需要額外安裝 email-validator 套件)。field_validator 用來自訂密碼強度規則:至少 8 字元、含英文字母、含數字。這套規則算是「最低限度」,真實世界通常會要求大小寫、特殊字元、長度更長,但太嚴格會逼使用者把密碼寫在便利貼上,反而更危險。實務上 NIST SP 800-63B 建議「至少 8 字元、不強制特殊字元、檢查是否為已知弱密碼」。
接下來是端點本身:
# app/routers/auth.py
from datetime import datetime
from fastapi import APIRouter, Depends, status
from sqlmodel import Session, select
from app.db import get_session
from app.errors import ConflictError, ValidationFailedError
from app.models import User
from app.schemas import RegisterRequest, RegisterResponse
from app.security import hash_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post(
"/register",
response_model=RegisterResponse,
status_code=status.HTTP_201_CREATED,
)
def register(
payload: RegisterRequest,
session: Session = Depends(get_session),
) -> RegisterResponse:
# 1. 檢查電子郵件是否已被註冊
existing = session.exec(
select(User).where(User.email == payload.email)
).first()
if existing is not None:
raise ConflictError(
message="這個電子郵件已經註冊過了",
details={"email": payload.email},
)
# 2. 雜湊密碼(這一步才是真正的資安防線)
password_hash = hash_password(payload.password)
# 3. 建立使用者
user = User(
email=payload.email,
password_hash=password_hash,
created_at=datetime.utcnow(),
)
session.add(user)
session.commit()
session.refresh(user)
return RegisterResponse(
id=user.id,
email=user.email,
created_at=user.created_at,
)
這支端點的流程是:先檢查電子郵件是否已被使用(避免唯一鍵衝突在 commit 後才爆炸),雜湊密碼,建立使用者並 commit。注意雜湊發生在使用者物件被加入 session 之前,這確保即使 commit 失敗,雜湊值也不會留在記憶體太久。同時請注意:我們從未把明文密碼寫進 log 或回應,register 函式裡完全沒有 print(payload.password) 這種東西。
驗證註冊流程
啟動服務(命令列):
uvicorn app.main:app --reload --port 8000
用 httpx 測三種情境:
# test_register.py
import httpx
BASE = "http://127.0.0.1:8000/api"
with httpx.Client(base_url=BASE, timeout=10.0) as client:
# 情境 1:成功註冊
r1 = client.post(
"/auth/register",
json={"email": "alice@example.com", "password": "MyP@ssw0rd-2025"},
)
print(r1.status_code, r1.json())
# 輸出(範例):
# 201 {'id': 1, 'email': 'alice@example.com', 'created_at': '...'}
# 情境 2:重複註冊(409)
r2 = client.post(
"/auth/register",
json={"email": "alice@example.com", "password": "AnotherP@ss"},
)
print(r2.status_code, r2.json())
# 輸出(範例):
# 409 {'error': {'code': 'CONFLICT', 'message': '這個電子郵件已經註冊過了', ...}}
# 情境 3:密碼太弱(422,自動被 Pydantic 攔下)
r3 = client.post(
"/auth/register",
json={"email": "bob@example.com", "password": "abc"},
)
print(r3.status_code, r3.json())
# 輸出(範例):
# 422 {'error': {'code': 'INVALID_INPUT', ...}}
三種情境都正確回應:第一種回 201 與新使用者資料,第二種回 409 與昨天設計的統一錯誤結構,第三種回 422(Pydantic 自動觸發昨天寫的 validation_error_handler)。整個註冊流程不需要額外處理錯誤,昨天的設計就直接接上了。
順便看一下資料庫實際存了什麼:
# check_storage.py
from sqlmodel import Session, create_engine, select
from app.models import User
engine = create_engine("sqlite:///./app.db")
with Session(engine) as session:
users = session.exec(select(User)).all()
for u in users:
print(f"id={u.id} email={u.email}")
print(f"password_hash={u.password_hash[:60]}...")
# 注意 password_hash 開頭是 $argon2id$v=19$m=...
# 完全看不出明文密碼是什麼
資料庫只存雜湊後的字串,攻擊者拿到這個欄位也無法直接還原密碼,這正是 argon2id 設計的目標。
常見錯誤與踩雷
第一個最常見的踩雷是「存明文或可逆加密」。有些團隊會說「我們用 AES 加密,加密密碼也很安全啊」。但 AES 是對稱加密,金鑰放在應用程式裡(否則無法驗證登入),攻擊者入侵應用程式後金鑰就跟著被拿走,加密等於明文。密碼必須用「單向雜湊」處理,無法從雜湊值反推原文,這是密碼學的基本原則。
第二個常見問題是「用 SHA-256 加自訂鹽」。SHA-256 是設計給「算得快」的雜湊函式,GPU 可以每秒算幾十億次。即使加了鹽,攻擊者拿到整個鹽+雜湊對照表後,每秒還是可以試幾十億組常見密碼組合。bcrypt 與 argon2 是設計成「每次算都要 100 毫秒」,GPU 優勢會被抵銷到幾乎等於零。
第三個是「忘記設 unique=True」。如果 email 欄位沒有唯一約束,攻擊者(或單純的程式 bug)可以重複註冊同一個電子郵件,導致登入時不知道該用哪一筆。資料層的 unique=True 是最後一道防線,端點的「先查再寫」只是方便給使用者友善訊息。
第四個是「密碼欄位洩漏到 log」。很多人會在 logger.debug(f"Register payload: {payload}") 這種地方不小心把整個請求物件印出來,密碼就進了 log。我們今天在端點裡完全沒有印 payload,連密碼雜湊都不建議印。實務上 log 只印 email 與「使用者建立成功」這類非敏感性資訊即可。
第五個是「把使用者物件的回應裡包含 password_hash」。注意我們的 RegisterResponse 只回傳 id、email、created_at,沒有把 password_hash 暴露出去。即使在前端除錯,也絕對不要把 password_hash 送到前端或寫進 cookie。
效能與實務提醒
argon2id 的 64MB 記憶體成本對單一請求沒問題,但在「批次建立使用者」(例如匯入舊系統的 10 萬筆帳號)時會吃光記憶體。實務上批次匯入可以用「降階參數」:time_cost=2、memory_cost=8MB、parallelism=1,匯入完成後在使用者首次登入時再用 needs_rehash 升級到正式參數。這是社群常見的折衷做法。
另一個效能議題是「登入請求的 QPS」。如果你的 API 每秒要處理數百次登入請求,每個登入都要花 100 毫秒算 argon2id,那單一個處理行程最多就是 10 QPS。解法有兩個方向:水平擴展(多開 uvicorn worker),或降低登入情境的 argon2 參數(不建議,安全風險)。實務上 Argon2id 在 100 毫秒的成本下,現代多核心伺服器可以支撐每秒數十次登入;如果業務量更大,建議改用「登入 token」(Web Day 12 會做),讓使用者只登入一次就能在有效期內反覆使用。登入 token 的設計重點是「access token 短期有效、refresh token 長期但單次使用」,這樣既降低每次請求的成本,又不會讓 token 變成長期被盜用的風險來源。整體效能規劃上,單一處理行程的 10 QPS 是 argon2id 的成本上限,多 worker 部署可以線性擴展,視伺服器 CPU 與記憶體資源決定 worker 數量。
最後提醒一下「EmailStr 需要安裝 email-validator」。如果你在 import 時遇到 ImportError: email-validator is not installed,那是因為 Pydantic v2 把電子郵件驗證拆到獨立套件了。安裝它:
uv add "email-validator==2.2.0"
這是 Pydantic v2 與 v1 的一個 breaking change,新手常在這裡卡住。安裝後 EmailStr 就會自動檢查電子郵件格式。
小結
今天建立了密碼處理與註冊流程的標準做法。我們選用 argon2-cffi 的 PasswordHasher 作為雜湊引擎,理由是它符合 2025 年的安全建議、抗 GPU 攻擊、參數可調;註冊端點把電子郵件驗證、密碼強度檢查、唯一性檢查、雜湊儲存整合在一起,並把錯誤情境透過昨天設計的 ConflictError 統一處理。整個流程沒有儲存任何明文密碼,也沒有把雜湊值暴露到 API 回應。今天完成的「使用者模型 + 註冊端點」會在明天直接接上登入端點,並用 argon2id 驗證密碼是否正確;驗證成功後就會發 JWT token,這就是 Web Day 12 的內容。
結語
今天把密碼這關做對了,明天我們進入「JWT 認證」。你會學到 JWT(JSON Web Token)的三個部分(header、payload、signature)的意義、access token 與 refresh token 的分工、PyJWT 套件(2.10 版)的 encode 與 decode 用法、以及如何把今天的註冊端點延伸出「登入端點」,驗證成功後發 token、token 過期後用 refresh token 換新 token。整個認證流程會把今天的雜湊、Web Day 10 的錯誤回應、Web Day 5 的相依性注入全部串起來。
延伸資源
- argon2-cffi 官方文件(2025):
https://argon2-cffi.readthedocs.io/en/stable/ - OWASP 密碼儲存 cheat sheet(2025):
https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - Pydantic v2 EmailStr 說明(2.11,2025):
https://docs.pydantic.dev/latest/api/networks/ - NIST SP 800-63B 數位身分指南(2025 版):
https://pages.nist.gov/800-63-3/sp800-63b.html
留言
張貼留言