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:
parent
6224044654
commit
9394bab1fb
23 changed files with 1208 additions and 78 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,15 +49,66 @@ class CancelBody(BaseModel):
|
|||
# Hilfsfunktionen
|
||||
# ------------------------------------------------------------------
|
||||
def _next_invoice_number(conn, prefix="RG"):
|
||||
"""Vergibt atomar die naechste Rechnungsnummer fuer (prefix, year).
|
||||
|
||||
Race-frei dank dedizierter Counter-Tabelle 'invoice_counters' und
|
||||
BEGIN IMMEDIATE — gleichzeitige Aufrufe von zwei Admins koennen nicht
|
||||
dieselbe Nummer ziehen. SQLite serialisiert die Writer; der zweite
|
||||
wartet bis busy_timeout.
|
||||
|
||||
Beim ersten Aufruf fuer (prefix, year) wird die Counter-Row angelegt;
|
||||
dabei wird der aktuelle Stand aus der invoices-Tabelle uebernommen
|
||||
(Backfill fuer bestehende Bestaende vor Einfuehrung des Counters).
|
||||
"""
|
||||
year = datetime.now().year
|
||||
last = conn.execute(
|
||||
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1",
|
||||
(f"{prefix}-{year}-%",)
|
||||
|
||||
# Falls noch keine Transaktion offen ist: BEGIN IMMEDIATE,
|
||||
# damit der naechste Writer serialisiert wird.
|
||||
if not conn.in_transaction:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT next_num FROM invoice_counters WHERE prefix=? AND year=?",
|
||||
(prefix, year)
|
||||
).fetchone()
|
||||
if last:
|
||||
n = int(last[0].split("-")[-1]) + 1
|
||||
|
||||
if row is None:
|
||||
# Counter fehlt → Backfill aus invoices-Tabelle (max. bisheriger
|
||||
# Nummer + 1), damit ein nachtraeglich eingefuehrter Counter
|
||||
# nicht bei 1 startet und Kollisionen erzeugt.
|
||||
last = conn.execute(
|
||||
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(f"{prefix}-{year}-%",)
|
||||
).fetchone()
|
||||
# Auch cancellation_number kann den 'ST'-Prefix tragen
|
||||
last_st = conn.execute(
|
||||
"SELECT cancellation_number FROM invoices "
|
||||
"WHERE cancellation_number LIKE ? ORDER BY id DESC LIMIT 1",
|
||||
(f"{prefix}-{year}-%",)
|
||||
).fetchone()
|
||||
|
||||
existing_max = 0
|
||||
for r in (last, last_st):
|
||||
if r and r[0]:
|
||||
try:
|
||||
existing_max = max(existing_max, int(r[0].split("-")[-1]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
n = existing_max + 1
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_counters (prefix, year, next_num) VALUES (?,?,?)",
|
||||
(prefix, year, n + 1)
|
||||
)
|
||||
else:
|
||||
n = 1
|
||||
n = row[0]
|
||||
conn.execute(
|
||||
"UPDATE invoice_counters SET next_num = next_num + 1 "
|
||||
"WHERE prefix=? AND year=?",
|
||||
(prefix, year)
|
||||
)
|
||||
|
||||
return f"{prefix}-{year}-{n:04d}"
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue