import os import time import uuid from collections import defaultdict, deque from dataclasses import dataclass from types import SimpleNamespace from typing import Optional from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, exceptions, schemas from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy from fastapi_users.db import BaseUserDatabase from db import get_async_database_connection, get_database_connection from templating import templates SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "") COOKIE_NAME = "kundenplattform_session" COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default MIN_PASSWORD_LENGTH = 12 # Login-Rate-Limiting (Sicherheits-Review 25.08.2026): einfacher In-Memory- # Zaehler statt einer zusaetzlichen Abhaengigkeit (slowapi o.ae.) - der # Dienst laeuft laut kundenplattform.service ohne --workers, also als # einzelner uvicorn-Prozess, ein In-Process-Dict ist damit ausreichend (kein # Multi-Worker-Sync-Problem). Ueberlebt keinen Neustart - das ist akzeptabel, # CrowdSec am Pangolin-Proxy ist die persistente/IP-uebergreifende Schicht, # das hier ist die zusaetzliche Anwendungsschicht gegen gezieltes Erraten # eines bekannten Accounts, auch von wechselnden Quell-IPs aus. LOGIN_RATE_LIMIT = 8 LOGIN_RATE_WINDOW_SECONDS = 10 * 60 _login_attempts_je_email: dict[str, deque] = defaultdict(deque) _login_attempts_je_ip: dict[str, deque] = defaultdict(deque) def _client_ip(request: Request) -> str: # Pangolin (Reverse Proxy) setzt X-Forwarded-For; ohne Proxy (z.B. beim # lokalen QEMU-Test) faellt das auf die direkte Verbindung zurueck. weitergeleitet = request.headers.get("x-forwarded-for") if weitergeleitet: return weitergeleitet.split(",")[0].strip() return request.client.host if request.client else "unbekannt" def _rate_limited(zaehler: dict[str, deque], schluessel: str) -> bool: jetzt = time.monotonic() versuche = zaehler[schluessel] while versuche and jetzt - versuche[0] > LOGIN_RATE_WINDOW_SECONDS: versuche.popleft() return len(versuche) >= LOGIN_RATE_LIMIT def _login_rate_limited(email: str, ip: str) -> bool: return _rate_limited(_login_attempts_je_email, email) or _rate_limited(_login_attempts_je_ip, ip) def _record_failed_login(email: str, ip: str) -> None: jetzt = time.monotonic() _login_attempts_je_email[email].append(jetzt) _login_attempts_je_ip[ip].append(jetzt) def _clear_failed_logins(email: str, ip: str) -> None: _login_attempts_je_email.pop(email, None) _login_attempts_je_ip.pop(ip, None) BENUTZER_SPALTEN = ( "id, organization_id, email, hashed_password, is_active, is_superuser, " "ist_organisationsadmin, is_verified" ) @dataclass class User: id: uuid.UUID email: str hashed_password: str organization_id: uuid.UUID is_active: bool = True is_superuser: bool = False ist_organisationsadmin: bool = False is_verified: bool = True class UserCreate(schemas.BaseUserCreate): organization_id: uuid.UUID ist_organisationsadmin: Optional[bool] = False is_verified: Optional[bool] = True def _row_to_user(row) -> User: ( user_id, organization_id, email, hashed_password, is_active, is_superuser, ist_organisationsadmin, is_verified, ) = row # psycopg liefert TEXT-Spalten hier teils als bytes zurück, nicht als str. if isinstance(hashed_password, bytes): hashed_password = hashed_password.decode("utf-8") if isinstance(email, bytes): email = email.decode("utf-8") return User( id=user_id, organization_id=organization_id, email=email, hashed_password=hashed_password, is_active=is_active, is_superuser=is_superuser, ist_organisationsadmin=ist_organisationsadmin, is_verified=is_verified, ) class UserDatabase(BaseUserDatabase[User, uuid.UUID]): def __init__(self, connection): self.connection = connection async def get(self, id: uuid.UUID) -> Optional[User]: async with self.connection.cursor() as cur: await cur.execute(f"SELECT {BENUTZER_SPALTEN} FROM benutzer WHERE id = %s", (id,)) row = await cur.fetchone() return _row_to_user(row) if row else None async def get_by_email(self, email: str) -> Optional[User]: async with self.connection.cursor() as cur: await cur.execute(f"SELECT {BENUTZER_SPALTEN} FROM benutzer WHERE email = %s", (email,)) row = await cur.fetchone() return _row_to_user(row) if row else None async def get_by_oauth_account(self, oauth: str, account_id: str) -> Optional[User]: raise NotImplementedError("OAuth wird nicht unterstützt.") async def create(self, create_dict: dict) -> User: user_id = uuid.uuid4() async with self.connection.cursor() as cur: await cur.execute( """ INSERT INTO benutzer (id, organization_id, email, hashed_password, is_active, is_superuser, ist_organisationsadmin, is_verified) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( user_id, create_dict["organization_id"], create_dict["email"], create_dict["hashed_password"], create_dict.get("is_active", True), create_dict.get("is_superuser", False), create_dict.get("ist_organisationsadmin", False), create_dict.get("is_verified", True), ), ) return await self.get(user_id) async def update(self, user: User, update_dict: dict) -> User: felder = ", ".join(f"{spalte} = %s" for spalte in update_dict) werte = [*update_dict.values(), user.id] async with self.connection.cursor() as cur: await cur.execute(f"UPDATE benutzer SET {felder} WHERE id = %s", werte) return await self.get(user.id) async def delete(self, user: User) -> None: async with self.connection.cursor() as cur: await cur.execute("DELETE FROM benutzer WHERE id = %s", (user.id,)) async def get_user_db(): async with await get_async_database_connection() as connection: yield UserDatabase(connection) class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): reset_password_token_secret = SESSION_SECRET verification_token_secret = SESSION_SECRET async def validate_password(self, password: str, user) -> None: # Sicherheits-Review 25.08.2026: fastapi_users' Default ist ein # No-Op, das lies bislang jedes Passwort (auch leer) zu - live als # `root@default-lab.test` / `test123` (Superuser!) gefunden. if len(password) < MIN_PASSWORD_LENGTH: raise exceptions.InvalidPasswordException( reason=f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein." ) email = getattr(user, "email", None) if email and password.lower() == email.lower(): raise exceptions.InvalidPasswordException( reason="Das Passwort darf nicht mit der E-Mail-Adresse identisch sein." ) async def get_user_manager(user_db: UserDatabase = Depends(get_user_db)): yield UserManager(user_db) cookie_transport = CookieTransport( cookie_name=COOKIE_NAME, cookie_max_age=COOKIE_MAX_AGE, cookie_secure=True, cookie_httponly=True, cookie_samesite="lax", ) def get_jwt_strategy() -> JWTStrategy: return JWTStrategy(secret=SESSION_SECRET, lifetime_seconds=COOKIE_MAX_AGE) auth_backend = AuthenticationBackend( name="jwt-cookie", transport=cookie_transport, get_strategy=get_jwt_strategy, ) fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) _current_user_optional = fastapi_users.current_user(active=True, optional=True) async def get_current_user(user: Optional[User] = Depends(_current_user_optional)) -> User: if user is None: raise HTTPException(status_code=303, headers={"Location": "/login"}) return user async def require_admin(user: User = Depends(get_current_user)) -> User: if not user.is_superuser: raise HTTPException(status_code=403, detail="Kein Admin-Zugriff.") return user async def require_org_admin(user: User = Depends(get_current_user)) -> User: """ Für Self-Service-Kontenverwaltung (/organisation/konten): erlaubt entweder is_superuser (internes Tuxflotte-Personal, soll nicht ausgesperrt sein, falls Support nötig ist) oder ist_organisationsadmin (die neue, auf die eigene Organisation beschränkte Rolle). Bewusst kein Ersatz für require_admin - is_superuser bleibt exklusiv für /admin/*. """ if not (user.is_superuser or user.ist_organisationsadmin): raise HTTPException(status_code=403, detail="Kein Admin-Zugriff für diese Organisation.") return user def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]: """ Kontenliste einer Organisation - gemeinsam genutzt vom internen Admin-Bereich (admin_kunden.py) und der Self-Service-Seite (routers/organisation.py), um die SQL-Abfrage nicht zu duplizieren (siehe ADR-0017 fetch_gruppen()-Reuse-Pattern). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, email, is_superuser, ist_organisationsadmin, created_at FROM benutzer WHERE organization_id = %s ORDER BY email """, (organization_id,), ) ergebnis = [] for benutzer_id, email, is_superuser, ist_organisationsadmin, created_at in cur.fetchall(): # psycopg liefert TEXT-Spalten hier teils als bytes zurück, # nicht deterministisch (dasselbe bekannte Verhalten wie in # _row_to_user()) - live gefunden, als ein daraus # weitergereichter bytes-Wert eine spätere SQL-Abfrage mit # "operator does not exist: text = bytea" zum Absturz brachte. if isinstance(email, bytes): email = email.decode("utf-8") ergebnis.append({ "id": str(benutzer_id), "email": email, "is_superuser": is_superuser, "ist_organisationsadmin": ist_organisationsadmin, "created_at": created_at, }) return ergebnis router = APIRouter() @router.get("/login") async def login_form(request: Request, user: Optional[User] = Depends(_current_user_optional)): if user is not None: return RedirectResponse("/geraete", status_code=303) return templates.TemplateResponse(request, "login.html", {"error": None}) @router.post("/login") async def login_submit( request: Request, email: str = Form(...), password: str = Form(...), user_manager: UserManager = Depends(get_user_manager), ): email_normalisiert = email.strip().lower() ip = _client_ip(request) if _login_rate_limited(email_normalisiert, ip): return templates.TemplateResponse( request, "login.html", {"error": "Zu viele fehlgeschlagene Loginversuche. Bitte in einigen Minuten erneut versuchen."}, status_code=429, ) user = await user_manager.authenticate(SimpleNamespace(username=email, password=password)) if user is None or not user.is_active: _record_failed_login(email_normalisiert, ip) return templates.TemplateResponse( request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401 ) _clear_failed_logins(email_normalisiert, ip) strategy = get_jwt_strategy() token = await strategy.write_token(user) response = RedirectResponse("/geraete", status_code=303) response.set_cookie( COOKIE_NAME, token, max_age=COOKIE_MAX_AGE, path="/", secure=True, httponly=True, samesite="lax", ) return response @router.get("/logout") def logout(): response = RedirectResponse("/login", status_code=303) response.delete_cookie(COOKIE_NAME, path="/") return response