Commits vergleichen
5 Commits
e31536f8f9
...
7c741062a9
| Autor | SHA1 | Datum | |
|---|---|---|---|
|
|
7c741062a9 | ||
|
|
e6fdc5cfa0 | ||
|
|
98b8780248 | ||
|
|
e52202b087 | ||
|
|
670a6617a7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ __pycache__/
|
||||
.env
|
||||
logs/
|
||||
.venv/
|
||||
venv/
|
||||
data/
|
||||
*.bak-*
|
||||
|
||||
166
CLAUDE.md
Normale Datei
166
CLAUDE.md
Normale Datei
@@ -0,0 +1,166 @@
|
||||
# AegisSight-Monitor-Verwaltung
|
||||
|
||||
> Admin-Portal für Mandanten, Lizenzen, Nutzer, Grundquellen und Token-Verbrauch des AegisSight-Monitors
|
||||
|
||||
## Übersicht
|
||||
|
||||
```yaml
|
||||
projekt: AegisSight-Monitor-Verwaltung
|
||||
url: https://monitor-verwaltung.aegis-sight.de
|
||||
server: ssh monitor (46.225.141.13, User: claude-dev)
|
||||
pfad: /home/claude-dev/AegisSight-Monitor-Verwaltung
|
||||
quellcode: /home/claude-dev/AegisSight-Monitor-Verwaltung/src/
|
||||
datenbank: /mnt/gitea/osint-data/osint.db (SQLite WAL, geteilt mit AegisSight-Monitor)
|
||||
gitea: https://gitea-undso.aegis-sight.de/AegisSight/AegisSight-Monitor-Verwaltung
|
||||
service: verwaltungsportal.service (systemd, Port 8892, Nginx Reverse Proxy)
|
||||
venv: /home/claude-dev/.venvs/verwaltung/ (Python 3.12)
|
||||
```
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
framework: FastAPI + Uvicorn
|
||||
datenbank: SQLite WAL (aiosqlite, async) - geteilt mit AegisSight-Monitor
|
||||
auth: Passwort-Login (bcrypt, JWT HS256, 8 Stunden)
|
||||
brute_force_schutz: 5 Fehlversuche pro 15 Minuten Block, Aufräumen nach 24 Stunden
|
||||
audit: jede Mutation via log_action -> portal_audit Tabelle
|
||||
email: aiosmtplib (smtp.ionos.de:587 TLS) - für Magic-Link-Einladungen Richtung Monitor
|
||||
|
||||
frontend:
|
||||
typ: Vanilla JS (kein Framework, kein Build-Step)
|
||||
design: AegisSight Dark Theme (gemeinsame Optik wie Monitor)
|
||||
fonts: Poppins (Titel), Inter (Body)
|
||||
```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```yaml
|
||||
src/:
|
||||
main.py: "FastAPI App, Login + Brute-Force-Logik, Lifespan, statische Routen"
|
||||
config.py: "Konfiguration (DB-Pfad, JWT, SMTP, Source-Discovery-Konstanten)"
|
||||
auth.py: "Passwort-Hash (bcrypt), JWT erstellen/verifizieren, get_current_admin Dependency"
|
||||
database.py: "DB-Connection-Pool, Schema-Helper"
|
||||
models.py: "Pydantic Request/Response-Schemas"
|
||||
audit.py: "log_action, get_client_ip, row_to_dict, /api/audit Router"
|
||||
|
||||
routers/:
|
||||
organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)"
|
||||
licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module)"
|
||||
users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de"
|
||||
sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge"
|
||||
dashboard.py: "Aggregat-Endpoints für Übersichts-Tab"
|
||||
token_usage.py: "Token-Verbrauch pro Org/Monat, Budget-Steuerung"
|
||||
audit.py: "Audit-Log-Abfrage, Filter"
|
||||
|
||||
email_utils/:
|
||||
sender.py: "Async SMTP Versand"
|
||||
templates.py: "HTML-Templates (Magic-Link für neue Nutzer)"
|
||||
|
||||
static/:
|
||||
index.html: "Login (Passwort)"
|
||||
dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Lizenzen, Quellen, Audit)"
|
||||
favicon.svg: "AegisSight Logo"
|
||||
css/: "Stylesheets (Dark Theme)"
|
||||
js/:
|
||||
app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render"
|
||||
sources.js: "Grundquellen + Kundenquellen Management"
|
||||
source-health.js: "Quellen-Health & KI-Vorschläge"
|
||||
audit.js: "Audit-Log Tab"
|
||||
|
||||
migrations/:
|
||||
einmal_migrationen: "Backfill-Skripte (DE-Übersetzungen, Umlaute, HTML-Strip etc.)"
|
||||
```
|
||||
|
||||
## Datenbank-Tabellen (relevant fürs Portal)
|
||||
|
||||
```yaml
|
||||
kern: "organizations, licenses, users, magic_links, portal_admins"
|
||||
quellen: "sources (geteilt mit Monitor), source_health_checks, source_suggestions"
|
||||
verbrauch: "token_usage_monthly"
|
||||
audit: "portal_audit"
|
||||
```
|
||||
|
||||
## Verwandte Projekte
|
||||
|
||||
```yaml
|
||||
monitor:
|
||||
pfad: /home/claude-dev/AegisSight-Monitor
|
||||
url: https://monitor.aegis-sight.de
|
||||
service: aegis-monitor.service (Port 8891)
|
||||
geteilte_db: ja
|
||||
geteilte_module:
|
||||
- source_rules: "Domain-Erkennung, RSS-Discovery, Claude-Feed-Bewertung"
|
||||
- services/source_health: "Health-Check-Logik"
|
||||
- services/source_suggester: "KI-Quellenvorschläge"
|
||||
- agents/claude_client: "Shared Claude CLI Client"
|
||||
hinweis: "Verwaltung importiert diese Module; sys.path-Hacks sollen schrittweise durch eigene Kopien in src/shared/ ersetzt werden"
|
||||
```
|
||||
|
||||
## Regeln
|
||||
|
||||
```yaml
|
||||
regeln:
|
||||
- "Jede Änderung MUSS sofort committed und nach Gitea gepusht werden"
|
||||
- "Echte Umlaute (ü, ä, ö, ß), niemals Umschreibungen (ue, ae, oe, ss) - gilt auch in Code-Kommentaren, Logs, UI-Texten"
|
||||
- "Keine Passwörter oder Secrets in den Code committen (.env nicht im Repo)"
|
||||
- "Service nach Backend-Änderungen: sudo systemctl restart verwaltungsportal"
|
||||
- "Frontend-Änderungen (HTML/JS/CSS) brauchen keinen Neustart"
|
||||
- "Backup-Dateien (.bak) nicht committen, vor Push löschen"
|
||||
- "Code-Fixes immer über develop -> Staging -> Promote, niemals direkt auf main"
|
||||
- "Direkte Live-DB-Patches nur nach Vorab-Ankündigung"
|
||||
```
|
||||
|
||||
## Changelog-Workflow
|
||||
|
||||
Bei JEDER Änderung an dieser Anwendung müssen zwei Dinge passieren:
|
||||
|
||||
1. **TaskMate Wissensdatenbank** (Kategorie: "Changelog Verwaltung", category_id=34)
|
||||
2. **Git Commit + Push zu Gitea**
|
||||
|
||||
Siehe AegisSight-Monitor/CLAUDE.md für vollständiges Beispiel des TaskMate-Aufrufs.
|
||||
|
||||
## Staging-Umgebung
|
||||
|
||||
Wird im Rahmen des Aufräum-Plans (Phase 0) aufgesetzt. Geplante Eckdaten:
|
||||
|
||||
```yaml
|
||||
staging:
|
||||
url: https://staging.monitor-verwaltung.aegis-sight.de
|
||||
server: 46.225.141.13 (gleicher Host wie Live)
|
||||
pfad: /home/claude-dev/AegisSight-Monitor-Verwaltung-staging
|
||||
branch: develop
|
||||
port: 18892 (Live: 8892)
|
||||
service: aegis-verwaltung-staging.service
|
||||
venv: /home/claude-dev/AegisSight-Monitor-Verwaltung-staging/venv (eigenes venv)
|
||||
zugriff: Magic-Link-Login an info@aegis-sight.de (Cookie 30 Tage, vorgelagerter Auth-Service)
|
||||
|
||||
datenbank:
|
||||
plan: eigene SQLite-Kopie der Live-DB in ~/AegisSight-Monitor-Verwaltung-staging/data/osint.db
|
||||
drift: gewollt - Änderungen in Staging beeinflussen Live nicht
|
||||
abstimmung: gemeinsame DB mit Monitor-Staging möglich, wird beim Aufbau entschieden
|
||||
|
||||
auth_service:
|
||||
pfad: /opt/aegis-verwaltung-staging-auth
|
||||
service: aegis-verwaltung-staging-auth.service
|
||||
port: 127.0.0.1:8098 (Monitor-Staging-Auth liegt schon auf 8095)
|
||||
cookie_domain: staging.monitor-verwaltung.aegis-sight.de
|
||||
cookie_name: aegis_verwaltung_staging_auth
|
||||
```
|
||||
|
||||
### Workflow develop -> Staging -> Live (Plan)
|
||||
|
||||
1. **Änderung in develop machen**:
|
||||
```bash
|
||||
cd ~/AegisSight-Monitor-Verwaltung
|
||||
git checkout develop
|
||||
# Änderung
|
||||
git add . && git commit -m '...' && git push origin develop
|
||||
```
|
||||
|
||||
2. **Auto-Deploy** (geplant, Phase 0f): Gitea-Webhook -> aegis-staging-deploy.service -> pullt develop ins Staging-Verzeichnis -> restartet aegis-verwaltung-staging
|
||||
|
||||
3. **Auf https://staging.monitor-verwaltung.aegis-sight.de prüfen**
|
||||
|
||||
4. **Promote zu Live** über https://deploy.aegis-sight.de (Phase 0g)
|
||||
-> Gitea-PR develop->main automerge -> Live-Listener pullt main -> systemctl restart verwaltungsportal
|
||||
91
migrations/2026-05-09_portal_magic_link.py
Normale Datei
91
migrations/2026-05-09_portal_magic_link.py
Normale Datei
@@ -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))
|
||||
@@ -23,19 +23,23 @@ from datetime import datetime
|
||||
# Pfade fuer Imports (Live-Repo bevorzugt, Staging-Fallback)
|
||||
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
|
||||
try:
|
||||
from agents.translator import translate_articles
|
||||
from agents.translator import translate_articles_batch, DEFAULT_BATCH_SIZE
|
||||
from agents.claude_client import UsageAccumulator
|
||||
from services.post_refresh_qc import normalize_german_umlauts
|
||||
except ImportError:
|
||||
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor-staging/src")
|
||||
from agents.translator import translate_articles
|
||||
from agents.translator import translate_articles_batch, DEFAULT_BATCH_SIZE
|
||||
from agents.claude_client import UsageAccumulator
|
||||
from services.post_refresh_qc import normalize_german_umlauts
|
||||
|
||||
|
||||
async def main_async(db_path: str, dry_run: bool, limit: int | None) -> int:
|
||||
db = sqlite3.connect(db_path)
|
||||
db = sqlite3.connect(db_path, timeout=60)
|
||||
db.row_factory = sqlite3.Row
|
||||
# Live-Service haelt regelmaessig den Write-Lock. Statt sofort zu crashen
|
||||
# warten wir bis zu 60 Sekunden auf den Lock.
|
||||
db.execute("PRAGMA busy_timeout = 60000")
|
||||
db.execute("PRAGMA journal_mode = WAL")
|
||||
|
||||
sql = """SELECT id, incident_id, headline, content_original, language
|
||||
FROM articles
|
||||
@@ -60,11 +64,35 @@ async def main_async(db_path: str, dry_run: bool, limit: int | None) -> int:
|
||||
return 0
|
||||
|
||||
usage = UsageAccumulator()
|
||||
translations = await translate_articles(rows, output_lang="de",
|
||||
usage_accumulator=usage)
|
||||
print(f"Uebersetzt: {len(translations)} von {len(rows)}")
|
||||
|
||||
total = len(rows)
|
||||
batch_size = DEFAULT_BATCH_SIZE
|
||||
PARALLEL_WORKERS = 4
|
||||
updated = 0
|
||||
translated = 0
|
||||
sample_translations = []
|
||||
completed_count = 0
|
||||
print(f"Starte parallele Verarbeitung: Batches a {batch_size}, {PARALLEL_WORKERS} Worker parallel...", flush=True)
|
||||
|
||||
# Batches vorbereiten
|
||||
batches = [rows[i:i + batch_size] for i in range(0, total, batch_size)]
|
||||
semaphore = asyncio.Semaphore(PARALLEL_WORKERS)
|
||||
|
||||
async def process_batch(batch):
|
||||
async with semaphore:
|
||||
return await translate_articles_batch(batch)
|
||||
|
||||
# Tasks erstellen und in beliebiger Reihenfolge bearbeiten
|
||||
tasks = [asyncio.create_task(process_batch(b)) for b in batches]
|
||||
n_batches = len(batches)
|
||||
|
||||
for completed_task in asyncio.as_completed(tasks):
|
||||
try:
|
||||
translations, batch_usage = await completed_task
|
||||
except Exception as e:
|
||||
print(f" Batch-Fehler: {e}", flush=True)
|
||||
continue
|
||||
usage.add(batch_usage)
|
||||
translated += len(translations)
|
||||
for t in translations:
|
||||
hd = t.get("headline_de")
|
||||
cd = t.get("content_de")
|
||||
@@ -79,23 +107,33 @@ async def main_async(db_path: str, dry_run: bool, limit: int | None) -> int:
|
||||
(hd, cd, t["id"]),
|
||||
)
|
||||
updated += 1
|
||||
db.commit()
|
||||
if len(sample_translations) < 3:
|
||||
sample_translations.append(t["id"])
|
||||
db.commit() # Per-Batch commit -> bei Abbruch kein Datenverlust
|
||||
|
||||
completed_count += 1
|
||||
if completed_count % 20 == 0 or completed_count == n_batches:
|
||||
print(
|
||||
f"[{completed_count}/{n_batches} Batches | Updates={updated}/{total} | Cost=${usage.total_cost_usd:.2f}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"=== Stats ===")
|
||||
print(f" Updates: {updated}")
|
||||
print(f" Total betrachtet: {total}")
|
||||
print(f" Translator OK: {translated}")
|
||||
print(f" DB-Updates: {updated}")
|
||||
print(f" Calls: {usage.call_count}")
|
||||
print(f" Input-Tokens: {usage.input_tokens:,}")
|
||||
print(f" Output-Tokens: {usage.output_tokens:,}")
|
||||
print(f" Cost gesamt: ${usage.total_cost_usd:.4f}")
|
||||
print()
|
||||
print("=== Stichprobe (3 frische Uebersetzungen) ===")
|
||||
sample_ids = [t["id"] for t in translations[:3]]
|
||||
if sample_ids:
|
||||
placeholders = ",".join("?" * len(sample_ids))
|
||||
if sample_translations:
|
||||
placeholders = ",".join("?" * len(sample_translations))
|
||||
for r in db.execute(
|
||||
f"SELECT id, headline, headline_de FROM articles WHERE id IN ({placeholders})",
|
||||
sample_ids,
|
||||
sample_translations,
|
||||
):
|
||||
d = dict(r)
|
||||
print(f" [{d['id']}]")
|
||||
|
||||
28
src/auth.py
28
src/auth.py
@@ -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", ""),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -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
191
src/routers/auth.py
Normale Datei
@@ -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,
|
||||
)
|
||||
@@ -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;">✉</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>
|
||||
|
||||
<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('loginForm');
|
||||
const form = document.getElementById('magicForm');
|
||||
const sentInfo = document.getElementById('sentInfo');
|
||||
const verifying = document.getElementById('verifying');
|
||||
const errorEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('loginBtn');
|
||||
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.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `Fehler ${res.status}`);
|
||||
}
|
||||
// Erfolg (oder generisch): Bestätigungsanzeige
|
||||
form.style.display = 'none';
|
||||
sentInfo.style.display = '';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Login-Link anfordern';
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Zu viele Fehlversuche. Bitte 15 Minuten warten.');
|
||||
}
|
||||
// --- 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();
|
||||
throw new Error(data.detail || 'Anmeldung fehlgeschlagen');
|
||||
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) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Anmelden';
|
||||
showError(err.message);
|
||||
// Token aus URL entfernen bei Fehler
|
||||
window.history.replaceState({}, '', '/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Redirect if already logged in
|
||||
if (localStorage.getItem('token')) {
|
||||
// Schon eingeloggt? -> direkt aufs Dashboard
|
||||
if (localStorage.getItem('token') && !window.location.search.includes('token=')) {
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
verifyTokenFromUrl();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren