diff --git a/src/auth/access_control.py b/src/auth/access_control.py index c907dbc..08c2255 100644 --- a/src/auth/access_control.py +++ b/src/auth/access_control.py @@ -87,6 +87,42 @@ class AccessControl: raise self.permission_exception +# Roles allowed to access the backend (whitelist). +# Management is intentionally excluded. +ALLOWED_ROLES = {"admin", "super admin", "engineer"} + + +def require_any_role(*allowed_roles: str): + """ + FastAPI dependency — whitelist-based role check. + Raises 403 if the authenticated user's role is not in the allowed set. + Comparison is case-insensitive. + + Usage (router-level): + router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) + """ + normalized = {r.lower() for r in allowed_roles} + + async def dependency(request: Request): + user = getattr(request.state, "user", None) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" + ) + + user_role = user.get("role") if isinstance(user, dict) else getattr(user, "role", None) + + if not user_role or user_role.lower() not in normalized: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Your role does not have permission to access this resource.", + ) + + return True + + return dependency + + def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any): """ FastAPI dependency for role-based access control. diff --git a/src/calculation_budget_constrains/router.py b/src/calculation_budget_constrains/router.py index 542a987..399f427 100644 --- a/src/calculation_budget_constrains/router.py +++ b/src/calculation_budget_constrains/router.py @@ -11,11 +11,11 @@ from src.database.core import CollectorDbSession, DbSession from src.models import StandardResponse from .service import get_all_budget_constrains -from src.auth.access_control import required_permission, OHPermission +from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES from src.overhaul_scope.model import OverhaulScope from fastapi import Depends -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) diff --git a/src/calculation_time_constrains/router.py b/src/calculation_time_constrains/router.py index 30abd14..6304722 100644 --- a/src/calculation_time_constrains/router.py +++ b/src/calculation_time_constrains/router.py @@ -25,6 +25,7 @@ from src.database.core import CollectorDbSession from src.auth.access_control import required_permission, OHPermission from src.overhaul_scope.model import OverhaulScope from fastapi import Depends +from src.csrf_protect import csrf_protect router = APIRouter() get_calculation = APIRouter() @@ -176,7 +177,7 @@ async def get_simulation_result( ) -@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))]) +@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)]) async def update_selected_equipment( db_session: DbSession, calculation_id, @@ -194,4 +195,4 @@ async def update_selected_equipment( return StandardResponse( data=results, message="Data retrieved successfully", - ) + ) \ No newline at end of file diff --git a/src/config.py b/src/config.py index f8a7896..e75959e 100644 --- a/src/config.py +++ b/src/config.py @@ -93,4 +93,7 @@ API_KEY = config("API_KEY") TR_RBD_ID = config("TR_RBD_ID") TC_RBD_ID = config("TC_RBD_ID") DEFAULT_TC_ID = config("DEFAULT_TC_ID") -RATELIMIT_STORAGE_URI = f"redis://{config('REDIS_HOST')}:{config('REDIS_PORT')}" \ No newline at end of file +REDIS_HOST = config("REDIS_HOST") +REDIS_PORT = config("REDIS_PORT", cast=int) +RATELIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}" +REDIS_AUTH_DB = config("REDIS_AUTH_DB", cast=int) \ No newline at end of file diff --git a/src/csrf_protect.py b/src/csrf_protect.py index 728b231..6cae4ae 100644 --- a/src/csrf_protect.py +++ b/src/csrf_protect.py @@ -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): +# 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, +) + + +async def csrf_protect(request: Request) -> None: """ - CSRF Protection Middleware using Double Submit Cookie pattern. - - 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. + FastAPI dependency untuk verifikasi CSRF token berbasis Redis. + Harus dikombinasikan dengan JWTBearer() dependency. """ - - 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 + # ── 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, + ) + + diff --git a/src/equipment_sparepart/router.py b/src/equipment_sparepart/router.py index f8642ee..36b5de6 100644 --- a/src/equipment_sparepart/router.py +++ b/src/equipment_sparepart/router.py @@ -1,18 +1,19 @@ from typing import Dict, List -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from src.database.core import CollectorDbSession from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate) from src.models import StandardResponse +from src.auth.access_control import require_any_role, ALLOWED_ROLES from .schema import (ScopeEquipmentActivityCreate, ScopeEquipmentActivityPagination, ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate) from .service import get_all -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get("/{location_tag}", response_model=StandardResponse[List[Dict]]) diff --git a/src/main.py b/src/main.py index f450184..f7e969c 100644 --- a/src/main.py +++ b/src/main.py @@ -31,7 +31,6 @@ from src.enums import ResponseStatus from src.exceptions import handle_exception from src.logging import configure_logging from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware -from src.csrf_protect import CSRFProtectMiddleware from src.rate_limiter import limiter from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException @@ -137,10 +136,10 @@ security_headers_middleware(app) app.add_middleware(RequestValidationMiddleware) -app.add_middleware( - CSRFProtectMiddleware, - excluded_paths=["/healthcheck"] -) +# app.add_middleware( +# CSRFProtectMiddleware, +# excluded_paths=["/healthcheck"] +# ) diff --git a/src/overhaul_activity/router.py b/src/overhaul_activity/router.py index 4affb20..d7c6ffb 100644 --- a/src/overhaul_activity/router.py +++ b/src/overhaul_activity/router.py @@ -1,18 +1,20 @@ from typing import List, Optional from uuid import UUID -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status +from src.csrf_protect import csrf_protect from src.database.core import CollectorDbSession from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate) from src.models import StandardResponse +from src.auth.access_control import require_any_role, ALLOWED_ROLES from .schema import (OverhaulActivityCreate, OverhaulActivityPagination, OverhaulActivityRead, OverhaulActivityUpdate) from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get( @@ -109,6 +111,7 @@ async def get_overhaul_equipment( @router.post( "/delete/{overhaul_session}/{location_tag}", response_model=StandardResponse[None], + dependencies=[Depends(csrf_protect)], ) async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID): await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag) diff --git a/src/overhaul_gantt/router.py b/src/overhaul_gantt/router.py index 966e6b1..08e704e 100644 --- a/src/overhaul_gantt/router.py +++ b/src/overhaul_gantt/router.py @@ -1,10 +1,11 @@ import re from typing import List, Optional -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from src.auth.service import CurrentUser +from src.csrf_protect import csrf_protect from src.database.core import DbSession from src.database.service import CommonParameters from src.models import StandardResponse @@ -64,7 +65,7 @@ async def get_gantt_spreadsheet(db_session: DbSession): ) @router.post( - "/spreadsheet", response_model=StandardResponse[dict] + "/spreadsheet", response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)] ) async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn): """Get all scope pagination.""" diff --git a/src/overhaul_scope/router.py b/src/overhaul_scope/router.py index 0687e62..93a69be 100644 --- a/src/overhaul_scope/router.py +++ b/src/overhaul_scope/router.py @@ -11,10 +11,11 @@ from src.models import StandardResponse from .model import OverhaulScope from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate from .service import create, delete, get, get_all, update,get_all_oh_with_history_service -from src.auth.access_control import required_permission, OHPermission +from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES +from src.csrf_protect import csrf_protect from fastapi import Depends -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @@ -64,7 +65,7 @@ async def update_scope( ) -@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope))]) +@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)]) async def delete_scope(db_session: DbSession, scope_id: str): scope = await get(db_session=db_session, overhaul_session_id=scope_id) diff --git a/src/sparepart/router.py b/src/sparepart/router.py index 8498c20..4c05653 100644 --- a/src/sparepart/router.py +++ b/src/sparepart/router.py @@ -1,14 +1,13 @@ -from fastapi import APIRouter, HTTPException, Query, status - +from fastapi import APIRouter, Depends, HTTPException, Query, status +from src.csrf_protect import csrf_protect from src.database.core import CollectorDbSession -from src.database.service import (CommonParameters, DbSession, - search_filter_sort_paginate) +from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate) from src.models import StandardResponse from src.sparepart.schema import SparepartRemark - +from src.auth.access_control import require_any_role, ALLOWED_ROLES from .service import create_remark, get_spareparts_paginated -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get("", response_model=StandardResponse[list]) @@ -17,15 +16,13 @@ async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbS # return data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session) - - return StandardResponse( data=data, message="Data retrieved successfully", ) -@router.post("", response_model=StandardResponse[SparepartRemark]) +@router.post("", response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)]) async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark): sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in) diff --git a/src/standard_scope/router.py b/src/standard_scope/router.py index ce80024..dbf0f40 100644 --- a/src/standard_scope/router.py +++ b/src/standard_scope/router.py @@ -1,19 +1,20 @@ from typing import List, Optional -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.params import Query from src.auth.service import CurrentUser from src.database.core import DbSession, CollectorDbSession from src.database.service import CommonParameters, search_filter_sort_paginate from src.models import StandardResponse +from src.auth.access_control import require_any_role, ALLOWED_ROLES from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate, ScopeEquipmentPagination, ScopeEquipmentRead, ScopeEquipmentUpdate) from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service) from uuid import UUID -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) @router.get("", response_model=StandardResponse[ScopeEquipmentPagination])