OncoLit: a multi-tenant oncology literature search, feed, and collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL. Includes PubMed pipeline, drug approvals, AI summaries, and systematic review tools.
75 lines
2.9 KiB
Python
75 lines
2.9 KiB
Python
"""CSRF 防护中间件 — Double-Submit Cookie 模式"""
|
||
|
||
import secrets
|
||
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from starlette.requests import Request
|
||
|
||
from app.config import settings
|
||
|
||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||
CSRF_EXEMPT_PREFIXES = (
|
||
"/api/v1/auth/", "/api/v1/webhooks/", "/api/v1/ws/",
|
||
"/api/v1/captcha/", "/api/v1/public/", "/api/v1/features/",
|
||
"/api/v1/domains/", "/api/v1/approvals/", "/api/v1/subscriptions/plans",
|
||
"/docs", "/openapi.json", "/redoc", "/health",
|
||
)
|
||
|
||
|
||
class CSRFProtectionMiddleware(BaseHTTPMiddleware):
|
||
"""双提交 Cookie CSRF 防护。
|
||
|
||
- 安全方法(GET/HEAD/OPTIONS)+ 豁免前缀:不验证,但自动注入 csrf_token cookie
|
||
- Bearer Token 请求:自动豁免(SPA token-based auth 天然防 CSRF)
|
||
- 其他 state-changing 请求:验证 Cookie 中的 csrf_token 与 X-CSRF-Token 请求头一致
|
||
"""
|
||
|
||
async def dispatch(self, request: Request, call_next):
|
||
path = request.url.path.rstrip('/') or '/'
|
||
is_safe = request.method in SAFE_METHODS
|
||
is_exempt = any(path.startswith(p.rstrip('/')) for p in CSRF_EXEMPT_PREFIXES)
|
||
|
||
# 安全方法 + 豁免路径:确保 csrf_token cookie 已设置
|
||
if is_safe or is_exempt:
|
||
response = await call_next(request)
|
||
if not request.cookies.get("csrf_token"):
|
||
cookie_secure = request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
||
response.set_cookie(
|
||
key="csrf_token",
|
||
value=secrets.token_hex(32),
|
||
# NOT HttpOnly — JS 需要读取并设置 X-CSRF-Token 请求头
|
||
httponly=False,
|
||
samesite="lax",
|
||
secure=cookie_secure,
|
||
path="/",
|
||
max_age=86400 * 7, # 7 天
|
||
)
|
||
return response
|
||
|
||
# Bearer Token 天然防 CSRF(浏览器不会自动附加)
|
||
if request.headers.get("Authorization", "").startswith("Bearer "):
|
||
return await call_next(request)
|
||
|
||
# State-changing 请求:验证双提交 Token
|
||
cookie_token = request.cookies.get("csrf_token")
|
||
header_token = request.headers.get("X-CSRF-Token")
|
||
|
||
if not cookie_token or not header_token or not _constant_time_compare(cookie_token, header_token):
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"success": False, "error": {"code": "CSRF_FAILED", "message": "CSRF token missing or invalid"}},
|
||
)
|
||
|
||
return await call_next(request)
|
||
|
||
|
||
def _constant_time_compare(a: str, b: str) -> bool:
|
||
"""防止时序攻击的字符串比较"""
|
||
if len(a) != len(b):
|
||
return False
|
||
result = 0
|
||
for x, y in zip(a, b, strict=True):
|
||
result |= ord(x) ^ ord(y)
|
||
return result == 0
|