Neue Tabelle user_activity_days (user_id, day, tenant_id, ein Eintrag je Nutzer und Kalendertag), angelegt per idempotenter Startup-Migration. Geschrieben wird sie in get_current_user bei jeder authentifizierten Nutzung, ein In-Memory-Tagesmerker begrenzt das auf einen INSERT OR IGNORE je Nutzer und Tag, Fehler werden geschluckt und blockieren nie eine Anfrage. Gelesen wird die Tabelle nur vom Verwaltungsportal für die neue MAU/DAU-Statistik. Ein reines Login-Protokoll wäre zu grob, weil das JWT 24 Stunden hält. Getestet gegen eine DB-Kopie, Tabelle entsteht, je Nutzer und Tag genau ein Eintrag trotz Mehrfachaufruf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 Zeilen
3.8 KiB
Python
123 Zeilen
3.8 KiB
Python
"""JWT-Authentifizierung mit Magic-Link-Support und Multi-Tenancy."""
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from jose import jwt, JWTError
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from config import get_jwt_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS, TIMEZONE
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
|
|
JWT_ISSUER = "intelsight-osint"
|
|
JWT_AUDIENCE = "intelsight-osint"
|
|
|
|
|
|
def create_token(
|
|
user_id: int,
|
|
username: str,
|
|
email: str,
|
|
role: str = "member",
|
|
tenant_id: int = None,
|
|
org_slug: str = None,
|
|
is_global_admin: bool = False,
|
|
) -> str:
|
|
"""JWT-Token erstellen mit Tenant-Kontext."""
|
|
now = datetime.now(TIMEZONE)
|
|
expire = now + timedelta(hours=JWT_EXPIRE_HOURS)
|
|
payload = {
|
|
"sub": str(user_id),
|
|
"username": username,
|
|
"email": email,
|
|
"role": role,
|
|
"tenant_id": tenant_id,
|
|
"org_slug": org_slug,
|
|
"is_global_admin": is_global_admin,
|
|
"iss": JWT_ISSUER,
|
|
"aud": JWT_AUDIENCE,
|
|
"iat": now,
|
|
"exp": expire,
|
|
}
|
|
return jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALGORITHM)
|
|
|
|
|
|
def decode_token(token: str) -> dict:
|
|
"""JWT-Token dekodieren und validieren."""
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
get_jwt_secret(),
|
|
algorithms=[JWT_ALGORITHM],
|
|
issuer=JWT_ISSUER,
|
|
audience=JWT_AUDIENCE,
|
|
)
|
|
return payload
|
|
except JWTError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Token ungueltig oder abgelaufen",
|
|
)
|
|
|
|
|
|
# --- Aktivitaets-Erfassung fuer MAU/DAU ---------------------------------------
|
|
# Je Nutzer und Kalendertag genau ein Eintrag in user_activity_days. Der
|
|
# In-Memory-Merker haelt die DB-Last bei einem INSERT je Nutzer und Tag.
|
|
# Ein Fehler hier darf NIEMALS eine Anfrage blockieren.
|
|
_activity_seen: set = set()
|
|
|
|
|
|
async def _track_activity(user_id: int, tenant_id) -> None:
|
|
day = datetime.now(TIMEZONE).strftime("%Y-%m-%d")
|
|
key = (user_id, day)
|
|
if key in _activity_seen:
|
|
return
|
|
_activity_seen.add(key)
|
|
if len(_activity_seen) > 20000:
|
|
# Speicher begrenzen, alte Tage interessieren den Merker nicht mehr
|
|
_activity_seen.clear()
|
|
_activity_seen.add(key)
|
|
try:
|
|
import aiosqlite
|
|
from config import DB_PATH
|
|
db = await aiosqlite.connect(DB_PATH)
|
|
try:
|
|
await db.execute(
|
|
"INSERT OR IGNORE INTO user_activity_days (user_id, day, tenant_id) VALUES (?, ?, ?)",
|
|
(user_id, day, tenant_id),
|
|
)
|
|
await db.commit()
|
|
finally:
|
|
await db.close()
|
|
except Exception:
|
|
# Bewusst schlucken (z.B. Tabelle fehlt noch). Der Merker behaelt den
|
|
# Schluessel, damit ein Dauerfehler nicht jede Anfrage erneut trifft.
|
|
pass
|
|
|
|
|
|
async def get_current_user(
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
) -> dict:
|
|
"""FastAPI Dependency: Aktuellen Nutzer aus Token extrahieren."""
|
|
if credentials is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Nicht authentifiziert",
|
|
)
|
|
payload = decode_token(credentials.credentials)
|
|
await _track_activity(int(payload["sub"]), payload.get("tenant_id"))
|
|
return {
|
|
"id": int(payload["sub"]),
|
|
"username": payload["username"],
|
|
"email": payload.get("email", ""),
|
|
"role": payload.get("role", "member"),
|
|
"tenant_id": payload.get("tenant_id"),
|
|
"org_slug": payload.get("org_slug"),
|
|
"is_global_admin": payload.get("is_global_admin", False),
|
|
}
|
|
|
|
|
|
def generate_magic_token() -> str:
|
|
"""Generiert einen 64-Zeichen URL-safe Token."""
|
|
return secrets.token_urlsafe(48)
|
|
|