""" 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)]