From 338e0824675744f60d8ceae9fc1870563781553f Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 24 Mar 2026 11:57:00 +0100 Subject: [PATCH] Auth: Magic Link Login + Globe-Zugangssteuerung - Magic Link Login (E-Mail + 6-stelliger Code) - JWT-basierte Session (24h) - Prueft: is_active=1 UND globe_access=1 - Akzeptiert auch Monitor-JWT-Tokens (Kompatibilitaet) - Globe-spezifisches E-Mail-Template (Dark Theme) - Alle Daten-APIs hinter Auth-Middleware - Login-Seite mit taktischem Design - Auto-Redirect bei fehlendem/abgelaufenem Token - Fetch-Wrapper injiziert Authorization Header automatisch --- requirements.txt | 4 ++ src/auth.py | 77 +++++++++++++++++++++++ src/auth_router.py | 137 ++++++++++++++++++++++++++++++++++++++++ src/config.py | 17 +++++ src/database.py | 12 ++++ src/email_utils.py | 50 +++++++++++++++ src/main.py | 24 +++++-- static/index.html | 23 ++++++- static/login.html | 152 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 487 insertions(+), 9 deletions(-) create mode 100644 src/auth.py create mode 100644 src/auth_router.py create mode 100644 src/config.py create mode 100644 src/database.py create mode 100644 src/email_utils.py create mode 100644 static/login.html diff --git a/requirements.txt b/requirements.txt index ea3951c..7a11b2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx>=0.27 websockets>=13.0 +aiosqlite>=0.20 +python-jose[cryptography]>=3.3 +aiosmtplib>=3.0 +pydantic[email]>=2.0 diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..21b8f6a --- /dev/null +++ b/src/auth.py @@ -0,0 +1,77 @@ +"""Auth: JWT + Magic Link fuer Globe.""" +import secrets +import string +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS +from database import get_db + +security = HTTPBearer(auto_error=False) + + +def create_token(user_id: int, username: str, email: str, role: str = "member") -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "username": username, + "email": email, + "role": role, + "iss": "aegissight-globe", + "aud": "aegissight-globe", + "iat": now, + "exp": now + timedelta(hours=JWT_EXPIRE_HOURS), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_token(token: str) -> dict: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience="aegissight-globe") + + +# Also accept Monitor tokens (audience: intelsight-osint) +def decode_any_token(token: str) -> dict: + for aud in ["aegissight-globe", "intelsight-osint"]: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM], audience=aud) + except JWTError: + continue + raise JWTError("Invalid token") + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db=Depends(get_db), +) -> dict: + if not credentials: + raise HTTPException(status_code=401, detail="Nicht authentifiziert") + try: + payload = decode_any_token(credentials.credentials) + user_id = int(payload["sub"]) + except (JWTError, KeyError, ValueError): + raise HTTPException(status_code=401, detail="Token ungueltig") + + cursor = await db.execute( + "SELECT id, email, username, is_active, globe_access FROM users WHERE id = ?", + (user_id,), + ) + user = await cursor.fetchone() + if not user: + raise HTTPException(status_code=401, detail="Nutzer nicht gefunden") + if not user["is_active"]: + raise HTTPException(status_code=403, detail="Account deaktiviert") + if not user["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Globe-Zugang") + + return dict(user) + + +def generate_magic_token() -> str: + return secrets.token_urlsafe(48) + + +def generate_magic_code() -> str: + return "".join(secrets.choice(string.digits) for _ in range(6)) diff --git a/src/auth_router.py b/src/auth_router.py new file mode 100644 index 0000000..d282865 --- /dev/null +++ b/src/auth_router.py @@ -0,0 +1,137 @@ +"""Auth-Router: Magic Link Login fuer Globe.""" +import logging +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr + +from auth import create_token, generate_magic_token, generate_magic_code, get_current_user +from config import GLOBE_BASE_URL, MAGIC_LINK_EXPIRE_MINUTES +from database import get_db +from email_utils import send_magic_link_email + +logger = logging.getLogger("globe.auth") +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + email: EmailStr + + +class CodeVerifyRequest(BaseModel): + email: EmailStr + code: str + + +@router.post("/request-link") +async def request_magic_link(req: LoginRequest, db=Depends(get_db)): + """Sendet Magic Link + Code per E-Mail.""" + email = req.email.lower().strip() + + # User pruefen + cursor = await db.execute( + "SELECT id, username, email, is_active, globe_access FROM users WHERE LOWER(email) = ?", + (email,), + ) + user = await cursor.fetchone() + if not user: + raise HTTPException(status_code=404, detail="Kein Account mit dieser E-Mail-Adresse gefunden.") + if not user["is_active"]: + raise HTTPException(status_code=403, detail="Account ist deaktiviert.") + if not user["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Globe-Zugang. Bitte wenden Sie sich an Ihren Administrator.") + + # Magic Token + Code erzeugen + token = generate_magic_token() + code = generate_magic_code() + expires = datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES) + + await db.execute( + """INSERT INTO magic_links (user_id, token, code, expires_at, purpose) + VALUES (?, ?, ?, ?, 'globe_login')""", + (user["id"], token, code, expires.isoformat()), + ) + await db.commit() + + link = f"{GLOBE_BASE_URL}/api/auth/verify?token={token}" + + try: + await send_magic_link_email(email, code, link) + except Exception: + raise HTTPException(status_code=502, detail="E-Mail konnte nicht gesendet werden.") + + logger.info(f"Magic Link gesendet an {email}") + return {"ok": True, "message": "Zugangscode wurde per E-Mail gesendet."} + + +@router.get("/verify") +async def verify_token(token: str, db=Depends(get_db)): + """Verifiziert Magic Link Token, gibt JWT zurueck.""" + cursor = await db.execute( + """SELECT ml.user_id, ml.expires_at, ml.is_used, + u.username, u.email, u.is_active, u.globe_access, u.role + FROM magic_links ml JOIN users u ON ml.user_id = u.id + WHERE ml.token = ? AND ml.purpose = 'globe_login'""", + (token,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Ungueltiger Link.") + if row["is_used"]: + raise HTTPException(status_code=400, detail="Link wurde bereits verwendet.") + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Link ist abgelaufen.") + if not row["is_active"] or not row["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Zugang.") + + await db.execute("UPDATE magic_links SET is_used = 1 WHERE token = ?", (token,)) + await db.execute("UPDATE users SET last_login_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), row["user_id"])) + await db.commit() + + jwt_token = create_token(row["user_id"], row["username"], row["email"], row["role"]) + + # Redirect zum Frontend mit Token als Query-Parameter + from fastapi.responses import HTMLResponse + return HTMLResponse(f""" + + """) + + +@router.post("/verify-code") +async def verify_code(req: CodeVerifyRequest, db=Depends(get_db)): + """Verifiziert 6-stelligen Code, gibt JWT zurueck.""" + email = req.email.lower().strip() + cursor = await db.execute( + """SELECT ml.id, ml.user_id, ml.expires_at, ml.is_used, + u.username, u.email, u.is_active, u.globe_access, u.role + FROM magic_links ml JOIN users u ON ml.user_id = u.id + WHERE ml.code = ? AND LOWER(u.email) = ? AND ml.purpose = 'globe_login' + ORDER BY ml.created_at DESC LIMIT 1""", + (req.code, email), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Ungueltiger Code.") + if row["is_used"]: + raise HTTPException(status_code=400, detail="Code wurde bereits verwendet.") + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + raise HTTPException(status_code=400, detail="Code ist abgelaufen.") + if not row["is_active"] or not row["globe_access"]: + raise HTTPException(status_code=403, detail="Kein Zugang.") + + await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (row["id"],)) + await db.execute("UPDATE users SET last_login_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), row["user_id"])) + await db.commit() + + jwt_token = create_token(row["user_id"], row["username"], row["email"], row["role"]) + return {"ok": True, "token": jwt_token} + + +@router.get("/me") +async def get_me(user: dict = Depends(get_current_user)): + return {"id": user["id"], "email": user["email"], "username": user["username"]} diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..52248f3 --- /dev/null +++ b/src/config.py @@ -0,0 +1,17 @@ +"""Globe Konfiguration.""" +import os + +DB_PATH = os.environ.get("DB_PATH", "/mnt/gitea/osint-data/osint.db") +JWT_SECRET = os.environ.get("JWT_SECRET", "") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 24 + +SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.ionos.de") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "info@aegis-sight.de") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") +SMTP_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", "noreply@aegis-sight.de") +SMTP_FROM_NAME = os.environ.get("SMTP_FROM_NAME", "AegisSight Globe") + +GLOBE_BASE_URL = os.environ.get("GLOBE_BASE_URL", "https://globe.aegis-sight.de") +MAGIC_LINK_EXPIRE_MINUTES = 10 diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..0610ad0 --- /dev/null +++ b/src/database.py @@ -0,0 +1,12 @@ +"""Datenbank-Zugriff auf geteilte osint.db.""" +import aiosqlite +from config import DB_PATH + + +async def get_db(): + db = await aiosqlite.connect(DB_PATH) + db.row_factory = aiosqlite.Row + try: + yield db + finally: + await db.close() diff --git a/src/email_utils.py b/src/email_utils.py new file mode 100644 index 0000000..bc7eca9 --- /dev/null +++ b/src/email_utils.py @@ -0,0 +1,50 @@ +"""E-Mail-Versand fuer Globe Magic Links.""" +import logging +import aiosmtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM_EMAIL, SMTP_FROM_NAME + +logger = logging.getLogger("globe.email") + + +async def send_magic_link_email(to_email: str, code: str, link: str): + """Sendet Magic Link + Code per E-Mail.""" + html = f""" +
+

