# 

# 04-8. 雙因素認證 (2FA)

&gt; ⏱️ **閱讀時間：** 18 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐⭐ (高階)

---

## 🤔 一句話解釋

**雙因素認證（2FA）在密碼之外增加第二層驗證，即使密碼洩漏也能保護帳號安全。**

---

## 🔐 認證因素

```
┌─────────────────────────────────────────────────────────┐
│                    認證因素類型                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  1. Something you KNOW（你知道的）                       │
│     └─ 密碼、PIN 碼、安全問題                           │
│                                                         │
│  2. Something you HAVE（你擁有的）                       │
│     └─ 手機、硬體金鑰、認證 App                          │
│                                                         │
│  3. Something you ARE（你是什麼）                        │
│     └─ 指紋、臉部辨識、虹膜掃描                          │
│                                                         │
├─────────────────────────────────────────────────────────┤
│  2FA = 兩個不同類型的因素組合                             │
│  例如：密碼（know）&#43; TOTP（have）                         │
└─────────────────────────────────────────────────────────┘
```

---

## 🔄 TOTP 原理

```
TOTP (Time-based One-Time Password)

┌─────────────────────────────────────────────────────────┐
│  1. 使用者和伺服器共享一個密鑰（Secret）                  │
│                                                         │
│  2. 根據當前時間產生一次性密碼                            │
│                                                         │
│     Secret &#43; Current Time → HMAC-SHA1 → 6 位數字        │
│                                                         │
│  3. 每 30 秒更換一次密碼                                 │
│                                                         │
│     時間: 10:00:00 → Code: 123456                       │
│     時間: 10:00:30 → Code: 789012                       │
└─────────────────────────────────────────────────────────┘
```

---

## 📦 安裝

```bash
pip install pyotp    # TOTP 產生和驗證
pip install qrcode   # QR Code 產生
pip install pillow   # 圖片處理
```

---

## 🔧 TOTP 工具

```python
# app/core/totp.py
import pyotp
import qrcode
import io
import base64
from typing import Optional


class TOTPManager:
    &#34;&#34;&#34;TOTP 管理器&#34;&#34;&#34;

    ISSUER = &#34;MyApp&#34;  # 在認證 App 中顯示的名稱

    @staticmethod
    def generate_secret() -&gt; str:
        &#34;&#34;&#34;產生 TOTP 密鑰&#34;&#34;&#34;
        return pyotp.random_base32()

    @staticmethod
    def get_totp_uri(secret: str, email: str) -&gt; str:
        &#34;&#34;&#34;產生 TOTP URI（用於 QR Code）&#34;&#34;&#34;
        totp = pyotp.TOTP(secret)
        return totp.provisioning_uri(
            name=email,
            issuer_name=TOTPManager.ISSUER
        )

    @staticmethod
    def generate_qr_code(secret: str, email: str) -&gt; str:
        &#34;&#34;&#34;產生 QR Code（Base64 編碼）&#34;&#34;&#34;
        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=&#34;black&#34;, back_color=&#34;white&#34;)

        # 轉換為 Base64
        buffer = io.BytesIO()
        img.save(buffer, format=&#34;PNG&#34;)
        buffer.seek(0)
        img_base64 = base64.b64encode(buffer.getvalue()).decode()

        return f&#34;data:image/png;base64,{img_base64}&#34;

    @staticmethod
    def verify_totp(secret: str, code: str) -&gt; bool:
        &#34;&#34;&#34;驗證 TOTP 碼&#34;&#34;&#34;
        totp = pyotp.TOTP(secret)
        # valid_window=1 允許前後一個時間窗口的誤差（30 秒）
        return totp.verify(code, valid_window=1)

    @staticmethod
    def get_current_code(secret: str) -&gt; str:
        &#34;&#34;&#34;取得當前的 TOTP 碼（用於測試）&#34;&#34;&#34;
        totp = pyotp.TOTP(secret)
        return totp.now()


class BackupCodesManager:
    &#34;&#34;&#34;備用碼管理器&#34;&#34;&#34;

    CODE_LENGTH = 8
    CODE_COUNT = 10

    @staticmethod
    def generate_codes() -&gt; list[str]:
        &#34;&#34;&#34;產生備用碼&#34;&#34;&#34;
        import secrets
        return [
            secrets.token_hex(BackupCodesManager.CODE_LENGTH // 2)
            for _ in range(BackupCodesManager.CODE_COUNT)
        ]

    @staticmethod
    def hash_codes(codes: list[str]) -&gt; list[str]:
        &#34;&#34;&#34;雜湊備用碼&#34;&#34;&#34;
        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]) -&gt; tuple[bool, int]:
        &#34;&#34;&#34;
        驗證備用碼

        Returns:
            (是否有效, 使用的碼的索引)
        &#34;&#34;&#34;
        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 擴充

```python
from sqlalchemy import Column, Integer, String, Boolean, JSON
from sqlalchemy.orm import Mapped, mapped_column


