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

@ -592,10 +592,15 @@ def _migrate(conn_factory):
]
with conn_factory() as conn:
for table, column, col_type in migrations:
# PRAGMA table_info() liefert leere Liste fuer nicht-existierende Tabellen.
# Wir ueberspringen — die CREATE TABLE-Bloecke darunter legen sie an,
# ihre Spalten sind dort dann sowieso schon enthalten.
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
if not existing:
continue
if column not in existing:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
@ -1209,7 +1214,7 @@ def _migrate(conn_factory):
# ki_daily_calls: source-Spalte + PK auf (user_id, date, source) erweitern
ki_cols = {r[1] for r in conn.execute("PRAGMA table_info(ki_daily_calls)").fetchall()}
if "source" not in ki_cols:
if ki_cols and "source" not in ki_cols:
conn.executescript("""
ALTER TABLE ki_daily_calls RENAME TO ki_daily_calls_old;
CREATE TABLE ki_daily_calls (
@ -1301,11 +1306,19 @@ def _migrate(conn_factory):
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.")
# Performance-Indizes für häufige Queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)")
# Defensiv — einige Tabellen werden erst beim ersten Zugriff in
# ihren jeweiligen Routes-Modulen angelegt (z.B. ki_daily_calls)
for _idx_sql in (
"CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)",
"CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)",
"CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)",
):
try:
conn.execute(_idx_sql)
except Exception as _e:
logger.debug("Index-Migration uebersprungen (%s): %s", _idx_sql.split()[5], _e)
logger.info("Migration: Performance-Indizes bereit.")
# Züchter-Tabellen
@ -2444,7 +2457,17 @@ def _migrate(conn_factory):
total REAL NOT NULL
)
""")
logger.info("Migration: invoices + invoice_items bereit.")
# Atomare Rechnungsnummern-Vergabe pro (prefix, year)
# next_num = naechste zu vergebende Nummer (startet bei 1)
conn.execute("""
CREATE TABLE IF NOT EXISTS invoice_counters (
prefix TEXT NOT NULL,
year INTEGER NOT NULL,
next_num INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (prefix, year)
)
""")
logger.info("Migration: invoices + invoice_items + invoice_counters bereit.")
except Exception as e:
logger.warning(f"Migration invoices: {e}")
@ -2461,6 +2484,63 @@ def _migrate(conn_factory):
else:
logger.info("Migration: users.geburtstag bereits vorhanden.")
# ---- Security Hardening (Sprint60) ----
# JWT-Blacklist: invalidierte Tokens nach Logout / Sperrungen
conn.executescript("""
CREATE TABLE IF NOT EXISTS jwt_blacklist (
jti TEXT PRIMARY KEY,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_jwt_blacklist_expires ON jwt_blacklist(expires_at);
""")
logger.info("Migration: jwt_blacklist Tabelle bereit.")
# Login-Brute-Force-Lockout (persistent statt In-Memory)
conn.executescript("""
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY COLLATE NOCASE,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt TEXT NOT NULL,
locked_until TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked ON login_attempts(locked_until);
""")
logger.info("Migration: login_attempts Tabelle bereit.")
# Fehlgeschlagene E-Mail-Zustellungen — für Admin-Retry / Diagnose
conn.executescript("""
CREATE TABLE IF NOT EXISTS failed_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
error TEXT NOT NULL,
context TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_failed_emails_created ON failed_emails(created_at DESC);
""")
logger.info("Migration: failed_emails Tabelle bereit.")
# Second-Pass der ALTER-TABLE-Migrations:
# Manche Migrations (z.B. diary_media.img_width) referenzieren Tabellen,
# die erst weiter unten in dieser Funktion per CREATE IF NOT EXISTS angelegt
# werden. Beim ersten Durchgang wurden sie uebersprungen — jetzt nachziehen.
for table, column, col_type in migrations:
existing = [
row[1] for row in
conn.execute(f"PRAGMA table_info({table})").fetchall()
]
if not existing:
continue
if column not in existing:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
logger.info(f"Migration (2nd pass): {table}.{column} hinzugefügt.")
except Exception as _e:
logger.debug(f"Migration (2nd pass) skipped {table}.{column}: {_e}")
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""