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

3
.gitignore vendored
Datei anzeigen

@@ -3,3 +3,6 @@ __pycache__/
.env .env
logs/ logs/
.venv/ .venv/
venv/
data/
*.bak-*

Datei anzeigen

@@ -0,0 +1,91 @@
"""Migration 2026-05-09: Magic-Link-Auth für Verwaltungsportal.
Erstellt zwei Tabellen:
- portal_magic_links: Token-Speicher (E-Mail, Token, Ablauf, used_at)
- portal_magic_link_attempts: Brute-Force-/Rate-Limit-Tracking (IP, E-Mail, ts)
Außerdem:
- portal_login_attempts wird gedroppt (alte Passwort-Login-Tabelle, obsolet)
- portal_admins.password_hash wird auf '' gesetzt (Spalten bleiben für Audit-Spur erhalten)
Ausführung:
DB_PATH=/home/claude-dev/osint-data/osint.db python3 migrations/2026-05-09_portal_magic_link.py
DB_PATH=/home/claude-dev/AegisSight-Monitor-staging/data/osint.db python3 migrations/2026-05-09_portal_magic_link.py
"""
import os
import sqlite3
import sys
def main(db_path: str) -> int:
if not os.path.exists(db_path):
print(f"FEHLER: DB nicht gefunden: {db_path}", file=sys.stderr)
return 1
conn = sqlite3.connect(db_path, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
print(f"Migration auf {db_path}")
# 1. Magic-Link-Tabellen anlegen
conn.executescript("""
CREATE TABLE IF NOT EXISTS portal_magic_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
ip_address TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_portal_magic_links_token ON portal_magic_links(token);
CREATE INDEX IF NOT EXISTS idx_portal_magic_links_email ON portal_magic_links(email);
CREATE TABLE IF NOT EXISTS portal_magic_link_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
email TEXT NOT NULL,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_portal_magic_link_attempts_lookup
ON portal_magic_link_attempts(email, ip, ts);
""")
print(" + portal_magic_links angelegt (oder vorhanden)")
print(" + portal_magic_link_attempts angelegt (oder vorhanden)")
# 2. Alte Brute-Force-Tabelle für Passwort-Login droppen
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='portal_login_attempts'"
)
if cur.fetchone():
conn.execute("DROP TABLE portal_login_attempts")
print(" - portal_login_attempts gedroppt (Passwort-Login obsolet)")
else:
print(" = portal_login_attempts war bereits weg")
# 3. portal_admins.email-Spalte hinzufügen (falls noch nicht da) - für künftige Mehr-Admin-Erweiterung
cols = [c[1] for c in conn.execute("PRAGMA table_info(portal_admins)")]
if "email" not in cols:
conn.execute("ALTER TABLE portal_admins ADD COLUMN email TEXT")
print(" + portal_admins.email Spalte hinzugefügt")
else:
print(" = portal_admins.email war bereits da")
# 4. password_hash auf leeren String setzen (Spalte bleibt für Audit, aber unbenutzt)
cur = conn.execute("SELECT COUNT(*) FROM portal_admins WHERE password_hash != ''")
if cur.fetchone()[0] > 0:
conn.execute("UPDATE portal_admins SET password_hash = ''")
print(" ~ portal_admins.password_hash geleert (Auth ab jetzt nur per Magic-Link)")
else:
print(" = portal_admins.password_hash war bereits leer")
conn.commit()
conn.close()
print("Migration abgeschlossen.")
return 0
if __name__ == "__main__":
db_path = os.environ.get("DB_PATH", "/home/claude-dev/osint-data/osint.db")
sys.exit(main(db_path))

Datei anzeigen

