# backend/auth/middleware.py # FastAPI dependencies for authentication and authorization. # Reads JWT from Authorization header OR access_token cookie. import logging import os import sys from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend.services.net import get_client_ip from .jwt_handler import decode_token, is_token_revoked from .user_store import get_user logger = logging.getLogger("obsigate.auth.middleware") security = HTTPBearer(auto_error=False) #: Hosts considered safe to bind without authentication (loopback only). _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"} def is_auth_enabled() -> bool: """Check if authentication is enabled via environment variable. Default: True (auth enabled). Set OBSIGATE_AUTH_ENABLED=false to disable. """ return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false" def is_insecure_mode_allowed() -> bool: """True when the operator explicitly accepts running without auth (BUG-037).""" return os.environ.get("OBSIGATE_ALLOW_INSECURE", "false").lower() in ("1", "true", "yes", "on") def bind_host_from_argv(argv: list[str] | None = None) -> str | None: """Extract the ``--host`` value from the process arguments (uvicorn), if any. Returns ``None`` when no explicit host is passed (uvicorn then defaults to loopback ``127.0.0.1``). """ args = sys.argv if argv is None else argv for i, arg in enumerate(args): if arg == "--host" and i + 1 < len(args): return args[i + 1] if arg.startswith("--host="): return arg.split("=", 1)[1] return None def is_loopback_host(host: str | None) -> bool: """True when *host* is a loopback address (or unset → uvicorn default).""" if not host: return True normalized = host.strip().strip("[]").lower() return normalized in _LOOPBACK_HOSTS def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security), ) -> dict | None: """Extract and validate the current user from JWT. Reads token from Authorization header first, falls back to access_token cookie. Returns None if no valid token is found. """ # If auth is disabled, return a fake admin user with full access if not is_auth_enabled(): return { "username": "anonymous", "display_name": "Anonymous", "role": "admin", "vaults": ["*"], "active": True, "_token_vaults": ["*"], "_request_ip": get_client_ip(request), } token = None if credentials: token = credentials.credentials elif "access_token" in request.cookies: token = request.cookies["access_token"] if not token: return None payload = decode_token(token) if not payload or payload.get("type") != "access": return None # BUG-027: access tokens revoked at logout must be rejected immediately. jti = payload.get("jti") if jti and is_token_revoked(jti): return None user = get_user(payload["sub"]) if not user or not user.get("active"): return None # BUG-028: a password change invalidates every token issued before it. pca = user.get("password_changed_at") iat = payload.get("iat") if pca is not None and iat is not None: try: if int(iat) < int(float(pca)): return None except (TypeError, ValueError): return None # Attach vault permissions from the token (snapshot at login time) user["_token_vaults"] = payload.get("vaults", []) # Attach the token id for per-token rate limiting (AI tool layer). user["_token_jti"] = payload.get("jti") # BUG-030: expose the real client IP to the audit log. user["_request_ip"] = get_client_ip(request) return user def require_auth(current_user=Depends(get_current_user)): """Dependency: require a valid authenticated user.""" if not current_user: raise HTTPException( status_code=401, detail="Authentification requise", headers={"WWW-Authenticate": "Bearer"}, ) return current_user def require_admin(current_user=Depends(require_auth)): """Dependency: require admin role.""" if current_user.get("role") != "admin": raise HTTPException(status_code=403, detail="Accès admin requis") return current_user def check_vault_access(vault_name: str, user: dict) -> bool: """Check if a user has access to a specific vault. Rules: - vaults == ["*"] → full access (admin default) - vault_name in vaults → access granted - otherwise → denied """ vaults = user.get("_token_vaults") or user.get("vaults", []) if "*" in vaults: return True return vault_name in vaults def require_vault_access(vault_name: str, user: dict = Depends(require_auth)): """Dependency: require access to a specific vault.""" if not check_vault_access(vault_name, user): raise HTTPException( status_code=403, detail=f"Accès refusé à la vault '{vault_name}'", ) return user