"""Konten, Anmeldung (Sitzungs-Cookie) und Benutzerverwaltung.""" import hashlib import json import re import secrets import time from collections import defaultdict from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from .config import LINKS as CONFIG_LINKS from .db import get_meta, now_iso, transaction router = APIRouter() SESSION_COOKIE = "p42_session" SESSION_SECONDS = 30 * 24 * 60 * 60 USERNAME = re.compile(r"[A-Za-z0-9._-]{3,32}") MIN_PASSWORD = 8 # ---------- Passwörter ---------- _SCRYPT = {"n": 2**14, "r": 8, "p": 1, "dklen": 32} def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, **_SCRYPT) return f"scrypt${salt.hex()}${digest.hex()}" def verify_password(password: str, stored: str) -> bool: _, salt, digest = stored.split("$") calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), **_SCRYPT) return secrets.compare_digest(calc.hex(), digest) # Für unbekannte Benutzernamen trotzdem rechnen, damit die Antwortzeit nichts verrät _DUMMY_HASH = hash_password(secrets.token_hex(8)) # ---------- Hilfsfunktionen ---------- def user_dict(row) -> dict: return { "id": row["id"], "username": row["username"], "displayName": row["display_name"], "isAdmin": bool(row["is_admin"]), "createdAt": row["created_at"], } def _hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() def _username(value) -> str: v = value.strip() if isinstance(value, str) else "" if not USERNAME.fullmatch(v): raise HTTPException(400, "Benutzername: 3–32 Zeichen, nur Buchstaben, Zahlen, Punkt, Minus und Unterstrich.") return v def _display_name(value, fallback: str = "") -> str: v = value.strip() if isinstance(value, str) else "" v = v or fallback if not v or len(v) > 60: raise HTTPException(400, "Anzeigename fehlt oder ist zu lang (max. 60 Zeichen).") return v def _password(value) -> str: if not isinstance(value, str) or len(value) < MIN_PASSWORD: raise HTTPException(400, f"Das Passwort muss mindestens {MIN_PASSWORD} Zeichen lang sein.") if len(value) > 200: raise HTTPException(400, "Das Passwort ist zu lang.") return value def _start_session(response: Response, request: Request, user_id: int) -> None: token = secrets.token_urlsafe(32) with transaction() as conn: conn.execute("DELETE FROM sessions WHERE expires_at < ?", (time.time(),)) conn.execute( "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", (_hash_token(token), user_id, time.time() + SESSION_SECONDS), ) https = request.headers.get("x-forwarded-proto", request.url.scheme) == "https" response.set_cookie( SESSION_COOKIE, token, max_age=SESSION_SECONDS, httponly=True, samesite="lax", secure=https, path="/", ) def _create_user(conn, username: str, display_name: str, password: str, is_admin: bool) -> int: if conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone(): raise HTTPException(400, "Diesen Benutzernamen gibt es schon.") cur = conn.execute( "INSERT INTO users (username, display_name, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?, ?)", (username, display_name, hash_password(password), int(is_admin), now_iso()), ) return cur.lastrowid # ---------- Abhängigkeiten für geschützte Routen ---------- def current_user(request: Request) -> dict: token = request.cookies.get(SESSION_COOKIE) if token: with transaction() as conn: row = conn.execute( """SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?""", (_hash_token(token), time.time()), ).fetchone() if row: return user_dict(row) raise HTTPException(401, "Nicht angemeldet.") def require_admin(user: dict = Depends(current_user)) -> dict: if not user["isAdmin"]: raise HTTPException(403, "Nur für Administratoren.") return user # ---------- Anmelde-Schutz: max. 5 Fehlversuche in 10 Minuten ---------- _failures: dict[tuple, list[float]] = defaultdict(list) MAX_FAILURES, WINDOW = 5, 600 def _check_rate_limit(key: tuple) -> None: now = time.time() _failures[key] = [t for t in _failures[key] if now - t < WINDOW] if len(_failures[key]) >= MAX_FAILURES: raise HTTPException(429, "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen.") # ---------- Routen: Anmeldung ---------- @router.get("/api/auth/status") def status(request: Request): with transaction() as conn: has_users = conn.execute("SELECT 1 FROM users LIMIT 1").fetchone() is not None try: user = current_user(request) except HTTPException: user = None return {"setupRequired": not has_users, "user": user} @router.post("/api/auth/setup") def setup(request: Request, response: Response, data: dict = Body(...)): """Erstes Konto anlegen – wird Administrator und übernimmt die bisherigen Daten.""" with transaction() as conn: if conn.execute("SELECT 1 FROM users LIMIT 1").fetchone(): raise HTTPException(403, "Es gibt bereits ein Konto.") username = _username(data.get("username")) display_name = _display_name(data.get("displayName"), username) password = _password(data.get("password")) with transaction() as conn: if conn.execute("SELECT 1 FROM users LIMIT 1").fetchone(): raise HTTPException(403, "Es gibt bereits ein Konto.") user_id = _create_user(conn, username, display_name, password, is_admin=True) # Bisherige Projekte ohne Autor diesem Konto zuordnen conn.execute("UPDATE projects SET created_by = ? WHERE created_by IS NULL", (user_id,)) conn.execute("UPDATE projects SET updated_by = ? WHERE updated_by IS NULL", (user_id,)) # Bisherige Links (links.json bzw. config.json) übernehmen legacy = get_meta(conn, "legacy_links") links = json.loads(legacy) if legacy else CONFIG_LINKS for key, url in (links or {}).items(): if url: conn.execute("INSERT OR REPLACE INTO user_links VALUES (?, ?, ?)", (user_id, key, url)) conn.execute("DELETE FROM meta WHERE key = 'legacy_links'") row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() _start_session(response, request, user_id) return user_dict(row) @router.post("/api/auth/login") def login(request: Request, response: Response, data: dict = Body(...)): username = str(data.get("username") or "").strip() password = str(data.get("password") or "") key = (request.client.host if request.client else "", username.lower()) _check_rate_limit(key) with transaction() as conn: row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() if not verify_password(password, row["password_hash"] if row else _DUMMY_HASH) or not row: _failures[key].append(time.time()) raise HTTPException(401, "Benutzername oder Passwort falsch.") _failures.pop(key, None) _start_session(response, request, row["id"]) return user_dict(row) @router.post("/api/auth/logout") def logout(request: Request, response: Response): token = request.cookies.get(SESSION_COOKIE) if token: with transaction() as conn: conn.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),)) response.delete_cookie(SESSION_COOKIE, path="/") return {"ok": True} @router.patch("/api/auth/me") def update_me(request: Request, data: dict = Body(...), user: dict = Depends(current_user)): """Eigenen Anzeigenamen oder eigenes Passwort ändern.""" with transaction() as conn: if "displayName" in data: conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (_display_name(data["displayName"]), user["id"])) if data.get("newPassword"): row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone() if not verify_password(str(data.get("currentPassword") or ""), row["password_hash"]): raise HTTPException(400, "Das aktuelle Passwort stimmt nicht.") conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(_password(data["newPassword"])), user["id"])) # Andere Sitzungen (z. B. auf anderen Geräten) beenden current = _hash_token(request.cookies.get(SESSION_COOKIE, "")) conn.execute("DELETE FROM sessions WHERE user_id = ? AND token_hash != ?", (user["id"], current)) row = conn.execute("SELECT * FROM users WHERE id = ?", (user["id"],)).fetchone() return user_dict(row) # ---------- Routen: Benutzerverwaltung (nur Admins) ---------- @router.get("/api/users") def list_users(_: dict = Depends(require_admin)): with transaction() as conn: rows = conn.execute("SELECT * FROM users ORDER BY display_name COLLATE NOCASE").fetchall() return [user_dict(r) for r in rows] @router.post("/api/users", status_code=201) def create_user(data: dict = Body(...), _: dict = Depends(require_admin)): username = _username(data.get("username")) display_name = _display_name(data.get("displayName"), username) password = _password(data.get("password")) with transaction() as conn: user_id = _create_user(conn, username, display_name, password, bool(data.get("isAdmin"))) row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return user_dict(row) def _ensure_other_admin(conn, user_id: int) -> None: others = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1 AND id != ?", (user_id,)).fetchone()[0] if not others: raise HTTPException(400, "Es muss mindestens ein Administrator übrig bleiben.") @router.patch("/api/users/{user_id}") def update_user(user_id: int, data: dict = Body(...), _: dict = Depends(require_admin)): with transaction() as conn: if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone(): raise HTTPException(404, "Nicht gefunden.") if "username" in data: username = _username(data["username"]) if conn.execute("SELECT 1 FROM users WHERE username = ? AND id != ?", (username, user_id)).fetchone(): raise HTTPException(400, "Diesen Benutzernamen gibt es schon.") conn.execute("UPDATE users SET username = ? WHERE id = ?", (username, user_id)) if "displayName" in data: conn.execute("UPDATE users SET display_name = ? WHERE id = ?", (_display_name(data["displayName"]), user_id)) if "isAdmin" in data: if not data["isAdmin"]: _ensure_other_admin(conn, user_id) conn.execute("UPDATE users SET is_admin = ? WHERE id = ?", (int(bool(data["isAdmin"])), user_id)) if data.get("password"): conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(_password(data["password"])), user_id)) conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return user_dict(row) @router.delete("/api/users/{user_id}") def delete_user(user_id: int, admin: dict = Depends(require_admin)): if user_id == admin["id"]: raise HTTPException(400, "Das eigene Konto kann nicht gelöscht werden.") with transaction() as conn: row = conn.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)).fetchone() if not row: raise HTTPException(404, "Nicht gefunden.") if row["is_admin"]: _ensure_other_admin(conn, user_id) conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) return {"ok": True}