class User(Base):
    __tablename__ = &#34;users&#34;

    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 端點

```python
# 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=&#34;/auth/2fa&#34;, tags=[&#34;2fa&#34;])


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(&#34;/enable&#34;, response_model=Enable2FAResponse)
async def enable_2fa(
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    啟用 2FA

    1. 產生 TOTP 密鑰
    2. 產生 QR Code
    3. 產生備用碼
    4. 等待使用者驗證
    &#34;&#34;&#34;
    if current_user.is_2fa_enabled:
        raise HTTPException(status_code=400, detail=&#34;2FA is already enabled&#34;)

    # 產生密鑰（暫存，等待驗證）
    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(&#34;/verify-enable&#34;)
async def verify_enable_2fa(
    request: Verify2FARequest,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    驗證並正式啟用 2FA

    使用者掃描 QR Code 後，輸入認證 App 顯示的驗證碼
    &#34;&#34;&#34;
    if current_user.is_2fa_enabled:
        raise HTTPException(status_code=400, detail=&#34;2FA is already enabled&#34;)

    if not current_user.totp_secret:
        raise HTTPException(status_code=400, detail=&#34;Please request 2FA setup first&#34;)

    # 驗證 TOTP 碼
    if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
        raise HTTPException(status_code=400, detail=&#34;Invalid verification code&#34;)

    # 正式啟用 2FA
    current_user.is_2fa_enabled = True
    await db.commit()

    return {&#34;message&#34;: &#34;2FA enabled successfully&#34;}


@router.post(&#34;/disable&#34;)
async def disable_2fa(
    request: Disable2FARequest,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    停用 2FA

    需要輸入當前的驗證碼才能停用
    &#34;&#34;&#34;
    if not current_user.is_2fa_enabled:
        raise HTTPException(status_code=400, detail=&#34;2FA is not enabled&#34;)

    # 驗證 TOTP 碼
    if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
        raise HTTPException(status_code=400, detail=&#34;Invalid verification code&#34;)

    # 停用 2FA
    current_user.is_2fa_enabled = False
    current_user.totp_secret = None
    current_user.backup_codes = None
    await db.commit()

    return {&#34;message&#34;: &#34;2FA disabled successfully&#34;}


@router.post(&#34;/regenerate-backup-codes&#34;)
async def regenerate_backup_codes(
    request: Verify2FARequest,
    current_user: User = Depends(get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    重新產生備用碼

    需要輸入當前的驗證碼
    &#34;&#34;&#34;
    if not current_user.is_2fa_enabled:
        raise HTTPException(status_code=400, detail=&#34;2FA is not enabled&#34;)

    # 驗證 TOTP 碼
    if not TOTPManager.verify_totp(current_user.totp_secret, request.code):
        raise HTTPException(status_code=400, detail=&#34;Invalid verification code&#34;)

    # 產生新的備用碼
    backup_codes = BackupCodesManager.generate_codes()
    current_user.backup_codes = BackupCodesManager.hash_codes(backup_codes)
    await db.commit()

    return {&#34;backup_codes&#34;: backup_codes}
```

---

## 🔑 登入流程整合