AEGISSIGHT GLOBE

+

Dein Zugangscode:

+
+ {code} +
+

+ Oder klicke auf diesen Link:
+ {link} +

+

+ Dieser Code ist 10 Minuten gueltig. Falls du diese Anfrage nicht gesendet hast, ignoriere diese E-Mail. +

+
+ """ + + msg = MIMEMultipart("alternative") + msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + msg["To"] = to_email + msg["Subject"] = "AegisSight Globe — Zugangscode" + msg.attach(MIMEText(f"Dein Globe-Zugangscode: {code}\n\nLink: {link}\n\nGueltig fuer 10 Minuten.", "plain")) + msg.attach(MIMEText(html, "html")) + + try: + await aiosmtplib.send( + msg, + hostname=SMTP_HOST, + port=SMTP_PORT, + username=SMTP_USER, + password=SMTP_PASSWORD, + start_tls=True, + ) + logger.info(f"Magic Link E-Mail gesendet an {to_email}") + except Exception as e: + logger.error(f"E-Mail-Versand fehlgeschlagen: {e}") + raise diff --git a/src/main.py b/src/main.py index b1daac7..35488f9 100644 --- a/src/main.py +++ b/src/main.py @@ -3,7 +3,7 @@ import logging import os from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Depends from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse @@ -20,22 +20,34 @@ logger = logging.getLogger("globe") app = FastAPI(title="AegisSight Globe", docs_url=None, redoc_url=None) -# --- Data modules --- +# --- Auth --- +from auth import get_current_user +from auth_router import router as auth_router + +app.include_router(auth_router, prefix="/api") + +# --- Data modules (geschuetzt) --- from data_flights import router as flights_router, start_flight_collector from data_ships import router as ships_router, start_ais_collector from data_quakes import router as quakes_router from data_gdelt import router as gdelt_router -app.include_router(flights_router, prefix="/api") -app.include_router(ships_router, prefix="/api") -app.include_router(quakes_router, prefix="/api") -app.include_router(gdelt_router, prefix="/api") +# Alle Daten-APIs hinter Auth +app.include_router(flights_router, prefix="/api", dependencies=[Depends(get_current_user)]) +app.include_router(ships_router, prefix="/api", dependencies=[Depends(get_current_user)]) +app.include_router(quakes_router, prefix="/api", dependencies=[Depends(get_current_user)]) +app.include_router(gdelt_router, prefix="/api", dependencies=[Depends(get_current_user)]) # --- Static files --- static_dir = Path(__file__).parent.parent / "static" app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") +@app.get("/login") +async def login_page(): + return FileResponse(str(static_dir / "login.html")) + + @app.get("/") async def index(): return FileResponse(str(static_dir / "index.html")) diff --git a/static/index.html b/static/index.html index 8ff60b1..f1fd0e5 100644 --- a/static/index.html +++ b/static/index.html @@ -9,6 +9,23 @@ +