banyaro/backend/routes/auth.py
rene de1677154f Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht
Registrierung & Login:
- E-Mail-Verifikation jetzt Pflicht vor erstem Login
- Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen
- Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert
- Resend-Verification ohne Auth (email-basiert)
- Frontend: _renderVerifyPending() nach Register und Login-Fehler
- Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py)
- Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min)
- Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt

E-Mail-Templates (alle Mails jetzt HTML):
- email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige)
- Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button
- Admin-Outreach: plain text auto-wrapped in HTML
- Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template
- Tierschutz-Alert (litters.py) → Template
- send_support_mail → HTML
- outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter

Forum-Schutz:
- Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check)
- Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde
- Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory)
- content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage,
  Sonderzeichen-Ratio-Check

Security-Headers:
- HSTS: max-age=31536000; includeSubDomains
- Content-Security-Policy: frame-ancestors none, base-uri self, …
- X-Frame-Options entfernt (CSP frame-ancestors ist moderner)

Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre):
- /api/admin/users, /api/v1/users, /api/.env, /api/config,
  /api/setup, /api/install, /api/phpinfo, /api/debug,
  /api/actuator, /api/swagger, /api/graphql u.a.

Quartalsbericht-System:
- backend/scripts/generate_reports.py: 6 Sections
  (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server)
- make reports: generiert alle Berichte aus dem Container, committed
- Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00)
  → vollständige HTML-Mail an ADMIN_EMAIL
- quarterly_report erscheint im täglichen Status-Report

Admin-Panel:
- "Forum & Meldungen" → "Forum"
2026-05-01 08:20:53 +02:00

350 lines
14 KiB
Python

"""BAN YARO — Auth Routes"""
import os
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional
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
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
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"))
def _send_verification_email(email: str, name: str, token: str):
if not _SMTP_READY:
return
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"
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
</p>"""
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:
pass # Nicht blockieren wenn SMTP fehlschlägt
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=30 * 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, uses FROM partner_codes WHERE code=?",
(code_upper,)
).fetchone()
if partner:
# Nur einlösen wenn max_uses nicht erreicht
if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]:
conn.execute(
"UPDATE partner_codes SET uses=uses+1 WHERE id=?",
(partner["id"],)
)
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()}")
if is_account_locked(data.email):
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
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"]):
record_login_failure(data.email)
raise HTTPException(401, "E-Mail oder Passwort falsch.")
if not user["email_verified"]:
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
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(response: Response):
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
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"])
)
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
body_html = f"""
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
<p style="margin:0 0 16px">
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
</p>
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
</p>"""
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:
pass
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}