|
|
|
@ -1,83 +1,125 @@
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
CSRF Token Verification Dependency for be-optimumoh (FastAPI).
|
|
|
|
|
|
|
|
|
|
|
|
import secrets
|
|
|
|
Menggantikan CSRFProtectMiddleware (Double Submit Cookie) dengan verifikasi
|
|
|
|
|
|
|
|
berbasis Redis. Token CSRF di-generate oleh be-auth (CSRFTokenResource) dan
|
|
|
|
|
|
|
|
disimpan di Redis dalam bentuk SHA256 hash.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Token Flow (generation di be-auth, verification di sini):
|
|
|
|
|
|
|
|
────────────────────────────────────────
|
|
|
|
|
|
|
|
GENERATION (be-auth / CSRFTokenResource):
|
|
|
|
|
|
|
|
1. token = secrets.token_urlsafe(32)
|
|
|
|
|
|
|
|
2. token_hash = SHA256(token)
|
|
|
|
|
|
|
|
3. redis[csrf:{user_id}:{path_hash}] = token_hash (dengan TTL)
|
|
|
|
|
|
|
|
4. return plain token ke client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VERIFICATION (dependency ini):
|
|
|
|
|
|
|
|
1. Baca X-CSRF-Token header dari request
|
|
|
|
|
|
|
|
2. Ambil user_id dari request.state.user (di-set oleh JWTBearer)
|
|
|
|
|
|
|
|
3. incoming_hash = SHA256(nilai X-CSRF-Token)
|
|
|
|
|
|
|
|
4. stored_hash = redis_auth.get("csrf:{user_id}:{path_hash}")
|
|
|
|
|
|
|
|
5. secrets.compare_digest(incoming_hash, stored_hash)
|
|
|
|
|
|
|
|
6. redis_auth.delete(key) ← one-time use
|
|
|
|
|
|
|
|
────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NOTE: Dependency ini harus selalu dikombinasikan dengan JWTBearer() agar
|
|
|
|
|
|
|
|
request.state.user sudah tersedia saat dijalankan.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
|
|
from fastapi import Depends
|
|
|
|
|
|
|
|
from src.csrf_protect import csrf_protect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/some-endpoint", dependencies=[Depends(csrf_protect)])
|
|
|
|
|
|
|
|
async def some_endpoint(...):
|
|
|
|
|
|
|
|
...
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
import logging
|
|
|
|
import logging
|
|
|
|
from typing import Optional, List
|
|
|
|
import secrets
|
|
|
|
from fastapi import Request, HTTPException
|
|
|
|
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
import redis
|
|
|
|
from starlette.responses import Response
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import src.config as config
|
|
|
|
|
|
|
|
from src.context import get_user_id
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class CSRFProtectMiddleware(BaseHTTPMiddleware):
|
|
|
|
# Koneksi Redis untuk CSRF — Redis DB 0 tempat be-auth menyimpan CSRF token
|
|
|
|
"""
|
|
|
|
_redis_auth = redis.Redis(
|
|
|
|
CSRF Protection Middleware using Double Submit Cookie pattern.
|
|
|
|
host=config.REDIS_HOST,
|
|
|
|
|
|
|
|
port=int(config.REDIS_PORT),
|
|
|
|
|
|
|
|
db=config.REDIS_AUTH_DB,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This middleware:
|
|
|
|
async def csrf_protect(request: Request) -> None:
|
|
|
|
1. Ensures a 'csrf_token' cookie exists for every request.
|
|
|
|
|
|
|
|
2. For mutating requests (POST, PUT, PATCH, DELETE):
|
|
|
|
|
|
|
|
- Requires an 'X-CSRF-Token' header.
|
|
|
|
|
|
|
|
- Validates that the header value matches the 'csrf_token' cookie.
|
|
|
|
|
|
|
|
"""
|
|
|
|
"""
|
|
|
|
|
|
|
|
FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
|
|
|
|
|
|
|
|
Harus dikombinasikan dengan JWTBearer() dependency.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
# ── Step 1: ambil user_id dari request.state (di-set oleh JWTBearer) ────
|
|
|
|
|
|
|
|
user = getattr(request.state, "user", None)
|
|
|
|
|
|
|
|
user_id = str(user.user_id) if user else get_user_id()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not user_id:
|
|
|
|
|
|
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 2: baca CSRF token dari request header ───────────────────────────
|
|
|
|
|
|
|
|
# Starlette lowercases all header names
|
|
|
|
|
|
|
|
csrf_token_header = (
|
|
|
|
|
|
|
|
request.headers.get("x-csrf-token")
|
|
|
|
|
|
|
|
or request.headers.get("x-xsrf-token")
|
|
|
|
|
|
|
|
or request.headers.get("x-csrf-token")
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not csrf_token_header:
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
|
|
|
|
"[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
|
|
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
|
|
request.client.host if request.client else "unknown",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="CSRF token missing in header")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
csrf_token_header = csrf_token_header.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ─────────
|
|
|
|
|
|
|
|
current_path = request.url.path
|
|
|
|
|
|
|
|
path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
|
|
|
|
|
|
|
|
redis_key = f"csrf:{user_id}:{path_hash}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
|
|
|
|
|
|
|
|
stored_token_hash = _redis_auth.get(redis_key)
|
|
|
|
|
|
|
|
if not stored_token_hash:
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
|
|
|
|
"[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s",
|
|
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
|
|
current_path,
|
|
|
|
|
|
|
|
request.client.host if request.client else "unknown",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="CSRF token expired or invalid")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(stored_token_hash, bytes):
|
|
|
|
|
|
|
|
stored_token_hash = stored_token_hash.decode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 5: bandingkan hash dengan constant-time digest ───────────────────
|
|
|
|
|
|
|
|
incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
|
|
|
|
|
|
|
|
if not secrets.compare_digest(incoming_hash, stored_token_hash):
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
|
|
|
|
"[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
|
|
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
|
|
current_path,
|
|
|
|
|
|
|
|
request.client.host if request.client else "unknown",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
_redis_auth.delete(redis_key)
|
|
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="CSRF token verification failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step 6: hapus token (one-time use) dan lanjutkan ─────────────────────
|
|
|
|
|
|
|
|
_redis_auth.delete(redis_key)
|
|
|
|
|
|
|
|
log.info(
|
|
|
|
|
|
|
|
"[CSRF] Verified & deleted | user_id=%s | path=%s",
|
|
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
|
|
current_path,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
|
|
self,
|
|
|
|
|
|
|
|
app,
|
|
|
|
|
|
|
|
cookie_name: str = "csrf_token",
|
|
|
|
|
|
|
|
header_name: str = "X-CSRF-Token",
|
|
|
|
|
|
|
|
excluded_paths: Optional[List[str]] = None,
|
|
|
|
|
|
|
|
safe_methods: List[str] = ("GET", "HEAD", "OPTIONS", "TRACE")
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
super().__init__(app)
|
|
|
|
|
|
|
|
self.cookie_name = cookie_name
|
|
|
|
|
|
|
|
self.header_name = header_name
|
|
|
|
|
|
|
|
self.excluded_paths = excluded_paths or []
|
|
|
|
|
|
|
|
self.safe_methods = safe_methods
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
|
|
# 1. Skip validation for "safe" methods (GET, HEAD, etc.) or excluded paths
|
|
|
|
|
|
|
|
is_safe_method = request.method in self.safe_methods
|
|
|
|
|
|
|
|
is_excluded = any(request.url.path.startswith(path) for path in self.excluded_paths)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not is_safe_method and not is_excluded:
|
|
|
|
|
|
|
|
# 2. Extract token from cookie and header
|
|
|
|
|
|
|
|
csrf_cookie = request.cookies.get(self.cookie_name)
|
|
|
|
|
|
|
|
csrf_header = request.headers.get(self.header_name.lower()) # Starlette lowercase headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Validate
|
|
|
|
|
|
|
|
if not csrf_cookie:
|
|
|
|
|
|
|
|
log.warning(f"CSRF validation failed: Missing cookie '{self.cookie_name}'")
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
|
|
detail="CSRF validation failed: Missing session cookie."
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not csrf_header:
|
|
|
|
|
|
|
|
log.warning(f"CSRF validation failed: Missing header '{self.header_name}'")
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
|
|
detail=f"CSRF validation failed: Missing header '{self.header_name}'."
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not secrets.compare_digest(csrf_cookie, csrf_header):
|
|
|
|
|
|
|
|
log.warning(f"CSRF validation failed: Token mismatch for path {request.url.path}")
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
|
|
detail="CSRF validation failed: Token mismatch."
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 4. Proceed with the request
|
|
|
|
|
|
|
|
response: Response = await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 5. Ensure the cookie is set if missing (e.g., on first GET request)
|
|
|
|
|
|
|
|
if not request.cookies.get(self.cookie_name):
|
|
|
|
|
|
|
|
new_token = secrets.token_urlsafe(32)
|
|
|
|
|
|
|
|
response.set_cookie(
|
|
|
|
|
|
|
|
key=self.cookie_name,
|
|
|
|
|
|
|
|
value=new_token,
|
|
|
|
|
|
|
|
httponly=False, # Set to False so frontend JS can read it for Double Submit
|
|
|
|
|
|
|
|
samesite="lax",
|
|
|
|
|
|
|
|
secure=True, # Recommended for production with HTTPS
|
|
|
|
|
|
|
|
path="/"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|