254 lines
10 KiB
Python
254 lines
10 KiB
Python
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
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 # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
|
|
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
|
|
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
|
|
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
|
|
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
|
|
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
|
|
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
|
|
token_tenant_id: int | None = None
|
|
|
|
@property
|
|
def is_any_admin(self) -> bool:
|
|
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
|
|
return self.is_admin or bool(self.tenant_admin_ids)
|
|
|
|
|
|
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
|
|
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
|
|
rows = await cursor.fetchall()
|
|
return frozenset(r[0] for r in rows)
|
|
|
|
|
|
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="/",
|
|
)
|
|
|
|
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
|
|
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
|
|
|
|
|
|
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 Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
|
|
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
|
|
if not user.is_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
|
|
return user
|
|
|
|
|
|
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
|
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
|
|
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
|
|
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
|
|
if not user.is_any_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), UND
|
|
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
|
|
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
|
|
# ausschliesslich ueber require_admin_session (Session, Super- ODER
|
|
# Mandanten-Admin), 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, at.tenant_id, "
|
|
"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, tenant_id, 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, token_tenant_id=tenant_id)
|
|
|
|
|
|
def require_admin_or_scope(resource: str, action: str):
|
|
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
|
|
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
|
|
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
|
|
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
|
|
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
|
|
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
|
|
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
|
|
|
|
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_any_admin:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
|
|
return user
|
|
|
|
return _dep
|
|
|
|
|
|
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
|
|
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
|
|
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
|
|
Mandanten-IDs, auf die der Principal beschraenkt ist:
|
|
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
|
|
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
|
|
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
|
|
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
|
|
require_admin_session ohnehin nie passieren -- kommt hier praktisch
|
|
nicht an."""
|
|
if admin.token_tenant_id is not None:
|
|
return {admin.token_tenant_id}
|
|
if admin.is_admin:
|
|
return None
|
|
return set(admin.tenant_admin_ids)
|