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

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"]}