Auth: Verwaltung auf Magic-Link umstellen (Passwort-Login entfernt)

Backend:
- src/routers/auth.py NEU: POST /api/auth/magic-link + POST /api/auth/verify
- src/auth.py: verify_password/hash_password raus, generate_magic_token rein
- src/main.py: alter Login-Endpoint + Brute-Force-Logik raus, neuer auth-Router eingebunden
- src/config.py: ALLOWED_EMAIL + PORTAL_MAGIC_LINK_* hinzu
- src/models.py: LoginRequest raus, MagicLinkRequest etc. rein
- src/email_utils/templates.py: portal_magic_link_email Template

Frontend:
- src/static/index.html: Email-Eingabe statt Passwort, Token-Verify-Logik fuer ?token= aus URL

Datenbank-Migration (migrations/2026-05-09_portal_magic_link.py):
- portal_magic_links + portal_magic_link_attempts neu
- portal_login_attempts gedroppt
- portal_admins.email Spalte hinzu, password_hash geleert

Whitelist info@aegis-sight.de, Rate-Limit 5/15 Min, Anti-Enumeration generische Antwort.
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 02:21:40 +00:00
Ursprung e6fdc5cfa0
Commit 7c741062a9
9 geänderte Dateien mit 482 neuen und 151 gelöschten Zeilen

Datei anzeigen

@@ -1,19 +1,17 @@
"""Verwaltungsportal - FastAPI Anwendung."""
"""Verwaltungsportal - FastAPI Anwendung.
Auth: Magic-Link (analog Monitor). Passwort-Login wurde mit Migration
2026-05-09 entfernt. Erlaubte Email-Adresse(n) sind in config.ALLOWED_EMAIL.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
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, audit
from audit import log_action, get_client_ip
import aiosqlite
from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit
logging.basicConfig(
level=logging.INFO,
@@ -21,11 +19,6 @@ 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):
@@ -36,11 +29,12 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="AegisSight Verwaltungsportal",
version="1.0.0",
version="2.0.0",
lifespan=lifespan,
)
# --- Routen ---
app.include_router(auth.router)
app.include_router(organizations.router)
app.include_router(licenses.router)
app.include_router(users.router)
@@ -49,86 +43,6 @@ 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 = ?",
(username,),
)
admin = await cursor.fetchone()
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"])
# --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")