"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung.""" from __future__ import annotations import json from dataclasses import dataclass from datetime import datetime, timezone from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status from app.db import get_db from app.rbac import user_has_role, user_has_role_for_host from app.security.api_tokens import TOKEN_PREFIX, hash_token, token_has_scope from app.security.sessions import ( SESSION_COOKIE_NAME, decode_session_token, is_expired, refresh_session_token, ) @dataclass class CurrentUser: id: int username: str is_admin: bool # Admin: sieht/verwaltet alle Ressourcen. Ein Token ist NIE # is_admin=True, auch wenn der zugehoerige User selbst Admin ist (siehe unten). is_token: bool = False # Teil D Schritt 5 (S4-Loesung): unterscheidet einen echten Nicht-Admin- # Session-User (is_admin=False, is_token=False -- kommt ueber eine # Hostgruppen-Rolle durch require_admin_scope_or_host_role) von einem # API-Token mit passendem Scope (is_admin=False, is_token=True -- # laut Docstring dort admin-aequivalent FUER SEINEN SCOPE, "kein Token # traegt je eine Hostgruppen-Rolle"). Vor dieser Unterscheidung liessen # sich beide Faelle in einer Dependency nicht auseinanderhalten -- siehe # FORTSETZUNG_Teil_D.md Abschnitt "Bewusst NICHT in Schritt 1 erledigt: # S4" fuer die damals bewusst aufgeschobene Begruendung. Wird in # app/admin/routes.py (map_ssh_key_to_host/assign_rdp_credential_to_host) # verwendet, um die Achse-B-Freigabepruefung NUR fuer echte Nicht-Admin- # Session-User zu erzwingen, nicht fuer Tokens. async def get_current_user( response: Response, jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), ) -> CurrentUser: if jh_session is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet") payload = decode_session_token(jh_session) if payload is None or is_expired(payload): response.delete_cookie(SESSION_COOKIE_NAME) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen oder ungueltig") conn = get_db() cursor = await conn.execute( "SELECT id, username, is_admin, is_active, session_version FROM users " "WHERE id = ? AND deleted_at IS NULL", (payload.user_id,), ) row = await cursor.fetchone() if row is None or not row[3] or row[4] != payload.session_version: response.delete_cookie(SESSION_COOKIE_NAME) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session ungueltig") # Gleitenden Idle-Timeout verlaengern (gleiche session_version/login_ts). new_token = refresh_session_token(payload) response.set_cookie( SESSION_COOKIE_NAME, new_token, httponly=True, secure=True, samesite="strict", max_age=None, # Session-Cookie; Ablauf wird serverseitig durchgesetzt path="/", ) return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: """Wie get_current_user(), aber fuer WebSocket-Handshakes: kein Cookie-Refresh (WebSockets erlauben nach dem Handshake kein Set-Cookie mehr), stattdessen wird der Idle-Timeout beim naechsten regulaeren HTTP-Request durchgesetzt.""" token = websocket.cookies.get(SESSION_COOKIE_NAME) if token is None: return None payload = decode_session_token(token) if payload is None or is_expired(payload): return None conn = get_db() cursor = await conn.execute( "SELECT id, username, is_admin, is_active, session_version FROM users " "WHERE id = ? AND deleted_at IS NULL", (payload.user_id,), ) row = await cursor.fetchone() if row is None or not row[3] or row[4] != payload.session_version: return None return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: """Nur Admin-Session (kein Token). Fuer die maechtigsten Aktionen: Sitzungsverwaltung (/admin/sessions*), API-Dokumentation (/docs).""" if not user.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") return user async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: """Admin, aber ausschliesslich per Session (NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben sollen (z.B. die Token-Verwaltung selbst). Das ist der Privilege-Escalation-Schutz der Token-Verwaltung: ein geleaktes Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.""" if not user.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") return user def require_host_group_role(role_name: str): """Dependency-Factory: prueft Rolle des Users fuer eine per Pfad-/Query-Param uebergebene host_group_id. Globale Admins duerfen immer.""" async def _dep(host_group_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: if user.is_admin: return user conn = get_db() allowed = await user_has_role( conn, user_id=user.id, host_group_id=host_group_id, role_name=role_name ) if not allowed: raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Hostgruppe") return user return _dep def require_host_role(role_name: str): """Dependency-Factory: prueft Rolle des Users fuer einen konkreten host_id.""" async def _dep(host_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: if user.is_admin: return user conn = get_db() allowed = await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name=role_name ) if not allowed: raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diesen Host") return user return _dep # --- API-Tokens (siehe app/security/api_tokens.py, Migration 0005) ------------- # # Tokens sind bewusst NUR fuer die Admin-/Verwaltungs-API gedacht (Hosts, # User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben # ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session # gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope, # auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). Die # Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber # require_admin_session (reine Session-Aktion), niemals ueber ein Token -- ein # geleaktes Token darf sich damit nicht selbst weitere/staerkere Tokens # ausstellen. async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None: if not token.startswith(TOKEN_PREFIX): return None conn = get_db() token_hash = hash_token(token) cursor = await conn.execute( "SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, " "u.username, u.is_active " "FROM api_tokens at JOIN users u ON u.id = at.user_id " "WHERE at.token_hash = ? AND u.deleted_at IS NULL", (token_hash,), ) row = await cursor.fetchone() if row is None: return None token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row if revoked_at is not None or not is_active: return None now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") if expires_at is not None and expires_at <= now_str: return None scopes = json.loads(scopes_json) if not token_has_scope(scopes, resource, action): return None await conn.execute( "UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (token_id,), ) await conn.commit() return CurrentUser(id=user_id, username=username, is_admin=False, is_token=True) def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]): """Wie require_admin_or_scope, erlaubt zusaetzlich einen regulaeren (Nicht-Admin-)User per Session, wenn er fuer den per Pfadparameter 'host_id' angegebenen Host mindestens eine der uebergebenen Hostgruppen- Rollen besitzt (siehe RBAC-Erweiterung 'Credentials ins RBAC-Modell': Rollen 'credentials_view'/'credentials_manage', app/models/schemas.py). Token-Auth bleibt unveraendert ausschliesslich ueber den Admin-Scope moeglich -- kein Token traegt je eine Hostgruppen-Rolle.""" async def _dep( host_id: int, response: Response, jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), authorization: str | None = Header(default=None), ) -> CurrentUser: if authorization and authorization.startswith("Bearer "): token = authorization.removeprefix("Bearer ").strip() principal = await _validate_api_token(token, resource=resource, action=action) if principal is None: raise HTTPException( status.HTTP_401_UNAUTHORIZED, "Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token", ) return principal user = await get_current_user(response, jh_session) if user.is_admin: return user conn = get_db() for role_name in role_names: if await user_has_role_for_host(conn, user_id=user.id, host_id=host_id, role_name=role_name): return user raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Zugangsdaten") return _dep def require_admin_or_scope(resource: str, action: str): """Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber eine eingeloggte Admin-Session (Cookie) ODER ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer '). 'write' schliesst 'read' auf derselben Ressource ein (siehe token_has_scope).""" async def _dep( response: Response, jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), authorization: str | None = Header(default=None), ) -> CurrentUser: if authorization and authorization.startswith("Bearer "): token = authorization.removeprefix("Bearer ").strip() principal = await _validate_api_token(token, resource=resource, action=action) if principal is None: raise HTTPException( status.HTTP_401_UNAUTHORIZED, "Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token", ) return principal user = await get_current_user(response, jh_session) if not user.is_admin: raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") return user return _dep