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.

+ + +""" + 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""" + + + +
+

AegisSight Verwaltung

+ +

Klicken Sie auf den Button, um sich am Verwaltungsportal anzumelden:

+ +
+ Jetzt anmelden +
+ +

Oder kopieren Sie diesen Link in Ihren Browser:

+

{link}

+ +

Dieser Link ist {expire_minutes} Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.

""" diff --git a/src/main.py b/src/main.py index bc12811..127236b 100644 --- a/src/main.py +++ b/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") diff --git a/src/models.py b/src/models.py index b9b8390..dc1acb4 100644 --- a/src/models.py +++ b/src/models.py @@ -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): diff --git a/src/routers/auth.py b/src/routers/auth.py new file mode 100644 index 0000000..09e8457 --- /dev/null +++ b/src/routers/auth.py @@ -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, + ) diff --git a/src/static/index.html b/src/static/index.html index 90db2a6..e6132a9 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -3,7 +3,8 @@ - AegisSight Monitor-Verwaltung - Login + + AegisSight Monitor-Verwaltung - Anmeldung @@ -16,68 +17,143 @@

Monitor-Verwaltung

-
+ +
- - -
-
- - + +
- + +

+ Wir senden dir einen einmaligen Login-Link per E-Mail. +

+ + + + + + -