Files
awoooi/apps/api/src/core/csrf.py
OG T d206460751 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>
2026-03-28 18:31:58 +08:00

179 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)]