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