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:
178
apps/api/src/core/csrf.py
Normal file
178
apps/api/src/core/csrf.py
Normal 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)]
|
||||
Reference in New Issue
Block a user