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>
179 lines
5.3 KiB
Python
179 lines
5.3 KiB
Python
"""
|
||
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)]
|