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
Dieser Commit ist enthalten in:
Claude Dev
2026-03-24 11:57:00 +01:00
Ursprung a22a4e70d1
Commit 338e082467
9 geänderte Dateien mit 487 neuen und 9 gelöschten Zeilen

77
src/auth.py Normale Datei
Datei anzeigen

@@ -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))

137
src/auth_router.py Normale Datei
Datei anzeigen

@@ -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"""
<html><body><script>
localStorage.setItem('globe_token', '{jwt_token}');
window.location.href = '/';
</script></body></html>
""")
@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"]}

17
src/config.py Normale Datei
Datei anzeigen

@@ -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

12
src/database.py Normale Datei
Datei anzeigen

@@ -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()

50
src/email_utils.py Normale Datei
Datei anzeigen

@@ -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"""
<div style="font-family: 'Courier New', monospace; max-width: 500px; margin: 0 auto; padding: 30px; background: #0b1121; color: #e8eaf0; border-radius: 12px;">
<h2 style="color: #00ff88; font-size: 16px; letter-spacing: 2px; margin-bottom: 24px;">AEGISSIGHT GLOBE</h2>
<p style="font-size: 14px; line-height: 1.6;">Dein Zugangscode:</p>
<div style="background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.2); border-radius: 8px; padding: 20px; text-align: center; margin: 20px 0;">
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #00ff88;">{code}</span>
</div>
<p style="font-size: 13px; color: rgba(255,255,255,0.6); line-height: 1.6;">
Oder klicke auf diesen Link:<br>
<a href="{link}" style="color: #00ff88; word-break: break-all;">{link}</a>
</p>
<p style="font-size: 11px; color: rgba(255,255,255,0.3); margin-top: 24px;">
Dieser Code ist 10 Minuten gueltig. Falls du diese Anfrage nicht gesendet hast, ignoriere diese E-Mail.
</p>
</div>
"""
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

Datei anzeigen

@@ -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"))