diff --git a/.gitignore b/.gitignore index e3c1b57..93afc90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ .env logs/ .venv/ +venv/ +data/ +*.bak-* diff --git a/migrations/2026-05-09_portal_magic_link.py b/migrations/2026-05-09_portal_magic_link.py new file mode 100644 index 0000000..6fd31db --- /dev/null +++ b/migrations/2026-05-09_portal_magic_link.py @@ -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)) diff --git a/src/auth.py b/src/auth.py index 18733d1..2a76a99 100644 --- a/src/auth.py +++ b/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", ""), } diff --git a/src/config.py b/src/config.py index 441bd28..ffcd599 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/email_utils/templates.py b/src/email_utils/templates.py index 732eca9..5a4d3dd 100644 --- a/src/email_utils/templates.py +++ b/src/email_utils/templates.py @@ -29,7 +29,41 @@ def invite_email(username: str, org_name: str, code: str, link: str) -> tuple[st Einladung annehmen -
Dieser Link ist 10 Minuten gueltig.
+Dieser Link ist 10 Minuten gültig.
+ +