```python
# app/api/routes/auth.py
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

router = APIRouter(prefix=&#34;/auth&#34;, tags=[&#34;auth&#34;])


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(&#34;/login&#34;, response_model=LoginResponse)
async def login(
    request: LoginRequest,
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    登入（第一步）

    - 驗證帳號密碼
    - 如果啟用 2FA，回傳臨時 Token，等待 2FA 驗證
    - 如果沒有 2FA，直接回傳 JWT Token
    &#34;&#34;&#34;
    # 查詢使用者
    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=&#34;Invalid credentials&#34;)

    if not user.is_active:
        raise HTTPException(status_code=403, detail=&#34;Account is disabled&#34;)

    # 檢查是否啟用 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(&#34;/login/2fa&#34;, response_model=LoginResponse)
async def login_2fa(
    request: TwoFactorRequest,
    db: AsyncSession = Depends(get_db)
):
    &#34;&#34;&#34;
    登入（第二步：2FA 驗證）

    - 驗證臨時 Token
    - 驗證 TOTP 碼或備用碼
    - 回傳 JWT Token
    &#34;&#34;&#34;
    # 驗證臨時 Token
    payload = verify_login_token(request.login_token)
    if not payload:
        raise HTTPException(status_code=401, detail=&#34;Invalid or expired login token&#34;)

    user_id = payload[&#34;sub&#34;]
    user = await db.get(User, user_id)

    if not user or not user.is_2fa_enabled:
        raise HTTPException(status_code=401, detail=&#34;Invalid request&#34;)

    # 驗證 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=&#34;Invalid verification code&#34;)

    # 產生 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) -&gt; str:
    &#34;&#34;&#34;建立臨時登入 Token（用於 2FA）&#34;&#34;&#34;
    expire = datetime.utcnow() &#43; timedelta(minutes=5)

    to_encode = {
        &#34;sub&#34;: str(user_id),
        &#34;exp&#34;: expire,
        &#34;type&#34;: &#34;login&#34;
    }

    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


def verify_login_token(token: str) -&gt; dict | None:
    &#34;&#34;&#34;驗證臨時登入 Token&#34;&#34;&#34;
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        if payload.get(&#34;type&#34;) != &#34;login&#34;:
            return None
        return payload
    except JWTError:
        return None
```

---

## 📱 前端整合範例

```javascript
// 登入流程
async function login(username, password) {
  const response = await fetch(&#39;/auth/login&#39;, {
    method: &#39;POST&#39;,
    body: JSON.stringify({ username, password })
  });

  const data = await response.json();

  if (data.requires_2fa) {
    // 需要 2FA 驗證
    showTwoFactorForm(data.login_token);
  } else {
    // 登入成功
    localStorage.setItem(&#39;access_token&#39;, data.access_token);
    redirect(&#39;/dashboard&#39;);
  }
}

// 2FA 驗證
async function verify2FA(loginToken, code) {
  const response = await fetch(&#39;/auth/login/2fa&#39;, {
    method: &#39;POST&#39;,
    body: JSON.stringify({
      login_token: loginToken,
      code: code
    })
  });

  const data = await response.json();

  if (data.access_token) {
    localStorage.setItem(&#39;access_token&#39;, data.access_token);
    redirect(&#39;/dashboard&#39;);
  }
}
```

---

## ✅ 重點總結

### 2FA 流程

| 步驟 | 說明 |
|------|------|
| 1 | 使用者啟用 2FA，取得 Secret |
| 2 | 掃描 QR Code 加入認證 App |
| 3 | 輸入驗證碼確認啟用 |
| 4 | 登入時需要額外輸入驗證碼 |

### 安全要點

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

---

## 🎤 面試這樣答

### Q: TOTP 的原理是什麼？

**答案：**

&gt; TOTP（Time-based One-Time Password）原理：
&gt;
&gt; 1. **共享密鑰**：使用者和伺服器共享一個 Secret
&gt; 2. **時間因素**：根據當前時間（每 30 秒一個週期）
&gt; 3. **產生密碼**：Secret &#43; Time → HMAC-SHA1 → 6 位數字
&gt;
&gt; ```python
&gt; import pyotp
&gt; totp = pyotp.TOTP(secret)
&gt; code = totp.now()  # 產生當前的驗證碼
&gt; totp.verify(code)  # 驗證
&gt; ```
&gt;
&gt; 因為雙方使用相同的 Secret 和時間，所以能產生相同的驗證碼。

---

**上一篇：** [04-7. Email 驗證](./04-7)
**下一篇：** [05-1. 非同步程式設計基礎](./05-1)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/04-8/  

