Big Sweep: Security + Race-Conditions + Tests + DSGVO + A11y, SW by-v1095

SECURITY (auth.py, routes/auth.py, database.py, main.py)
- JWT bekommt jti; Logout trägt in neue jwt_blacklist-Tabelle ein,
  decode_token() prüft → server-side Invalidierung
- JWT-Expiry default 30 → 7 Tage (ENV JWT_EXPIRY_DAYS überschreibt)
- Sliding-Refresh-Middleware: erneuert Cookie wenn >50% verbraucht
  (Schwelle via JWT_REFRESH_FRACTION, Default 2)
- Login-Lockout in DB-Tabelle login_attempts (5 Versuche / 15 Min,
  überlebt Container-Restart) — alte In-Memory-Lockouts ersetzt
- SMTP-Versand: alle 'except: pass' durch logger.exception ersetzt;
  Fehlversuche landen in failed_emails-Tabelle für späteres Retry
- Referral-Counter Race gefixt: UPDATE partner_codes SET uses=uses+1
  ... WHERE uses<max_uses RETURNING — atomar statt SELECT+UPDATE

RACE CONDITIONS (routes/invoices.py, database.py)
- Neue invoice_counters-Tabelle für atomare Nummernvergabe
- _next_invoice_number nutzt BEGIN IMMEDIATE + atomares UPDATE
- Funktioniert für RG- und ST-Prefixe (Stornorechnungen)
- Race-Test verifiziert (5 Threads × 20 Calls = 100 eindeutige Nummern)

VERSION + TESTS + ERROR-DIGEST (VERSION, Makefile, tests/, scheduler.py)
- Neue VERSION-Datei (Single Source of Truth) — main.py liest beim
  Startup
- Makefile-Target 'make bump' propagiert in sw.js, app.js, index.html
- Makefile-Target 'make test' setzt venv auf, läuft pytest
- 19 Smoke-Tests in tests/ (health, auth, diary, invoice) — alle grün
- Scheduler: täglicher _job_error_digest um 06:30 → schickt Error-
  Zusammenfassung an ADMIN_EMAIL (still wenn keine Errors)

DSGVO + A11Y + ERSTE-HILFE
- landing.html: 'HTML und ODS' → 'JSON' (tatsächlich implementiert)
- datenschutz.js: Sektion Account-Löschung erweitert (sofort gelöscht /
  anonymisiert / 10 Jahre für Rechnungen)
- erste-hilfe.js: prominentes Warning-Banner oben (ersetzt keine
  Tierarzt-Beratung); Notfallnummern gruppiert nach Land, TODO-Platz-
  halter für AT-Uni-Klinik, CH Tox Info Suisse, CH Tierspital Zürich
- ui.js Modal: ESC schließt, Focus-Trap, Auto-Focus erstes Element,
  Restore Focus auf vorigen Caller
- impressum.js Kontaktformular: Labels mit for=cf-name etc.

NEUE DB-TABELLEN (idempotent via CREATE TABLE IF NOT EXISTS)
- jwt_blacklist, login_attempts, failed_emails, invoice_counters

NEUE ENV-VARS
- JWT_REFRESH_FRACTION (Default 2)
- JWT_EXPIRY_DAYS Default geändert (30 → 7)
This commit is contained in:
rene 2026-05-26 20:12:01 +02:00
parent 6224044654
commit 9394bab1fb
23 changed files with 1208 additions and 78 deletions

View file

@ -3,25 +3,119 @@
import os
import secrets
import string
from datetime import datetime, timedelta
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
get_current_user, decode_token, blacklist_jti, JWT_EXPIRY
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
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:
@ -45,8 +139,9 @@ def _send_verification_email(email: str, name: str, token: str):
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
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):
@ -69,7 +164,7 @@ 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
max_age=JWT_EXPIRY * 24 * 3600
)
@ -113,16 +208,24 @@ async def register(data: RegisterRequest, response: Response, request: Request):
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=?",
"SELECT id, grants_founder, max_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=?",
# 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(
@ -155,8 +258,15 @@ 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.")
# 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(
@ -165,13 +275,13 @@ async def login(data: LoginRequest, response: Response, request: Request):
).fetchone()
if not user or not verify_password(data.password, user["pw_hash"]):
record_login_failure(data.email)
_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")
clear_login_failures(data.email)
_db_clear_login_failures(data.email)
token = create_token(user["id"], user["rolle"])
_set_cookie(response, token)
@ -184,7 +294,30 @@ async def login(data: LoginRequest, response: Response, request: Request):
@router.post("/logout")
async def logout(response: Response):
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}
@ -332,8 +465,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
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
except Exception as exc:
_log_smtp_failure(data.email, subject, plain, exc, context="forgot_password")
return {"ok": True}
@ -372,8 +505,8 @@ async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_curr
f"Nachricht: {data.message or ''}\n\n"
f"Admin-Panel: https://banyaro.app/#admin")
_send_smtp(admin_email, subject, body, "support")
except Exception:
pass
except Exception as exc:
_log_smtp_failure(admin_email, subject, body, exc, context="upgrade_request_admin_notify")
return {"ok": True}
@ -450,8 +583,14 @@ async def cancel_subscription(user=Depends(get_current_user)):
+ (f"Aktiv bis: {expires_de}\n" if expires_de else "")
+ "\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené")
await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain)
except Exception:
pass
except Exception as exc:
_log_smtp_failure(
user.get("email") or "",
f"Kündigung bestätigt — {tier_label if 'tier_label' in locals() else '?'}",
plain if 'plain' in locals() else "",
exc,
context="subscription_cancel_confirmation"
)
return {"ok": True, "expires_at": expires}