banyaro/backend/auth.py
rene ce8aa2b699 Feature: Partner-Profile Backend + Pro-Zugang für Partner
Die Partner-Showcase-Seite (#partner) und der Profil-Editor (#partner-profil)
existierten seit v1102 nur als Frontend — /api/partners/public und
/api/partner/my-profile gab es nie (vermutlich Worktree-Merge-Verlust).

Backend neu:
- partner_profiles-Tabelle (user_id PK, ON DELETE CASCADE → DSGVO-Delete greift)
- GET/PUT /partner/my-profile (Texte, Website-Normalisierung, @-Instagram)
- Logo-Upload (≤5 MB → WebP 512px, altes Logo wird geräumt)
- Foto/Video-Upload (max 6, 200-MB-Budget, HEIC→JPEG, MOV→MP4 via ffmpeg,
  Bilder→WebP 1600px) + Lösch-Endpoint
- Submit-Workflow (approved 0/1/-1) + Admin-Mail (best effort)
- GET /partners/public (nur freigegebene, JOIN users für Name/Avatar)
- Admin: GET /admin/partner/profiles + POST .../review

Pro für Partner: has_pro_access() + App._hasPro() prüfen jetzt is_partner —
Multiplikatoren bekommen Pro gratis (mehrere Hunde, KI-Trainer etc.).

UI: Admin-Partner-Tab mit Freigabe-Sektion (offen-Badge, ✓/✗),
Settings zeigt Partnern eine Karte mit Link zum Profil-Editor.

Tests: tests/test_partner_profile.py — 5 Smoke-Tests (403, Voll-Flow
inkl. Freigabe/Ablehnung, Pflicht-Anzeigename, Logo+Foto-Upload, Pro-Zugang).
Suite: 44 passed.
2026-06-07 17:20:20 +02:00

293 lines
11 KiB
Python

"""
BAN YARO — Auth
JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt.
"""
import os
import uuid
import jwt
import bcrypt
import logging
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from database import db
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGO = "HS256"
# Default 7 Tage (vorher 30) — sensible Hundedaten brauchen kürzere Sessions.
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "7"))
# Wenn das Token älter als (JWT_EXPIRY / JWT_REFRESH_FRACTION) Tage ist,
# wird das Cookie bei einer authentifizierten Anfrage neu gesetzt (Sliding Session).
# Standard 2 → Refresh wenn mehr als die Hälfte der Laufzeit weg ist.
JWT_REFRESH_FRACTION = int(os.getenv("JWT_REFRESH_FRACTION", "2"))
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
raise RuntimeError(
"SICHERHEITSFEHLER: JWT_SECRET ist nicht gesetzt. "
"Bitte JWT_SECRET in .env setzen und Container neu starten."
)
security = HTTPBearer(auto_error=False)
# ------------------------------------------------------------------
# JWT-Blacklist (Logout invalidiert Token serverseitig)
# ------------------------------------------------------------------
def _is_jti_blacklisted(jti: str) -> bool:
if not jti:
return False
with db() as conn:
row = conn.execute(
"SELECT 1 FROM jwt_blacklist WHERE jti=?", (jti,)
).fetchone()
return bool(row)
def blacklist_jti(jti: str, expires_at_iso: str):
"""Token-ID auf die Blacklist setzen. expires_at_iso entspricht dem urspr. exp."""
if not jti or not expires_at_iso:
return
with db() as conn:
conn.execute(
"INSERT OR IGNORE INTO jwt_blacklist (jti, expires_at) VALUES (?,?)",
(jti, expires_at_iso)
)
def _purge_expired_jwt() -> int:
"""Entfernt abgelaufene Blacklist-Einträge. Gibt Anzahl gelöschter Zeilen zurück.
Kann später aus dem Scheduler aufgerufen werden."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
cur = conn.execute(
"DELETE FROM jwt_blacklist WHERE expires_at < ?", (now,)
)
deleted = cur.rowcount or 0
if deleted:
logger.info("JWT-Blacklist Cleanup: %d abgelaufene Einträge entfernt.", deleted)
return deleted
# ------------------------------------------------------------------
# Passwort
# ------------------------------------------------------------------
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
# ------------------------------------------------------------------
# JWT
# ------------------------------------------------------------------
def create_token(user_id: int, rolle: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"rolle": rolle,
"exp": now + timedelta(days=JWT_EXPIRY),
"iat": now,
"jti": uuid.uuid4().hex,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
def decode_token(token: str) -> dict:
"""Dekodiert + prüft Signatur, Ablauf und Blacklist.
Wirft jwt.InvalidTokenError wenn Token auf der Blacklist steht."""
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
jti = payload.get("jti")
if jti and _is_jti_blacklisted(jti):
raise jwt.InvalidTokenError("Token wurde invalidiert (Logout).")
return payload
# ------------------------------------------------------------------
# FastAPI Dependencies
# ------------------------------------------------------------------
def _get_token_from_request(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str | None:
"""Token aus Bearer-Header oder HttpOnly-Cookie."""
if credentials:
return credentials.credentials
return request.cookies.get("by_token")
def _maybe_refresh_token(request: Request, payload: dict):
"""Sliding-Session: setzt das Cookie neu wenn das Token älter als JWT_EXPIRY/JWT_REFRESH_FRACTION ist.
Nur wirksam wenn das Token aus dem Cookie kommt (nicht Bearer-Header).
Hängt das neue Token an request.state.refresh_token, damit Middleware es ins Response-Cookie setzen kann."""
try:
# Nur refreshen wenn Token aus Cookie kam (Bearer-Clients verwalten Tokens selbst)
if not request.cookies.get("by_token"):
return
iat = payload.get("iat")
exp = payload.get("exp")
if not iat or not exp:
return
# Wenn mehr als 1/JWT_REFRESH_FRACTION der Laufzeit vergangen ist → neues Token ausstellen
now_ts = datetime.now(timezone.utc).timestamp()
lifetime = exp - iat
if lifetime <= 0 or JWT_REFRESH_FRACTION <= 0:
return
threshold = iat + (lifetime / JWT_REFRESH_FRACTION)
if now_ts < threshold:
return
new_token = create_token(int(payload["sub"]), payload.get("rolle", "user"))
request.state.refresh_token = new_token
except Exception:
# Refresh-Fehler dürfen die eigentliche Anfrage nie blockieren.
logger.exception("Sliding-Token-Refresh fehlgeschlagen")
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""Dependency: gibt den eingeloggten User zurück oder wirft 401."""
token = _get_token_from_request(request, credentials)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht eingeloggt.")
try:
payload = decode_token(token)
except jwt.ExpiredSignatureError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen.")
except jwt.InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungültiges Token.")
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until, subscription_tier FROM users WHERE id=?",
(user_id,)
).fetchone()
if not row:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User nicht gefunden.")
user = dict(row)
if user.get("is_banned"):
reason = user.get("ban_reason") or "Kein Grund angegeben."
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Account gesperrt: {reason}")
# Sliding-Session: ggf. neues Token vorbereiten (in Middleware ins Cookie schreiben).
_maybe_refresh_token(request, payload)
return user
def get_current_user_optional(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""Dependency: gibt User zurück falls eingeloggt, sonst None."""
try:
return get_current_user(request, credentials)
except HTTPException:
return None
def require_premium(user=Depends(get_current_user)):
"""Dependency: nur für Premium-User."""
if not user["is_premium"]:
raise HTTPException(
status.HTTP_402_PAYMENT_REQUIRED,
"Dieses Feature erfordert Ban Yaro Premium."
)
return user
def require_admin(user=Depends(get_current_user)):
"""Dependency: nur für Admins."""
if user["rolle"] != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user
def require_moderator(user=Depends(get_current_user)):
"""Dependency: Admin oder Moderator. Konsequente Nutzung statt
Inline-`if user['rolle'] not in (...):` in den Routen."""
if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.")
return user
def require_breeder(user=Depends(get_current_user)):
"""Dependency: Admin oder Züchter (breeder/breeder_test)."""
if user["rolle"] == "admin":
return user
if user.get("subscription_tier") in ("breeder", "breeder_test"):
return user
raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.")
# ------------------------------------------------------------------
# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403`
# ------------------------------------------------------------------
def require_owner(row, user: dict, owner_field: str = "user_id",
not_found_msg: str = "Nicht gefunden",
forbidden_msg: str = "Kein Zugriff"):
"""Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer.
Returns row für chainability:
dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden')
"""
if not row:
raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg)
if row[owner_field] != user["id"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg)
return row
def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool:
"""True wenn User Owner ist oder Admin/Moderator."""
if not row:
return False
if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"):
return True
return row[owner_field] == user["id"]
def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf."""
if not user:
return False
role = user.get("rolle", "user")
tier = user.get("subscription_tier", "standard")
if role in ("admin", "moderator"):
return True
if user.get("is_moderator") or user.get("is_social_media"):
return True
if user.get("is_partner"): # Partner (Multiplikatoren) bekommen Pro gratis
return True
return tier in ("pro", "breeder", "pro_test", "breeder_test")
def has_breeder_access(user: dict) -> bool:
"""True wenn User Züchter-Features nutzen darf."""
if not user:
return False
role = user.get("rolle", "user")
tier = user.get("subscription_tier", "standard")
if role in ("admin", "moderator"):
return True
if user.get("is_moderator") or user.get("is_social_media"):
return True
return tier in ("breeder", "breeder_test") or role == "breeder"
def require_social_media(user=Depends(get_current_user)):
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user