"""BAN YARO — Auth Routes""" import os import secrets import string import logging from datetime import datetime, timedelta, timezone from typing import Optional import jwt as _pyjwt from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from database import db from auth import ( hash_password, verify_password, create_token, get_current_user, decode_token, blacklist_jti, JWT_EXPIRY ) from username_blocklist import is_username_blocked from ratelimit import check as rl_check logger = logging.getLogger(__name__) router = APIRouter() COOKIE_NAME = "by_token" _APP_URL = os.getenv("APP_URL", "https://banyaro.app") _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS")) # Login-Brute-Force-Lockout: 5 Fehlversuche in 15 Minuten → 15 Min. gesperrt. _LOCKOUT_WINDOW_MIN = 15 _LOCKOUT_ATTEMPTS_MAX = 5 # ------------------------------------------------------------------ # Login-Lockout (DB-basiert, überlebt Container-Restart) # ------------------------------------------------------------------ def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _db_is_account_locked(email: str) -> Optional[int]: """Gibt verbleibende Sperrzeit in Sekunden zurück (oder None falls nicht gesperrt).""" with db() as conn: row = conn.execute( "SELECT locked_until FROM login_attempts WHERE email=? COLLATE NOCASE", (email,) ).fetchone() if not row or not row["locked_until"]: return None try: locked_until = datetime.fromisoformat(row["locked_until"]) except Exception: return None now = datetime.now(timezone.utc) # Falls ohne TZ gespeichert (Legacy) → as-UTC interpretieren if locked_until.tzinfo is None: locked_until = locked_until.replace(tzinfo=timezone.utc) if locked_until <= now: return None return int((locked_until - now).total_seconds()) def _db_record_login_failure(email: str): """Inkrementiert Fehlversuche; setzt locked_until wenn Schwelle erreicht.""" now = datetime.now(timezone.utc) window_start = now - timedelta(minutes=_LOCKOUT_WINDOW_MIN) with db() as conn: row = conn.execute( "SELECT attempts, last_attempt FROM login_attempts WHERE email=? COLLATE NOCASE", (email,) ).fetchone() if row: try: last = datetime.fromisoformat(row["last_attempt"]) if last.tzinfo is None: last = last.replace(tzinfo=timezone.utc) except Exception: last = now # Zähler resetten wenn letzter Versuch außerhalb des Fensters lag attempts = (row["attempts"] + 1) if last >= window_start else 1 else: attempts = 1 locked_until = None if attempts >= _LOCKOUT_ATTEMPTS_MAX: locked_until = (now + timedelta(minutes=_LOCKOUT_WINDOW_MIN)).isoformat() conn.execute( """INSERT INTO login_attempts (email, attempts, last_attempt, locked_until) VALUES (?,?,?,?) ON CONFLICT(email) DO UPDATE SET attempts=excluded.attempts, last_attempt=excluded.last_attempt, locked_until=excluded.locked_until""", (email.lower(), attempts, now.isoformat(), locked_until) ) def _db_clear_login_failures(email: str): with db() as conn: conn.execute("DELETE FROM login_attempts WHERE email=? COLLATE NOCASE", (email,)) # ------------------------------------------------------------------ # SMTP-Fehler-Logging # ------------------------------------------------------------------ def _log_smtp_failure(to_email: str, subject: str, body: str, error: Exception, context: str = ""): """Loggt SMTP-Fehler und speichert in failed_emails für Admin-Retry.""" logger.exception("SMTP failed for %s | context=%s | subject=%s", to_email, context, subject) try: with db() as conn: conn.execute( "INSERT INTO failed_emails (to_email, subject, body, error, context) VALUES (?,?,?,?,?)", (to_email, subject, body, repr(error), context or None) ) except Exception: logger.exception("failed_emails-Insert fehlgeschlagen") def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return import html as _html from routes.outreach import _send_smtp from mailer import email_html url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" _ename = _html.escape(name) body_html = f"""
Hallo {_ename},
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
Der Link ist 7 Tage gültig.
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
""" html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen") plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n" try: _send_smtp(email, subject, plain, "support", html=html) except Exception as exc: # Nicht blockieren wenn SMTP fehlschlägt — aber Fehler protokollieren + persistieren. _log_smtp_failure(email, subject, plain, exc, context="verification_email") class LoginRequest(BaseModel): email: EmailStr password: str class RegisterRequest(BaseModel): email: EmailStr password: str name: str ref_code: Optional[str] = None def _gen_referral_code() -> str: alphabet = string.ascii_uppercase + string.digits return ''.join(secrets.choice(alphabet) for _ in range(8)) def _set_cookie(response: Response, token: str): response.set_cookie( key=COOKIE_NAME, value=token, httponly=True, secure=True, samesite="lax", max_age=JWT_EXPIRY * 24 * 3600 ) @router.post("/register") async def register(data: RegisterRequest, response: Response, request: Request): rl_check(request, max_requests=5, window_seconds=3600, key="register") name = data.name.strip() if len(name) < 2: raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.") if len(name) > 40: raise HTTPException(400, "Benutzername darf maximal 40 Zeichen lang sein.") if ' ' in name: raise HTTPException(400, "Benutzername darf keine Leerzeichen enthalten.") if is_username_blocked(name): raise HTTPException(400, "Dieser Benutzername ist nicht erlaubt.") if len(data.password) < 8: raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") with db() as conn: if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): raise HTTPException(400, "E-Mail bereits registriert.") if conn.execute( "SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,) ).fetchone(): raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") code = _gen_referral_code() verify_token = secrets.token_urlsafe(32) try: conn.execute( "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)", (data.email, hash_password(data.password), name, code, verify_token) ) except Exception: raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) ).fetchone() new_user_id = user["id"] if data.ref_code: code_upper = data.ref_code.strip().upper() # Zuerst prüfen ob es ein Partner-Code ist partner = conn.execute( "SELECT id, grants_founder, max_uses FROM partner_codes WHERE code=?", (code_upper,) ).fetchone() if partner: # ATOMARE Inkrementierung (SQLite ≥3.35 unterstützt RETURNING). # Schließt Race-Condition wenn zwei User gleichzeitig den gleichen Code einlösen. if partner["max_uses"] is None: redeemed = conn.execute( "UPDATE partner_codes SET uses=uses+1 WHERE id=? RETURNING uses", (partner["id"],) ).fetchone() else: redeemed = conn.execute( "UPDATE partner_codes SET uses=uses+1 WHERE id=? AND uses RETURNING uses", (partner["id"], partner["max_uses"]) ).fetchone() if redeemed: updates = {"referred_by": -partner["id"]} if partner["grants_founder"]: total_founders = conn.execute( "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert updates["is_founder_pending"] = 1 set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", (*updates.values(), new_user_id) ) else: # Fallback: Referral-Code eines anderen Users referrer = conn.execute( "SELECT id FROM users WHERE referral_code=? AND id != ?", (code_upper, new_user_id) ).fetchone() if referrer: conn.execute("UPDATE users SET referred_by=? WHERE id=?", (referrer['id'], new_user_id)) _send_verification_email(data.email, name, verify_token) return {"pending_verification": True} @router.post("/login") async def login(data: LoginRequest, response: Response, request: Request): rl_check(request, max_requests=10, window_seconds=300, key="login") rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}") # DB-basierter Account-Lockout (überlebt Container-Restart) remaining = _db_is_account_locked(data.email) if remaining is not None: minutes = max(1, remaining // 60) raise HTTPException( 429, f"Zu viele Fehlversuche. Bitte warte {minutes} Minute(n) und versuche es erneut.", headers={"Retry-After": str(remaining)} ) with db() as conn: user = conn.execute( "SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() if not user or not verify_password(data.password, user["pw_hash"]): _db_record_login_failure(data.email) raise HTTPException(401, "E-Mail oder Passwort falsch.") if not user["email_verified"]: raise HTTPException(403, "EMAIL_NOT_VERIFIED") _db_clear_login_failures(data.email) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) with db() as conn: conn.execute( "UPDATE users SET last_login=datetime('now') WHERE id=?", (user["id"],) ) return {"token": token, "name": user["name"], "is_premium": bool(user["is_premium"])} @router.post("/logout") async def logout(request: Request, response: Response): # Token aus Cookie ODER Bearer-Header extrahieren und auf die Blacklist setzen, # damit es serverseitig wirklich ungültig wird (nicht nur Cookie löschen). raw_token = request.cookies.get(COOKIE_NAME) if not raw_token: auth_header = request.headers.get("Authorization", "") if auth_header.lower().startswith("bearer "): raw_token = auth_header.split(None, 1)[1].strip() if raw_token: try: # options={'verify_exp': False}: auch bei abgelaufenem Token wollen wir jti+exp lesen, # falls jemand sich vor Ablauf "ordentlich" abmelden möchte — und exp brauchen wir für TTL. payload = _pyjwt.decode( raw_token, options={"verify_signature": False} ) jti = payload.get("jti") exp = payload.get("exp") if jti and exp: expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).isoformat() blacklist_jti(jti, expires_at) except Exception: logger.exception("Logout: Token konnte nicht für Blacklist gelesen werden") response.delete_cookie(COOKIE_NAME) return {"ok": True} _REFERRAL_TIERS = [ (50, 50), (20, 30), (10, 20), ] def _referral_tier(count: int): for threshold, discount in _REFERRAL_TIERS: if count >= threshold: return discount return 0 def _referral_next(count: int): for threshold, discount in reversed(_REFERRAL_TIERS): if count < threshold: return {"count": threshold, "discount": discount} return None # Maximalstufe erreicht @router.get("/referral") async def get_referral_info(user=Depends(get_current_user)): with db() as conn: row = conn.execute( """SELECT referral_code, COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=?), 0) AS count FROM users WHERE id=?""", (user['id'], user['id']) ).fetchone() code = row["referral_code"] if row else None if not code: code = _gen_referral_code() conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id'])) count = row["count"] if row else 0 base = os.getenv("APP_URL", "https://banyaro.app") return { "code": code, "count": count, "link": f"{base}/?ref={code}", "discount_pct": _referral_tier(count), "next_tier": _referral_next(count), } @router.get("/me") async def me(user=Depends(get_current_user)): with db() as conn: row = conn.execute( """SELECT id, name, real_name, email, rolle, is_premium, email_verified, bio, wohnort, erfahrung, social_link, profil_sichtbarkeit, avatar_url, created_at, is_founder, is_partner, founder_number, is_founder_pending, notes_ki_enabled, gassi_stunde_push, preferred_theme, subscription_tier, subscription_expires_at, subscription_cancelled_at, needs_dog_selection, billing_address, geburtstag FROM users WHERE id=?""", (user["id"],) ).fetchone() if not row: raise HTTPException(404, "User nicht gefunden.") data = dict(row) data["is_premium"] = bool(data["is_premium"]) return data @router.get("/verify-email/{token}") async def verify_email(token: str): with db() as conn: row = conn.execute( "SELECT id, email_verified FROM users WHERE verification_token=?", (token,) ).fetchone() if not row: return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302) conn.execute( "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", (row["id"],) ) return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) class ResendVerificationRequest(BaseModel): email: EmailStr @router.post("/resend-verification") async def resend_verification(data: ResendVerificationRequest, request: Request): rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}") with db() as conn: row = conn.execute( "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() if not row or row["email_verified"]: return {"ok": True} token = secrets.token_urlsafe(32) with db() as conn: conn.execute( "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"]) ) _send_verification_email(data.email, row["name"], token) return {"ok": True} class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str password: str @router.post("/forgot-password") async def forgot_password(data: ForgotPasswordRequest, request: Request): rl_check(request, max_requests=3, window_seconds=3600, key="forgot_pw") with db() as conn: user = conn.execute( "SELECT id, name FROM users WHERE email=?", (data.email,) ).fetchone() # Immer OK zurückgeben — kein User-Enumeration if user: token = secrets.token_urlsafe(32) expires = (datetime.utcnow() + timedelta(hours=2)).isoformat() with db() as conn: conn.execute( "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", (token, expires, user["id"]) ) import html as _html app_url = os.getenv("APP_URL", "https://banyaro.app") url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" from routes.outreach import _send_smtp from mailer import email_html _ename = _html.escape(user['name']) body_html = f"""Hallo {_ename},
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
Der Link ist 2 Stunden gültig.
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
""" html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen") plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n" try: _send_smtp(data.email, subject, plain, "support", html=html) except Exception as exc: _log_smtp_failure(data.email, subject, plain, exc, context="forgot_password") return {"ok": True} class UpgradeRequestBody(BaseModel): tier: str message: Optional[str] = None @router.post("/upgrade-request") async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): _VALID = {"pro", "breeder"} if data.tier not in _VALID: raise HTTPException(400, "Ungültiger Tarif.") with db() as conn: existing = conn.execute( "SELECT id FROM upgrade_requests WHERE user_id=? AND tier=? AND fulfilled_at IS NULL", (user["id"], data.tier) ).fetchone() if existing: return {"ok": True, "already": True} conn.execute( "INSERT INTO upgrade_requests (user_id, tier, message) VALUES (?,?,?)", (user["id"], data.tier, data.message or None) ) email = conn.execute("SELECT email FROM users WHERE id=?", (user["id"],)).fetchone()["email"] tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"} tier_label = tier_labels[data.tier] admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email: try: from routes.outreach import _send_smtp subject = f"[Ban Yaro] Upgrade-Anfrage: {tier_label} — {user['name']}" body = (f"Neue Upgrade-Anfrage:\n\n" f"Nutzer: {user['name']} ({email})\n" f"Tarif: {tier_label}\n" f"Nachricht: {data.message or '—'}\n\n" f"Admin-Panel: https://banyaro.app/#admin") _send_smtp(admin_email, subject, body, "support") except Exception as exc: _log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify") return {"ok": True} @router.post("/reset-password") async def reset_password(data: ResetPasswordRequest, request: Request): rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") if len(data.password) < 8: raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein.") with db() as conn: user = conn.execute( "SELECT id, password_reset_expires FROM users WHERE password_reset_token=?", (data.token,) ).fetchone() if not user: raise HTTPException(400, "Ungültiger oder abgelaufener Link.") if user["password_reset_expires"] < datetime.utcnow().isoformat(): raise HTTPException(400, "Dieser Link ist abgelaufen. Bitte fordere einen neuen an.") conn.execute( "UPDATE users SET pw_hash=?, password_reset_token=NULL, password_reset_expires=NULL WHERE id=?", (hash_password(data.password), user["id"]) ) return {"ok": True} @router.post("/subscription/cancel") async def cancel_subscription(user=Depends(get_current_user)): with db() as conn: row = conn.execute( "SELECT subscription_tier, subscription_expires_at, subscription_cancelled_at FROM users WHERE id=?", (user["id"],) ).fetchone() if not row or row["subscription_tier"] in ("standard", "standard_test"): raise HTTPException(400, "Kein aktives Abo vorhanden.") if row["subscription_cancelled_at"]: raise HTTPException(400, "Abo ist bereits gekündigt.") conn.execute( "UPDATE users SET subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?", (user["id"],) ) expires = row["subscription_expires_at"] # Bestätigungsmail try: from mailer import send_email, email_html import html as _html tier_label = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}.get(row["subscription_tier"], row["subscription_tier"]) expires_de = None if expires: from datetime import date as _date try: d = _date.fromisoformat(expires[:10]) monate = ["Januar","Februar","März","April","Mai","Juni", "Juli","August","September","Oktober","November","Dezember"] expires_de = f"{d.day}. {monate[d.month-1]} {d.year}" except Exception: expires_de = expires[:10] expiry_line = ( f"Dein Abo ist weiterhin aktiv bis zum {expires_de}. " f"Ab diesem Datum wirst du automatisch auf den kostenlosen Tarif gesetzt.
" if expires_de else "Dein Abo bleibt bis zum Ende des bezahlten Zeitraums aktiv.
" ) body_html = f"""Hallo {_html.escape(user['name'])},
deine Kündigung für {tier_label} wurde bestätigt.
{expiry_line}Deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten. Wenn du mehrere Hunde hast, kannst du vor dem Ablauf einen als Haupthund festlegen.
Wir hoffen, dich bald wieder begrüßen zu dürfen!
Viele Grüße
René & das Ban Yaro Team