Sicherheits-Review 25.08.2026 vor geplanter öffentlicher Erreichbarkeit über Pangolin (bisher nur Pangolin-interne Auth vorgeschaltet): - auth.py: In-Memory-Rate-Limiting auf /login (je Email UND je Quell-IP, 8 Fehlversuche / 10 Minuten), UserManager.validate_password() setzt jetzt eine Mindestlänge von 12 Zeichen durch (fastapi_users' Default war ein No-Op, ließ auch leere Passwörter zu). - routers/admin_kunden.py, routers/organisation.py: fangen InvalidPasswordException/UserAlreadyExists jetzt sauber ab statt 500. - app.py: /docs, /redoc, /openapi.json deaktiviert (legten zuvor die komplette Routenstruktur inkl. /admin/* offen), Security-Header- Middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP). - scripts/reset_password.py: neues Skript, Gegenstück zu create_user.py, für die im selben Review gefundenen aktiven Testaccounts mit dem Passwort 'test123' (einer davon Superuser) gebraucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
12 KiB
Python
366 lines
12 KiB
Python
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
|