Update: Sheet 4 access control
parent
2615ff9a6e
commit
d4175f75d0
@ -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
|
||||
from typing import Optional, List
|
||||
from fastapi import Request, HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
import secrets
|
||||
|
||||
import redis
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
import src.config as config
|
||||
from src.context import get_user_id
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class CSRFProtectMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
CSRF Protection Middleware using Double Submit Cookie pattern.
|
||||
# Koneksi Redis untuk CSRF — Redis DB 0 tempat be-auth menyimpan CSRF token
|
||||
_redis_auth = redis.Redis(
|
||||
host=config.REDIS_HOST,
|
||||
port=int(config.REDIS_PORT),
|
||||
db=config.REDIS_AUTH_DB,
|
||||
)
|
||||
|
||||
This middleware:
|
||||
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.
|
||||
|
||||
async def csrf_protect(request: Request) -> None:
|
||||
"""
|
||||
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")
|
||||
|
||||
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."
|
||||
# ── 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_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 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}"
|
||||
|
||||
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."
|
||||
# ── 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")
|
||||
|
||||
# 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="/"
|
||||
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,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
Loading…
Reference in New Issue