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
|
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:
|
|
||||||
1. Ensures a 'csrf_token' cookie exists for every request.
|
async def csrf_protect(request: Request) -> None:
|
||||||
2. For mutating requests (POST, PUT, PATCH, DELETE):
|
"""
|
||||||
- Requires an 'X-CSRF-Token' header.
|
FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
|
||||||
- Validates that the header value matches the 'csrf_token' cookie.
|
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__(
|
# ── Step 2: baca CSRF token dari request header ───────────────────────────
|
||||||
self,
|
# Starlette lowercases all header names
|
||||||
app,
|
csrf_token_header = (
|
||||||
cookie_name: str = "csrf_token",
|
request.headers.get("x-csrf-token")
|
||||||
header_name: str = "X-CSRF-Token",
|
or request.headers.get("x-xsrf-token")
|
||||||
excluded_paths: Optional[List[str]] = None,
|
or request.headers.get("x-csrf-token")
|
||||||
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:
|
if not csrf_token_header:
|
||||||
log.warning(f"CSRF validation failed: Missing header '{self.header_name}'")
|
log.warning(
|
||||||
raise HTTPException(
|
"[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
|
||||||
status_code=403,
|
user_id,
|
||||||
detail=f"CSRF validation failed: Missing header '{self.header_name}'."
|
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):
|
# ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
|
||||||
log.warning(f"CSRF validation failed: Token mismatch for path {request.url.path}")
|
stored_token_hash = _redis_auth.get(redis_key)
|
||||||
raise HTTPException(
|
if not stored_token_hash:
|
||||||
status_code=403,
|
log.warning(
|
||||||
detail="CSRF validation failed: Token mismatch."
|
"[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
|
if isinstance(stored_token_hash, bytes):
|
||||||
response: Response = await call_next(request)
|
stored_token_hash = stored_token_hash.decode("utf-8")
|
||||||
|
|
||||||
# 5. Ensure the cookie is set if missing (e.g., on first GET request)
|
# ── Step 5: bandingkan hash dengan constant-time digest ───────────────────
|
||||||
if not request.cookies.get(self.cookie_name):
|
incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
|
||||||
new_token = secrets.token_urlsafe(32)
|
if not secrets.compare_digest(incoming_hash, stored_token_hash):
|
||||||
response.set_cookie(
|
log.warning(
|
||||||
key=self.cookie_name,
|
"[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
|
||||||
value=new_token,
|
user_id,
|
||||||
httponly=False, # Set to False so frontend JS can read it for Double Submit
|
current_path,
|
||||||
samesite="lax",
|
request.client.host if request.client else "unknown",
|
||||||
secure=True, # Recommended for production with HTTPS
|
)
|
||||||
path="/"
|
_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