Audit-Log + Brute-Force-Schutz + unlimited_budget + User-Delete-Fix

- Schema-Migration: ON DELETE SET NULL fuer incidents.created_by, magic_links.user_id,
  network_analyses.created_by (behebt 500er beim User-Loeschen). Neue Spalte
  licenses.unlimited_budget. Neue Tabellen portal_audit_log, portal_login_attempts.
- Audit-Log: alle CREATE/UPDATE/DELETE auf Org/User/Lizenz/Quelle + Login-Events
  werden mit before/after-Diff in portal_audit_log geschrieben.
- Brute-Force-Schutz: 5 Fehlversuche pro IP+Username/15min -> 429 mit Retry-After.
- Token-Budget: expliziter Schalter unlimited_budget pro Lizenz. UI zeigt ehrlich
  >100%-Verbrauch (kein Math.min mehr) und ungebremste Anzeige bei unlimited.
- Neuer Audit-Log Tab mit Filter (Aktion/Ressource/Admin/Zeitraum) und Pagination.
Dieser Commit ist enthalten in:
claude-dev
2026-05-02 20:16:03 +00:00
Ursprung 0da66fb585
Commit 4dc372814d
15 geänderte Dateien mit 1215 neuen und 151 gelöschten Zeilen

Datei anzeigen

@@ -2,7 +2,7 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -10,7 +10,8 @@ from config import STATIC_DIR, PORT
from database import db_dependency
from auth import verify_password, create_token
from models import LoginRequest, TokenResponse
from routers import organizations, licenses, users, dashboard, sources, token_usage
from routers import organizations, licenses, users, dashboard, sources, token_usage, audit
from audit import log_action, get_client_ip
import aiosqlite
@@ -20,6 +21,11 @@ logging.basicConfig(
)
logger = logging.getLogger("verwaltung")
# Brute-Force-Schutz
MAX_FAILED_ATTEMPTS = 5
BLOCK_WINDOW_MINUTES = 15
PURGE_AFTER_HOURS = 24
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -41,25 +47,84 @@ app.include_router(users.router)
app.include_router(dashboard.router)
app.include_router(sources.router)
app.include_router(token_usage.router)
app.include_router(audit.router)
# --- Login ---
@app.post("/api/auth/login", response_model=TokenResponse)
async def login(
data: LoginRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
ip = get_client_ip(request)
username = data.username.strip()
# Alte Login-Versuche purgen (LRU-Style, einmal pro Anfrage)
await db.execute(
f"DELETE FROM portal_login_attempts WHERE ts < datetime('now', '-{PURGE_AFTER_HOURS} hours')"
)
# Brute-Force-Check: Anzahl Fehlversuche fuer (ip, username) im Zeitfenster
cursor = await db.execute(
f"""SELECT COUNT(*) AS cnt FROM portal_login_attempts
WHERE ip = ? AND username = ? AND success = 0
AND ts > datetime('now', '-{BLOCK_WINDOW_MINUTES} minutes')""",
(ip, username),
)
failed_count = (await cursor.fetchone())["cnt"]
if failed_count >= MAX_FAILED_ATTEMPTS:
await log_action(
db, admin=None, ip=ip, action="login_blocked",
resource_type="auth",
after={"username": username, "failed_attempts": failed_count},
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Zu viele Fehlversuche. Bitte {BLOCK_WINDOW_MINUTES} Minuten warten.",
headers={"Retry-After": str(BLOCK_WINDOW_MINUTES * 60)},
)
# Auth-Pruefung
cursor = await db.execute(
"SELECT id, username, password_hash FROM portal_admins WHERE username = ?",
(data.username,),
(username,),
)
admin = await cursor.fetchone()
if not admin or not verify_password(data.password, admin["password_hash"]):
auth_ok = bool(admin and verify_password(data.password, admin["password_hash"]))
# Versuch in Tabelle eintragen (fuer Brute-Force-Tracking)
await db.execute(
"INSERT INTO portal_login_attempts (ip, username, success) VALUES (?, ?, ?)",
(ip, username, 1 if auth_ok else 0),
)
await db.commit()
if not auth_ok:
admin_dict = (
{"id": admin["id"], "username": admin["username"]} if admin else None
)
await log_action(
db, admin=admin_dict, ip=ip, action="login_failed",
resource_type="auth",
after={"username": username},
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungueltige Zugangsdaten",
)
# Erfolg
await log_action(
db,
admin={"id": admin["id"], "username": admin["username"]},
ip=ip,
action="login_success",
resource_type="auth",
)
token = create_token(admin["id"], admin["username"])
return TokenResponse(access_token=token, username=admin["username"])