Files
ssh-jumphost/app/auth/deps.py
2026-09-02 20:30:44 +02:00

267 lines
11 KiB
Python

"""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 <token>'). '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