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
logs/
.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 jose import jwt, JWTError
import bcrypt as _bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
@@ -12,20 +16,19 @@ JWT_ISSUER = "aegissight-portal"
JWT_AUDIENCE = "aegissight-portal"
def hash_password(password: str) -> str:
return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
def generate_magic_token() -> str:
"""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:
return _bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def create_token(admin_id: int, username: str) -> str:
def create_token(admin_id: int, email: str, username: str = "") -> str:
"""JWT-Session-Token nach erfolgreichem Magic-Link-Verify."""
now = datetime.now(timezone.utc)
expire = now + timedelta(hours=JWT_EXPIRE_HOURS)
payload = {
"sub": str(admin_id),
"username": username,
"email": email,
"username": username or email.split("@")[0],
"role": "portal_admin",
"iss": JWT_ISSUER,
"aud": JWT_AUDIENCE,
@@ -47,7 +50,7 @@ def decode_token(token: str) -> dict:
except JWTError:
raise HTTPException(
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)
return {
"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_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_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)
CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "/home/claude-dev/.claude/local/claude")
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>
</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>
</body>
</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
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")

Datei anzeigen

@@ -1,17 +1,25 @@
"""Pydantic Models fuer das Verwaltungsportal."""
from pydantic import BaseModel, Field
"""Pydantic Models für das Verwaltungsportal."""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class LoginRequest(BaseModel):
username: str
password: str
class MagicLinkRequest(BaseModel):
email: str = Field(min_length=3, max_length=200)
class MagicLinkResponse(BaseModel):
message: str
class VerifyTokenRequest(BaseModel):
token: str = Field(min_length=10, max_length=200)
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
email: str = ""
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>
<meta charset="UTF-8">
<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="apple-touch-icon" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
@@ -16,68 +17,143 @@
<p class="subtitle">Monitor-Verwaltung</p>
</div>
<form id="loginForm" class="login-form">
<!-- Schritt 1: Email-Eingabe -->
<form id="magicForm" class="login-form">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<label for="email">E-Mail-Adresse</label>
<input type="email" id="email" name="email" required autocomplete="email" autofocus
placeholder="info@aegis-sight.de">
</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>
<!-- 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>
<script>
const form = document.getElementById('loginForm');
const errorEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
<style>
.spinner {
width: 36px; height: 36px;
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) => {
e.preventDefault();
errorEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Anmeldung...';
btn.textContent = 'Sende...';
try {
const res = await fetch('/api/auth/login', {
const res = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
body: JSON.stringify({ email: document.getElementById('email').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) {
const data = await res.json();
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || `Fehler ${res.status}`);
}
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('username', data.username);
window.location.href = '/dashboard';
// Erfolg (oder generisch): Bestätigungsanzeige
form.style.display = 'none';
sentInfo.style.display = '';
} catch (err) {
errorEl.textContent = err.message;
errorEl.style.display = 'block';
showError(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Anmelden';
btn.textContent = 'Login-Link anfordern';
}
});
// Redirect if already logged in
if (localStorage.getItem('token')) {
// --- Token aus URL verifizieren (Schritt 3) ---
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';
} else {
verifyTokenFromUrl();
}
</script>
</body>