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:
73
src/main.py
73
src/main.py
@@ -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"])
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren