Dateien
AegisSight-Monitor/src/routers/auth.py
claude-dev 1bbbd891ef feat(abrechnung): Monatskontingent in Credits mit festen Sätzen, Hard-Stop und Warnschwelle
Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit d2b9168,
per Cherry-Pick übernommen und an den Server-Stand angepasst).

- Feste Sätze je Aktion (BILLING_MODE=flat) statt echter Kosten geteilt
  durch cost_per_credit. Sätze auf die am 23.07.2026 beschlossenen
  Verkaufswerte gesetzt. Live-Lauf 45, Recherche je Durchlauf 40,
  Studio-Bausteine 12, Chat/Beschreibung/Globe 1. Per ENV überschreibbar,
  der alte Modus bleibt als BILLING_MODE=actual erhalten.
- Abrechnungsperiode. licenses.credits_period trennt monthly von total,
  träger Monatsreset bei der nächsten Lizenzprüfung, optionaler Übertrag
  (credits_rollover, gedeckelt auf ein Monatskontingent). Bestandslizenzen
  ohne Periodenmarke behalten ihren Verbrauch.
- Hard-Stop gegen das verfügbare Monatskontingent (Kontingent plus
  Übertrag) statt gegen das Lebenszeit-Total.
- Warnschwelle budget_warning_percent (Default 80 Prozent) wird erstmals
  ausgewertet, einmalige Meldung an alle aktiven Nutzer der Organisation.
- DB-Migration additiv und idempotent (credits_period, credits_period_start,
  credits_rollover, credits_carried, budget_warning_sent, unlimited_budget).
- /api/auth/me liefert credits_available als Bezugsgröße plus
  credits_period, das Credits-Widget zeigt "Credits diesen Monat".
- Wortlaut überall Credits (nicht Guthaben/Einheiten), docs/ABRECHNUNG.md
  auf den Online-Stand gebracht.

Abweichungen zur Fork-Vorlage. Die Takt-Änderungen (Untergrenze 30 Min,
Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578) sind bewusst NICHT
enthalten, die Sätze stehen auf 45/40 statt der Fork-Defaults 24/33.

Getestet gegen eine Kopie der Staging-DB, 20 Prüfungen bestanden.
Migration, Flat-Buchung adhoc/research/chat, Warnschwelle einmalig,
Hard-Stop, Monatsreset, Übertrag gedeckelt, Bestandslizenz ohne Marke.
Die vier Live-Lizenzen stehen auf unlimited_budget und sind unbeeinflusst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:26:54 +00:00

304 Zeilen
11 KiB
Python

"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
def _staging_mode() -> bool:
"""STAGING_MODE Env-Flag (vgl. services.license_service)."""
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
from models import (
MagicLinkRequest,
MagicLinkResponse,
VerifyTokenRequest,
TokenResponse,
UserMeResponse,
)
from auth import (
create_token,
get_current_user,
generate_magic_token,
)
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
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."""
email = data.email.lower().strip()
ip = request.client.host if request.client else "unknown"
# Rate-Limit prüfen
allowed, reason = magic_link_limiter.check(email, ip)
if not allowed:
logger.warning(f"Rate-Limit für {email} von {ip}: {reason}")
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 prüfen
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 generieren
token = generate_magic_token()
expires_at = (datetime.now(TIMEZONE) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
# Alte ungenutzte Magic Links für 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, user["id"], expires_at, ip),
)
await db.commit()
# E-Mail senden -- Sprache aus Org-Settings des Users
link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
from services.org_settings import get_org_language
org_lang_iso = await get_org_language(db, user["organization_id"])
subject, html = magic_link_login_email(user["email"].split("@")[0], link, lang=org_lang_iso)
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="Ungültiger oder bereits verwendeter Link")
# Ablauf prüfen
now = datetime.now(TIMEZONE)
expires = datetime.fromisoformat(ml["expires_at"])
if expires.tzinfo is None:
expires = expires.replace(tzinfo=TIMEZONE)
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()
# Global-Admin-Flag aus DB lesen
ga_cursor = await db.execute(
"SELECT is_global_admin FROM users WHERE id = ?", (ml["user_id"],)
)
ga_row = await ga_cursor.fetchone()
_is_global_admin = bool(ga_row["is_global_admin"]) if ga_row else False
# 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"],
is_global_admin=_is_global_admin,
)
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"])
# Guthaben-Daten aus der Lizenzpruefung uebernehmen. check_license() hat den
# Periodenwechsel bereits nachgeholt, ein zweiter Griff in die Tabelle wuerde
# nur dieselben Werte noch einmal lesen.
credits_total = None
credits_remaining = None
credits_percent_used = None
credits_period = None
unlimited_budget = bool(license_info.get("unlimited_budget", False))
if current_user.get("tenant_id") and license_info.get("credits_available"):
# Verfuegbar ist Kontingent plus Uebertrag, danach richtet sich auch der
# Hard-Stop. Die Anzeige muss dieselbe Bezugsgroesse nutzen.
credits_total = int(license_info["credits_available"])
credits_used = license_info.get("credits_used") or 0
credits_period = license_info.get("credits_period")
credits_remaining = max(0, int(credits_total - credits_used))
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
# Org-Switcher fuer Global-Admins -- auch auf Staging aktiv, damit eng_demo
# und andere Sprach-/Demo-Mandanten via Dropdown erreichbar sind. (Vorherige
# STAGING_MODE-Suppression wurde 2026-05-13 zurueckgenommen.)
is_global_admin_response = current_user.get("is_global_admin", False)
# Org-Sprache fuer Frontend-i18n
output_language_iso = "de"
if current_user.get("tenant_id"):
from services.org_settings import get_org_language
output_language_iso = await get_org_language(db, current_user["tenant_id"])
return UserMeResponse(
id=current_user["id"],
username=current_user["username"],
email=current_user.get("email", ""),
credits_total=credits_total,
credits_remaining=credits_remaining,
credits_percent_used=credits_percent_used,
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),
read_only_reason=license_info.get("read_only_reason"),
unlimited_budget=unlimited_budget,
is_global_admin=is_global_admin_response,
output_language=output_language_iso,
credits_period=credits_period,
)
# --- Global Admin: Org-Wechsel (herausnehmbar) ---
from models import SwitchOrgRequest, OrgListItem
@router.get("/organizations")
async def list_all_organizations(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Organisationen auflisten (nur fuer Global Admin)."""
if not current_user.get("is_global_admin"):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
cursor = await db.execute(
"SELECT id, name, slug, is_active FROM organizations ORDER BY name"
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
@router.post("/switch-org")
async def switch_organization(
data: SwitchOrgRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Organisation wechseln (nur fuer Global Admin). Gibt neues JWT zurueck."""
if not current_user.get("is_global_admin"):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
# Ziel-Org pruefen
cursor = await db.execute(
"SELECT id, name, slug FROM organizations WHERE id = ?", (data.organization_id,)
)
org = await cursor.fetchone()
if not org:
raise HTTPException(status_code=404, detail="Organisation nicht gefunden")
# Neues JWT mit anderem tenant_id ausstellen
token = create_token(
user_id=current_user["id"],
username=current_user["username"],
email=current_user["email"],
role=current_user["role"],
tenant_id=org["id"],
org_slug=org["slug"],
is_global_admin=True,
)
return {
"access_token": token,
"token_type": "bearer",
"org_name": org["name"],
"org_slug": org["slug"],
}