Initial commit: AegisSight-Monitor (OSINT-Monitoringsystem)
Dieser Commit ist enthalten in:
276
src/routers/auth.py
Normale Datei
276
src/routers/auth.py
Normale Datei
@@ -0,0 +1,276 @@
|
||||
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from models import (
|
||||
MagicLinkRequest,
|
||||
MagicLinkResponse,
|
||||
VerifyTokenRequest,
|
||||
VerifyCodeRequest,
|
||||
TokenResponse,
|
||||
UserMeResponse,
|
||||
)
|
||||
from auth import (
|
||||
create_token,
|
||||
get_current_user,
|
||||
generate_magic_token,
|
||||
generate_magic_code,
|
||||
verify_password,
|
||||
)
|
||||
from database import db_dependency
|
||||
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
|
||||
from email_utils.sender import send_email
|
||||
from email_utils.templates import magic_link_login_email
|
||||
from email_utils.rate_limiter import magic_link_limiter, verify_code_limiter
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger("osint.auth")
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@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 Link + Code."""
|
||||
email = data.email.lower().strip()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Rate-Limit pruefen
|
||||
allowed, reason = magic_link_limiter.check(email, ip)
|
||||
if not allowed:
|
||||
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
|
||||
# Trotzdem 200 zurueckgeben (kein Information-Leak)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Nutzer suchen
|
||||
cursor = await db.execute(
|
||||
"""SELECT u.id, u.email, u.username, u.role, u.organization_id, u.is_active,
|
||||
o.is_active as org_active, o.slug as org_slug
|
||||
FROM users u
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE LOWER(u.email) = ?""",
|
||||
(email,),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
if not user["is_active"]:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
if not user["org_active"]:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Lizenz pruefen
|
||||
from services.license_service import check_license
|
||||
lic = await check_license(db, user["organization_id"])
|
||||
if lic.get("status") == "org_disabled":
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Token + Code generieren
|
||||
token = generate_magic_token()
|
||||
code = generate_magic_code()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
|
||||
await db.execute(
|
||||
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
||||
(email,),
|
||||
)
|
||||
|
||||
# Neuen Magic Link speichern
|
||||
await db.execute(
|
||||
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
|
||||
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
|
||||
(email, token, code, user["id"], expires_at, ip),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# E-Mail senden
|
||||
link = f"{MAGIC_LINK_BASE_URL}/auth/verify?token={token}"
|
||||
subject, html = magic_link_login_email(user["username"], code, link)
|
||||
await send_email(email, subject, html)
|
||||
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
|
||||
@router.post("/verify", response_model=TokenResponse)
|
||||
async def verify_magic_link(
|
||||
data: VerifyTokenRequest,
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Magic Link verifizieren (Token aus URL)."""
|
||||
cursor = await db.execute(
|
||||
"""SELECT ml.*, u.username, u.email, u.role, u.organization_id, u.is_active,
|
||||
o.slug as org_slug, o.is_active as org_active
|
||||
FROM magic_links ml
|
||||
JOIN users u ON u.id = ml.user_id
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE ml.token = ? AND ml.is_used = 0""",
|
||||
(data.token,),
|
||||
)
|
||||
ml = await cursor.fetchone()
|
||||
|
||||
if not ml:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link")
|
||||
|
||||
# Ablauf pruefen
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = datetime.fromisoformat(ml["expires_at"])
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if now > expires:
|
||||
raise HTTPException(status_code=400, detail="Link abgelaufen. Bitte neuen Link anfordern.")
|
||||
|
||||
if not ml["is_active"] or not ml["org_active"]:
|
||||
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
|
||||
|
||||
# Magic Link als verwendet markieren
|
||||
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
|
||||
|
||||
# Letzten Login aktualisieren
|
||||
await db.execute(
|
||||
"UPDATE users SET last_login_at = ? WHERE id = ?",
|
||||
(now.isoformat(), ml["user_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# JWT erstellen
|
||||
token = create_token(
|
||||
user_id=ml["user_id"],
|
||||
username=ml["username"],
|
||||
email=ml["email"],
|
||||
role=ml["role"],
|
||||
tenant_id=ml["organization_id"],
|
||||
org_slug=ml["org_slug"],
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
username=ml["username"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-code", response_model=TokenResponse)
|
||||
async def verify_magic_code(
|
||||
data: VerifyCodeRequest,
|
||||
request: Request,
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Magic Code verifizieren (6-stelliger Code + E-Mail)."""
|
||||
email = data.email.lower().strip()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Brute-Force-Schutz: Fehlversuche pruefen
|
||||
allowed, reason = verify_code_limiter.check(email, ip)
|
||||
if not allowed:
|
||||
logger.warning(f"Verify-Code Rate-Limit fuer {email} von {ip}: {reason}")
|
||||
# Bei Sperre alle offenen Magic Links fuer diese E-Mail invalidieren
|
||||
await db.execute(
|
||||
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
||||
(email,),
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=429, detail=reason)
|
||||
|
||||
cursor = await db.execute(
|
||||
"""SELECT ml.*, u.username, u.email as user_email, u.role, u.organization_id, u.is_active,
|
||||
o.slug as org_slug, o.is_active as org_active
|
||||
FROM magic_links ml
|
||||
JOIN users u ON u.id = ml.user_id
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE LOWER(ml.email) = ? AND ml.code = ? AND ml.is_used = 0
|
||||
ORDER BY ml.created_at DESC LIMIT 1""",
|
||||
(email, data.code),
|
||||
)
|
||||
ml = await cursor.fetchone()
|
||||
|
||||
if not ml:
|
||||
verify_code_limiter.record_failure(email, ip)
|
||||
logger.warning(f"Fehlgeschlagener Code-Versuch fuer {email} von {ip}")
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger Code")
|
||||
|
||||
# Ablauf pruefen
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = datetime.fromisoformat(ml["expires_at"])
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if now > expires:
|
||||
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")
|
||||
|
||||
if not ml["is_active"] or not ml["org_active"]:
|
||||
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
|
||||
|
||||
# Magic Link als verwendet markieren
|
||||
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
|
||||
|
||||
# Letzten Login aktualisieren
|
||||
await db.execute(
|
||||
"UPDATE users SET last_login_at = ? WHERE id = ?",
|
||||
(now.isoformat(), ml["user_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Fehlversuche-Zaehler nach Erfolg zuruecksetzen
|
||||
verify_code_limiter.clear(email)
|
||||
|
||||
token = create_token(
|
||||
user_id=ml["user_id"],
|
||||
username=ml["username"],
|
||||
email=ml["user_email"],
|
||||
role=ml["role"],
|
||||
tenant_id=ml["organization_id"],
|
||||
org_slug=ml["org_slug"],
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
username=ml["username"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserMeResponse)
|
||||
async def get_me(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Aktuellen Nutzer mit Org-Info abfragen."""
|
||||
# Org-Name laden
|
||||
org_name = ""
|
||||
if current_user.get("tenant_id"):
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM organizations WHERE id = ?",
|
||||
(current_user["tenant_id"],),
|
||||
)
|
||||
org = await cursor.fetchone()
|
||||
if org:
|
||||
org_name = org["name"]
|
||||
|
||||
# Lizenzstatus laden
|
||||
license_info = {}
|
||||
if current_user.get("tenant_id"):
|
||||
from services.license_service import check_license
|
||||
license_info = await check_license(db, current_user["tenant_id"])
|
||||
|
||||
return UserMeResponse(
|
||||
id=current_user["id"],
|
||||
username=current_user["username"],
|
||||
email=current_user.get("email", ""),
|
||||
role=current_user["role"],
|
||||
org_name=org_name,
|
||||
org_slug=current_user.get("org_slug", ""),
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
license_status=license_info.get("status", "unknown"),
|
||||
license_type=license_info.get("license_type", ""),
|
||||
read_only=license_info.get("read_only", False),
|
||||
)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren