目錄
04-8. 雙因素認證 (2FA)
⏱️ 閱讀時間: 18 分鐘 🎯 難度: ⭐⭐⭐⭐ (高階)
🤔 一句話解釋
雙因素認證(2FA)在密碼之外增加第二層驗證,即使密碼洩漏也能保護帳號安全。
🔐 認證因素
┌─────────────────────────────────────────────────────────┐
│ 認證因素類型 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. Something you KNOW(你知道的) │
│ └─ 密碼、PIN 碼、安全問題 │
│ │
│ 2. Something you HAVE(你擁有的) │
│ └─ 手機、硬體金鑰、認證 App │
│ │
│ 3. Something you ARE(你是什麼) │
│ └─ 指紋、臉部辨識、虹膜掃描 │
│ │
├─────────────────────────────────────────────────────────┤
│ 2FA = 兩個不同類型的因素組合 │
│ 例如:密碼(know)+ TOTP(have) │
└─────────────────────────────────────────────────────────┘🔄 TOTP 原理
TOTP (Time-based One-Time Password)
┌─────────────────────────────────────────────────────────┐
│ 1. 使用者和伺服器共享一個密鑰(Secret) │
│ │
│ 2. 根據當前時間產生一次性密碼 │
│ │
│ Secret + Current Time → HMAC-SHA1 → 6 位數字 │
│ │
│ 3. 每 30 秒更換一次密碼 │
│ │
│ 時間: 10:00:00 → Code: 123456 │
│ 時間: 10:00:30 → Code: 789012 │
└─────────────────────────────────────────────────────────┘📦 安裝
pip install pyotp # TOTP 產生和驗證
pip install qrcode # QR Code 產生
pip install pillow # 圖片處理🔧 TOTP 工具
# app/core/totp.py
import pyotp
import qrcode
import io
import base64
from typing import Optional
class TOTPManager:
"""TOTP 管理器"""
ISSUER = "MyApp" # 在認證 App 中顯示的名稱
@staticmethod
def generate_secret() -> str:
"""產生 TOTP 密鑰"""
return pyotp.random_base32()
@staticmethod
def get_totp_uri(secret: str, email: str) -> str:
"""產生 TOTP URI(用於 QR Code)"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=email,
issuer_name=TOTPManager.ISSUER
)
@staticmethod
def generate_qr_code(secret: str, email: str) -> str:
"""產生 QR Code(Base64 編碼)"""
uri = TOTPManager.get_totp_uri(secret, email)
# 產生 QR Code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 轉換為 Base64
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_base64}"
@staticmethod
def verify_totp(secret: str, code: str) -> bool:
"""驗證 TOTP 碼"""
totp = pyotp.TOTP(secret)
# valid_window=1 允許前後一個時間窗口的誤差(30 秒)
return totp.verify(code, valid_window=1)
@staticmethod
def get_current_code(secret: str) -> str:
"""取得當前的 TOTP 碼(用於測試)"""
totp = pyotp.TOTP(secret)
return totp.now()
class BackupCodesManager:
"""備用碼管理器"""
CODE_LENGTH = 8
CODE_COUNT = 10
@staticmethod
def generate_codes() -> list[str]:
"""產生備用碼"""
import secrets
return [
secrets.token_hex(BackupCodesManager.CODE_LENGTH // 2)
for _ in range(BackupCodesManager.CODE_COUNT)
]
@staticmethod
def hash_codes(codes: list[str]) -> list[str]:
"""雜湊備用碼"""
from app.core.security import hash_password
return [hash_password(code) for code in codes]
@staticmethod
def verify_code(code: str, hashed_codes: list[str]) -> tuple[bool, int]:
"""
驗證備用碼
Returns:
(是否有效, 使用的碼的索引)
"""
from app.core.security import verify_password
for i, hashed in enumerate(hashed_codes):
if verify_password(code, hashed):
return True, i
return False, -1📊 User Model 擴充
from sqlalchemy import Column, Integer, String, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(100), unique=True)
hashed_password: Mapped[str] = mapped_column(String(255))
# 2FA 設定
is_2fa_enabled: Mapped[bool] = mapped_column(default=False)
totp_secret: Mapped[str | None] = mapped_column(String(32), nullable=True)
backup_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 帳號狀態
is_active: Mapped[bool] = mapped_column(default=True)
is_verified: Mapped[bool] = mapped_column(default=False)🔐 API 端點
# app/api/routes/two_factor.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.database import get_db
from app.models import User
from app.core.totp import TOTPManager, BackupCodesManager
from app.api.deps import get_current_active_user
router = APIRouter(prefix="/auth/2fa", tags=["2fa"])
class Enable2FAResponse(BaseModel):
secret: str
qr_code: str # Base64 encoded
backup_codes: list[str]
class Verify2FARequest(BaseModel):
code: str
class Disable2FARequest(BaseModel):
code: str # 需要驗證才能關閉
@router.post("/enable", response_model=Enable2FAResponse)
async def enable_2fa(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
啟用 2FA
1. 產生 TOTP 密鑰
2. 產生 QR Code
3. 產生備用碼
4. 等待使用者驗證
"""
if current_user.is_2fa_enabled:
raise HTTPException(status_code=400, detail="2FA is already enabled")
# 產生密鑰(暫存,等待驗證)
secret = TOTPManager.generate_secret()
# 產生 QR Code
qr_code = TOTPManager.generate_qr_code(secret, current_user.email)
# 產生備用碼
backup_codes = BackupCodesManager.generate_codes()
# 暫存密鑰和備用碼(等待驗證後才正式啟用)
current_user.totp_secret = secret
current_user.backup_codes = BackupCodesManager.hash_codes(backup_codes)
await db.commit()
return Enable2FAResponse(
secret=secret,
qr_code=qr_code,
backup_codes=backup_codes
)
@router.post("/verify-enable")
async def verify_enable_2fa(
request: Verify2FARequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
驗證並正式啟用 2FA
使用者掃描 QR Code 後,輸入認證 App 顯示的驗證碼
"""
if current_user.is_2fa_enabled:
raise HTTPException(status_code=400, detail="2FA is already enabled")
if not current_user.totp_secret:
raise HTTPException(status_code=400, detail="Please request 2FA setup first")
# 驗證 TOTP 碼
if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
raise HTTPException(status_code=400, detail="Invalid verification code")
# 正式啟用 2FA
current_user.is_2fa_enabled = True
await db.commit()
return {"message": "2FA enabled successfully"}
@router.post("/disable")
async def disable_2fa(
request: Disable2FARequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
停用 2FA
需要輸入當前的驗證碼才能停用
"""
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=400, detail="2FA is not enabled")
# 驗證 TOTP 碼
if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
raise HTTPException(status_code=400, detail="Invalid verification code")
# 停用 2FA
current_user.is_2fa_enabled = False
current_user.totp_secret = None
current_user.backup_codes = None
await db.commit()
return {"message": "2FA disabled successfully"}
@router.post("/regenerate-backup-codes")
async def regenerate_backup_codes(
request: Verify2FARequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""
重新產生備用碼
需要輸入當前的驗證碼
"""
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=400, detail="2FA is not enabled")
# 驗證 TOTP 碼
if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
raise HTTPException(status_code=400, detail="Invalid verification code")
# 產生新的備用碼
backup_codes = BackupCodesManager.generate_codes()
current_user.backup_codes = BackupCodesManager.hash_codes(backup_codes)
await db.commit()
return {"backup_codes": backup_codes}🔑 登入流程整合
# app/api/routes/auth.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class TwoFactorRequest(BaseModel):
login_token: str # 第一步登入後取得的臨時 Token
code: str
class LoginResponse(BaseModel):
access_token: str | None = None
refresh_token: str | None = None
requires_2fa: bool = False
login_token: str | None = None # 用於 2FA 驗證
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""
登入(第一步)
- 驗證帳號密碼
- 如果啟用 2FA,回傳臨時 Token,等待 2FA 驗證
- 如果沒有 2FA,直接回傳 JWT Token
"""
# 查詢使用者
stmt = select(User).where(User.username == request.username)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
# 驗證密碼
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
# 檢查是否啟用 2FA
if user.is_2fa_enabled:
# 產生臨時 Token(用於 2FA 驗證)
login_token = create_login_token(user.id)
return LoginResponse(
requires_2fa=True,
login_token=login_token
)
# 沒有 2FA,直接產生 JWT
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
requires_2fa=False
)
@router.post("/login/2fa", response_model=LoginResponse)
async def login_2fa(
request: TwoFactorRequest,
db: AsyncSession = Depends(get_db)
):
"""
登入(第二步:2FA 驗證)
- 驗證臨時 Token
- 驗證 TOTP 碼或備用碼
- 回傳 JWT Token
"""
# 驗證臨時 Token
payload = verify_login_token(request.login_token)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired login token")
user_id = payload["sub"]
user = await db.get(User, user_id)
if not user or not user.is_2fa_enabled:
raise HTTPException(status_code=401, detail="Invalid request")
# 驗證 TOTP 碼
code_valid = TOTPManager.verify_totp(user.totp_secret, request.code)
# 如果 TOTP 驗證失敗,嘗試備用碼
if not code_valid and user.backup_codes:
code_valid, used_index = BackupCodesManager.verify_code(
request.code,
user.backup_codes
)
# 使用過的備用碼要刪除
if code_valid:
user.backup_codes.pop(used_index)
await db.commit()
if not code_valid:
raise HTTPException(status_code=401, detail="Invalid verification code")
# 產生 JWT
access_token = create_access_token(subject=user.id)
refresh_token = create_refresh_token(subject=user.id)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
requires_2fa=False
)
def create_login_token(user_id: int) -> str:
"""建立臨時登入 Token(用於 2FA)"""
expire = datetime.utcnow() + timedelta(minutes=5)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "login"
}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_login_token(token: str) -> dict | None:
"""驗證臨時登入 Token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "login":
return None
return payload
except JWTError:
return None📱 前端整合範例
// 登入流程
async function login(username, password) {
const response = await fetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.requires_2fa) {
// 需要 2FA 驗證
showTwoFactorForm(data.login_token);
} else {
// 登入成功
localStorage.setItem('access_token', data.access_token);
redirect('/dashboard');
}
}
// 2FA 驗證
async function verify2FA(loginToken, code) {
const response = await fetch('/auth/login/2fa', {
method: 'POST',
body: JSON.stringify({
login_token: loginToken,
code: code
})
});
const data = await response.json();
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
redirect('/dashboard');
}
}✅ 重點總結
2FA 流程
| 步驟 | 說明 |
|---|---|
| 1 | 使用者啟用 2FA,取得 Secret |
| 2 | 掃描 QR Code 加入認證 App |
| 3 | 輸入驗證碼確認啟用 |
| 4 | 登入時需要額外輸入驗證碼 |
安全要點
- Secret 安全儲存:加密儲存在資料庫
- 備用碼:提供備用碼以防手機遺失
- 臨時 Token:2FA 驗證期間使用短期 Token
- 允許時間誤差:valid_window 處理時間同步問題
🎤 面試這樣答
Q: TOTP 的原理是什麼?
答案:
TOTP(Time-based One-Time Password)原理:
- 共享密鑰:使用者和伺服器共享一個 Secret
- 時間因素:根據當前時間(每 30 秒一個週期)
- 產生密碼:Secret + Time → HMAC-SHA1 → 6 位數字
import pyotp totp = pyotp.TOTP(secret) code = totp.now() # 產生當前的驗證碼 totp.verify(code) # 驗證因為雙方使用相同的 Secret 和時間,所以能產生相同的驗證碼。
上一篇: 04-7. Email 驗證 下一篇: 05-1. 非同步程式設計基礎
最後更新:2025-12-17