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登入時需要額外輸入驗證碼

安全要點

  1. Secret 安全儲存:加密儲存在資料庫
  2. 備用碼:提供備用碼以防手機遺失
  3. 臨時 Token:2FA 驗證期間使用短期 Token
  4. 允許時間誤差:valid_window 處理時間同步問題

🎤 面試這樣答

Q: TOTP 的原理是什麼?

答案:

TOTP(Time-based One-Time Password)原理:

  1. 共享密鑰:使用者和伺服器共享一個 Secret
  2. 時間因素:根據當前時間(每 30 秒一個週期)
  3. 產生密碼: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

0%