@@ -1,7 +1,11 @@
"""Passwort-basierte Authentifizierung fuer das Verwaltungsportal.""" """Magic-Link-Authentifizierung für das Verwaltungsportal.
JWT für Session, Magic-Link an info@aegis-sight.de zur Anmeldung.
Passwort-Login wurde mit Migration 2026-05-09 entfernt.
"""
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError from jose import jwt, JWTError
import bcrypt as _bcrypt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
@@ -12,20 +16,19 @@ JWT_ISSUER = "aegissight-portal"
JWT_AUDIENCE = "aegissight-portal" JWT_AUDIENCE = "aegissight-portal"
def hash_password(password: str) -> str: def generate_magic_token() -> str:
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8") """Erzeugt einen URL-sicheren Token (43 Zeichen) für den Magic-Link."""
return secrets.token_urlsafe(32)
def verify_password(password: str, password_hash: str) -> bool: def create_token(admin_id: int, email: str, username: str = "") -> str:
return _bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) """JWT-Session-Token nach erfolgreichem Magic-Link-Verify."""
def create_token(admin_id: int, username: str) -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expire = now + timedelta(hours=JWT_EXPIRE_HOURS) expire = now + timedelta(hours=JWT_EXPIRE_HOURS)
payload = { payload = {
"sub": str(admin_id), "sub": str(admin_id),
"username": username, "email": email,
"username": username or email.split("@")[0],
"role": "portal_admin", "role": "portal_admin",
"iss": JWT_ISSUER, "iss": JWT_ISSUER,
"aud": JWT_AUDIENCE, "aud": JWT_AUDIENCE,
@@ -47,7 +50,7 @@ def decode_token(token: str) -> dict:
except JWTError: except JWTError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token ungueltig oder abgelaufen", detail="Token ungültig oder abgelaufen",
) )
@@ -57,5 +60,6 @@ async def get_current_admin(
payload = decode_token(credentials.credentials) payload = decode_token(credentials.credentials)
return { return {
"id": int(payload["sub"]), "id": int(payload["sub"]),
"username": payload["username"], "email": payload.get("email", ""),
"username": payload.get("username", ""),
} }

Datei anzeigen

@@ -27,10 +27,20 @@ SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", "noreply@aegis-sight.de")
SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AegisSight Verwaltung") SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AegisSight Verwaltung")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
# Magic Link Base URL (fuer OSINT-Monitor Einladungen) # Magic Link Base URL (fuer Einladungen Richtung OSINT-Monitor, NICHT Portal-Login)
MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://monitor.aegis-sight.de") MAGIC_LINK_BASE_URL = os.environ.get("MAGIC_LINK_BASE_URL", "https://monitor.aegis-sight.de")
MAGIC_LINK_EXPIRE_MINUTES = 10 MAGIC_LINK_EXPIRE_MINUTES = 10
# Magic-Link-Auth fuer das Verwaltungsportal SELBST
# (frueher Passwort-Login, ab 2026-05-09 nur noch Magic-Link)
ALLOWED_EMAIL = os.environ.get("PORTAL_ALLOWED_EMAIL", "info@aegis-sight.de")
PORTAL_MAGIC_LINK_BASE_URL = os.environ.get(
"PORTAL_MAGIC_LINK_BASE_URL", "https://monitor-verwaltung.aegis-sight.de"
)
PORTAL_MAGIC_LINK_EXPIRE_MINUTES = int(
os.environ.get("PORTAL_MAGIC_LINK_EXPIRE_MINUTES", "10")
)
# Source Discovery (geteilte Config mit OSINT-Monitor) # Source Discovery (geteilte Config mit OSINT-Monitor)
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude") CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
CLAUDE_TIMEOUT = 300 CLAUDE_TIMEOUT = 300

Datei anzeigen

@@ -29,7 +29,41 @@ def invite_email(username: str, org_name: str, code: str, link: str) -> tuple[st
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Einladung annehmen</a> <a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Einladung annehmen</a>
</div> </div>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gueltig.</p> <p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gültig.</p>
</div>
</body>
</html>"""
return subject, html
def portal_magic_link_email(link: str, expire_minutes: int) -> tuple[str, str]:
"""Erzeugt Login-E-Mail mit Magic-Link für das Verwaltungsportal.
Args:
link: Login-URL inkl. Token
expire_minutes: Gültigkeitsdauer in Minuten
Returns:
(subject, html_body)
"""
subject = "AegisSight Verwaltung - Anmeldung"
html = f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 40px 20px;">
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 24px 0;">AegisSight Verwaltung</h1>
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, um sich am Verwaltungsportal anzumelden:</p>
<div style="text-align: center; margin: 0 0 24px 0;">
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #94a3b8; font-size: 13px; margin: 0 0 12px 0;">Oder kopieren Sie diesen Link in Ihren Browser:</p>
<p style="color: #64748b; font-size: 11px; word-break: break-all; margin: 0 0 24px 0;">{link}</p>
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist {expire_minutes} Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div> </div>
</body> </body>
</html>""" </html>"""

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 import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status, Request from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import STATIC_DIR, PORT from config import STATIC_DIR, PORT
from database import db_dependency from routers import auth, organizations, licenses, users, dashboard, sources, token_usage, audit
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
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -21,11 +19,6 @@ logging.basicConfig(
) )
logger = logging.getLogger("verwaltung") logger = logging.getLogger("verwaltung")
# Brute-Force-Schutz
MAX_FAILED_ATTEMPTS = 5
BLOCK_WINDOW_MINUTES = 15
PURGE_AFTER_HOURS = 24
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -36,11 +29,12 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="AegisSight Verwaltungsportal", title="AegisSight Verwaltungsportal",
version="1.0.0", version="2.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
# --- Routen --- # --- Routen ---
app.include_router(auth.router)
app.include_router(organizations.router) app.include_router(organizations.router)
app.include_router(licenses.router) app.include_router(licenses.router)
app.include_router(users.router) app.include_router(users.router)
@@ -49,86 +43,6 @@ app.include_router(sources.router)
app.include_router(token_usage.router) app.include_router(token_usage.router)
app.include_router(audit.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 --- # --- Statische Dateien ---
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

Datei anzeigen

@@ -1,17 +1,25 @@
"""Pydantic Models fuer das Verwaltungsportal.""" """Pydantic Models für das Verwaltungsportal."""
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
from typing import Optional from typing import Optional
class LoginRequest(BaseModel): class MagicLinkRequest(BaseModel):
username: str email: str = Field(min_length=3, max_length=200)
password: str
class MagicLinkResponse(BaseModel):
message: str
class VerifyTokenRequest(BaseModel):
token: str = Field(min_length=10, max_length=200)
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
username: str username: str
email: str = ""
class OrgCreate(BaseModel): class OrgCreate(BaseModel):

191
src/routers/auth.py Normale Datei
Datei anzeigen

@@ -0,0 +1,191 @@
"""Magic-Link-Authentifizierung."""
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
import aiosqlite
from auth import generate_magic_token, create_token
from config import (
ALLOWED_EMAIL,
PORTAL_MAGIC_LINK_BASE_URL,
PORTAL_MAGIC_LINK_EXPIRE_MINUTES,
)
from database import db_dependency
from email_utils.sender import send_email
from email_utils.templates import portal_magic_link_email
from models import MagicLinkRequest, MagicLinkResponse, TokenResponse, VerifyTokenRequest
from audit import log_action, get_client_ip
logger = logging.getLogger("verwaltung.auth")
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Rate-Limit: max N Magic-Link-Anfragen pro Email/IP-Kombination im Zeitfenster
RATE_LIMIT_PER_WINDOW = 5
RATE_LIMIT_WINDOW_MINUTES = 15
ATTEMPTS_PURGE_AFTER_HOURS = 24
# Generische Antwort - keine Rückschlüsse auf gültige Emails (Anti-Enumeration)
GENERIC_RESPONSE = MagicLinkResponse(
message="Wenn die E-Mail-Adresse berechtigt ist, wurde ein Login-Link gesendet."
)
@router.post("/magic-link", response_model=MagicLinkResponse)
async def request_magic_link(
data: MagicLinkRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic-Link anfordern. Sendet E-Mail mit zeitlich begrenztem Login-Link."""
email = data.email.lower().strip()
ip = get_client_ip(request)
# Alte Versuche purgen
await db.execute(
f"DELETE FROM portal_magic_link_attempts "
f"WHERE ts < datetime('now', '-{ATTEMPTS_PURGE_AFTER_HOURS} hours')"
)
# Rate-Limit prüfen
cur = await db.execute(
f"""SELECT COUNT(*) AS cnt FROM portal_magic_link_attempts
WHERE email = ? AND ip = ?
AND ts > datetime('now', '-{RATE_LIMIT_WINDOW_MINUTES} minutes')""",
(email, ip),
)
attempts = (await cur.fetchone())["cnt"]
# Versuch immer eintragen (auch wenn rate-limited oder Email nicht erlaubt)
await db.execute(
"INSERT INTO portal_magic_link_attempts (ip, email) VALUES (?, ?)",
(ip, email),
)
await db.commit()
if attempts >= RATE_LIMIT_PER_WINDOW:
logger.warning(f"Rate-Limit erreicht für {email} von {ip}: {attempts} Versuche")
return GENERIC_RESPONSE
# Whitelist-Check (still gegen Enumeration)
if email != ALLOWED_EMAIL.lower():
logger.info(f"Magic-Link-Anfrage für nicht erlaubte Email: {email} von {ip}")
return GENERIC_RESPONSE
# Token erzeugen
token = generate_magic_token()
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=PORTAL_MAGIC_LINK_EXPIRE_MINUTES)
).strftime("%Y-%m-%d %H:%M:%S")
# Vorige unbenutzte Tokens für diese Email entwerten (mehrfaches Anfordern)
await db.execute(
"UPDATE portal_magic_links SET used_at = CURRENT_TIMESTAMP "
"WHERE email = ? AND used_at IS NULL",
(email,),
)
await db.execute(
"""INSERT INTO portal_magic_links (email, token, expires_at, ip_address)
VALUES (?, ?, ?, ?)""",
(email, token, expires_at, ip),
)
await db.commit()
# E-Mail versenden
link = f"{PORTAL_MAGIC_LINK_BASE_URL}/?token={token}"
subject, html = portal_magic_link_email(link, PORTAL_MAGIC_LINK_EXPIRE_MINUTES)
sent = await send_email(email, subject, html)
if not sent:
logger.error(f"E-Mail-Versand fehlgeschlagen für {email}")
# Wir geben trotzdem die generische Antwort zurück, damit Angreifer
# SMTP-Fehler nicht von "Email nicht erlaubt" unterscheiden können
return GENERIC_RESPONSE
@router.post("/verify", response_model=TokenResponse)
async def verify_magic_link(
data: VerifyTokenRequest,
request: Request,
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Magic-Link-Token verifizieren, JWT-Session zurückgeben."""
ip = get_client_ip(request)
cur = await db.execute(
"""SELECT id, email, expires_at, used_at
FROM portal_magic_links
WHERE token = ?""",
(data.token,),
)
ml = await cur.fetchone()
if not ml:
raise HTTPException(status_code=400, detail="Ungültiger Login-Link")
if ml["used_at"] is not None:
raise HTTPException(
status_code=400, detail="Login-Link bereits verwendet. Bitte neuen anfordern."
)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > expires:
raise HTTPException(
status_code=400, detail="Login-Link abgelaufen. Bitte neuen anfordern."
)
email = ml["email"]
if email.lower() != ALLOWED_EMAIL.lower():
# Defense-in-depth: sollte nie passieren, da Einreichung schon Whitelist prüft
raise HTTPException(status_code=403, detail="Nicht berechtigt")
# Admin-Datensatz holen oder anlegen
cur = await db.execute(
"SELECT id, username, email FROM portal_admins WHERE LOWER(email) = ?",
(email.lower(),),
)
admin = await cur.fetchone()
if not admin:
# Beim ersten erfolgreichen Login mit dieser Email einen Admin-Eintrag erzeugen,
# falls noch keiner existiert (z.B. nach Migration). Username = local-part der E-Mail.
username = email.split("@")[0]
cur = await db.execute(
"""INSERT INTO portal_admins (username, password_hash, email)
VALUES (?, '', ?)""",
(username, email),
)
admin_id = cur.lastrowid
admin_username = username
await db.commit()
logger.info(f"Neuer portal_admin angelegt für {email} (id={admin_id})")
else:
admin_id = admin["id"]
admin_username = admin["username"]
# Token als verwendet markieren
await db.execute(
"UPDATE portal_magic_links SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
(ml["id"],),
)
await db.commit()
# Audit
await log_action(
db,
admin={"id": admin_id, "username": admin_username},
ip=ip,
action="login_success",
resource_type="auth",
after={"email": email, "method": "magic_link"},
)
await db.commit()
jwt_token = create_token(admin_id, email, admin_username)
return TokenResponse(
access_token=jwt_token,
username=admin_username,
email=email,
)

Datei anzeigen

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AegisSight Monitor-Verwaltung - Login</title> <meta name="robots" content="noindex, nofollow">
<title>AegisSight Monitor-Verwaltung - Anmeldung</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="apple-touch-icon" href="/static/favicon.svg"> <link rel="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
@@ -16,68 +17,143 @@
<p class="subtitle">Monitor-Verwaltung</p> <p class="subtitle">Monitor-Verwaltung</p>
</div> </div>
<form id="loginForm" class="login-form"> <!-- Schritt 1: Email-Eingabe -->
<form id="magicForm" class="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Benutzername</label> <label for="email">E-Mail-Adresse</label>
<input type="text" id="username" name="username" required autocomplete="username" autofocus> <input type="email" id="email" name="email" required autocomplete="email" autofocus
</div> placeholder="info@aegis-sight.de">
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div> </div>
<div id="loginError" class="error-msg" style="display:none"></div> <div id="loginError" class="error-msg" style="display:none"></div>
<button type="submit" class="btn btn-primary btn-full" id="loginBtn">Anmelden</button> <button type="submit" class="btn btn-primary btn-full" id="magicBtn">Login-Link anfordern</button>
<p class="form-hint" style="margin-top:14px;text-align:center;font-size:12px;color:#94a3b8;">
Wir senden dir einen einmaligen Login-Link per E-Mail.
</p>
</form> </form>
<!-- Schritt 2: Bestätigung nach Versand -->
<div id="sentInfo" style="display:none;text-align:center;">
<div style="font-size:42px;margin:8px 0 16px 0;">&#9993;</div>
<h2 style="font-size:17px;margin:0 0 8px 0;">E-Mail unterwegs</h2>
<p style="margin:0 0 18px 0;font-size:14px;color:#94a3b8;line-height:1.5;">
Wenn die Adresse berechtigt ist, hast du gleich einen Login-Link in deinem Posteingang.
Der Link ist 10 Minuten gültig.
</p>
<button type="button" class="btn btn-secondary btn-full" onclick="resetForm()">Andere E-Mail-Adresse</button>
</div>
<!-- Schritt 3: Verify (während Token-Prüfung) -->
<div id="verifying" style="display:none;text-align:center;">
<div class="spinner" style="margin:8px auto 16px;"></div>
<h2 style="font-size:17px;margin:0 0 8px 0;">Anmeldung wird geprüft...</h2>
<p style="margin:0;font-size:14px;color:#94a3b8;">Einen Moment bitte.</p>
</div>
</div> </div>
</div> </div>
<script> <style>
const form = document.getElementById('loginForm'); .spinner {
const errorEl = document.getElementById('loginError'); width: 36px; height: 36px;
const btn = document.getElementById('loginBtn'); border: 3px solid rgba(240,180,41,0.2);
border-top-color: #f0b429;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.form-hint { font-size: 12px; color: #94a3b8; margin-top: 8px; }
</style>
<script>
const form = document.getElementById('magicForm');
const sentInfo = document.getElementById('sentInfo');
const verifying = document.getElementById('verifying');
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('magicBtn');
function resetForm() {
sentInfo.style.display = 'none';
verifying.style.display = 'none';
form.style.display = '';
errorEl.style.display = 'none';
document.getElementById('email').value = '';
document.getElementById('email').focus();
}
function showError(msg) {
form.style.display = '';
sentInfo.style.display = 'none';
verifying.style.display = 'none';
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
// --- Magic-Link anfordern ---
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
errorEl.style.display = 'none'; errorEl.style.display = 'none';
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Anmeldung...'; btn.textContent = 'Sende...';
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/magic-link', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ email: document.getElementById('email').value }),
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
}); });
if (res.status === 429) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.');
}
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Anmeldung fehlgeschlagen'); throw new Error(data.detail || `Fehler ${res.status}`);
} }
// Erfolg (oder generisch): Bestätigungsanzeige
const data = await res.json(); form.style.display = 'none';
localStorage.setItem('token', data.access_token); sentInfo.style.display = '';
localStorage.setItem('username', data.username);
window.location.href = '/dashboard';
} catch (err) { } catch (err) {
errorEl.textContent = err.message; showError(err.message);
errorEl.style.display = 'block';
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Anmelden'; btn.textContent = 'Login-Link anfordern';
} }
}); });
// Redirect if already logged in // --- Token aus URL verifizieren (Schritt 3) ---
if (localStorage.getItem('token')) { async function verifyTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) return;
form.style.display = 'none';
verifying.style.display = '';
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Login-Link ungültig');
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('username', data.username);
if (data.email) localStorage.setItem('email', data.email);
// Token aus URL entfernen, damit er nicht im Verlauf liegt
window.history.replaceState({}, '', '/');
window.location.href = '/dashboard';
} catch (err) {
showError(err.message);
// Token aus URL entfernen bei Fehler
window.history.replaceState({}, '', '/');
}
}
// Schon eingeloggt? -> direkt aufs Dashboard
if (localStorage.getItem('token') && !window.location.search.includes('token=')) {
window.location.href = '/dashboard'; window.location.href = '/dashboard';
} else {
verifyTokenFromUrl();
} }
</script> </script>
</body> </body>