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:
77
src/auth.py
Normale Datei
77
src/auth.py
Normale Datei
@@ -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
137
src/auth_router.py
Normale Datei
@@ -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
17
src/config.py
Normale Datei
@@ -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
12
src/database.py
Normale Datei
@@ -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
50
src/email_utils.py
Normale Datei
@@ -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
|
||||
24
src/main.py
24
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"))
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren