feat(security): Phase 20 CSRF 防護實作

Phase 19 首席架構師審查指出: 核鑰 UX 安全性缺 CSRF 防護

後端:
- 新增 src/core/csrf.py (Double Submit Cookie 模式)
- 新增 src/api/v1/csrf.py (GET /api/v1/csrf/token)
- 新增 src/models/csrf.py (CSRFTokenResponse)
- 修改 approvals.py sign/reject/bulk 端點加入 CSRFToken 驗證

前端:
- 新增 hooks/useCSRF.ts (React Hook)
- 修改 approval.store.ts 整合 CSRF Token 參數

安全特性:
- 256-bit Token (secrets.token_hex)
- 時序安全比較 (secrets.compare_digest)
- SameSite=Strict Cookie
- 1 小時 Token 有效期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-28 18:31:58 +08:00
parent cd305a0baf
commit d206460751
10 changed files with 503 additions and 11 deletions

178
apps/api/src/core/csrf.py Normal file
View File

@@ -0,0 +1,178 @@
"""
CSRF Protection Module (Phase 20)
=================================
Phase 19 首席架構師審查: 核鑰 UX 缺 CSRF 防護 (9/10)
實作模式: Double Submit Cookie
- 後端生成 CSRF Token設定在 Cookie 中
- 前端在敏感請求 Header 中帶上相同 Token
- 後端驗證 Cookie 和 Header 的 Token 是否匹配
保護端點:
- POST /api/v1/approvals/{id}/sign - 核鑰簽核
- POST /api/v1/approvals/{id}/reject - 拒絕請求
- POST /api/v1/approvals/bulk - 批次處理
建立日期: 2026-03-28 (台北時間)
建立者: Claude Code (首席架構師)
"""
import secrets
from typing import Annotated
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, status
from src.core.config import settings
from src.core.logging import get_logger
logger = get_logger("awoooi.csrf")
# =============================================================================
# Constants
# =============================================================================
CSRF_COOKIE_NAME = "awoooi_csrf_token"
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_TOKEN_LENGTH = 32 # 256 bits of entropy
# =============================================================================
# Token Generation
# =============================================================================
def generate_csrf_token() -> str:
"""
生成加密安全的 CSRF Token
Returns:
str: 64 字元的十六進位 Token (32 bytes = 256 bits)
"""
return secrets.token_hex(CSRF_TOKEN_LENGTH)
# =============================================================================
# Token Verification (Dependency Injection)
# =============================================================================
async def verify_csrf_token(
request: Request,
x_csrf_token: Annotated[str | None, Header(alias=CSRF_HEADER_NAME)] = None,
csrf_cookie: Annotated[str | None, Cookie(alias=CSRF_COOKIE_NAME)] = None,
) -> str:
"""
驗證 CSRF Token (FastAPI Depends)
Double Submit Cookie 模式:
1. 從 Cookie 取得 Token
2. 從 Header 取得 Token
3. 比對兩者是否一致
Args:
request: FastAPI Request 物件
x_csrf_token: X-CSRF-Token Header
csrf_cookie: awoooi_csrf_token Cookie
Returns:
str: 驗證通過的 CSRF Token
Raises:
HTTPException: 403 CSRF 驗證失敗
"""
# 開發環境可選擇性跳過 CSRF 驗證 (不建議用於生產)
if settings.ENVIRONMENT == "development" and settings.DEBUG:
# 即使在開發環境也記錄警告
if not csrf_cookie or not x_csrf_token:
logger.warning(
"csrf_skipped_dev_mode",
has_cookie=bool(csrf_cookie),
has_header=bool(x_csrf_token),
path=request.url.path,
)
return "dev-mode-bypass"
# 生產環境: 嚴格驗證
if not csrf_cookie:
logger.warning(
"csrf_cookie_missing",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token cookie missing. Please refresh the page.",
)
if not x_csrf_token:
logger.warning(
"csrf_header_missing",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token header missing. Ensure X-CSRF-Token header is sent.",
)
# 時序安全比較 (防止 timing attack)
if not secrets.compare_digest(csrf_cookie, x_csrf_token):
logger.error(
"csrf_token_mismatch",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token validation failed. Please refresh the page and try again.",
)
logger.debug(
"csrf_token_verified",
path=request.url.path,
)
return csrf_cookie
# =============================================================================
# Response Helper
# =============================================================================
def set_csrf_cookie(response: Response, token: str | None = None) -> str:
"""
在 Response 中設定 CSRF Cookie
Args:
response: FastAPI Response 物件
token: 指定的 Token (None 時自動生成)
Returns:
str: 設定的 CSRF Token
"""
csrf_token = token or generate_csrf_token()
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
httponly=False, # 前端 JS 需要讀取
secure=settings.ENVIRONMENT == "production", # 生產環境啟用 HTTPS Only
samesite="strict", # 嚴格同站策略
max_age=3600, # 1 小時有效
path="/",
)
logger.debug(
"csrf_cookie_set",
token_preview=csrf_token[:8] + "...",
)
return csrf_token
# =============================================================================
# Type Alias for Dependency Injection
# =============================================================================
CSRFToken = Annotated[str, Depends(verify_csrf_token)]