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

@ -4,11 +4,12 @@ JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt.
"""
import os
import uuid
import jwt
import bcrypt
import logging
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from database import db
@ -16,7 +17,12 @@ from database import db
logger = logging.getLogger(__name__)
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGO = "HS256"
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
# Default 7 Tage (vorher 30) — sensible Hundedaten brauchen kürzere Sessions.
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "7"))
# Wenn das Token älter als (JWT_EXPIRY / JWT_REFRESH_FRACTION) Tage ist,
# wird das Cookie bei einer authentifizierten Anfrage neu gesetzt (Sliding Session).
# Standard 2 → Refresh wenn mehr als die Hälfte der Laufzeit weg ist.
JWT_REFRESH_FRACTION = int(os.getenv("JWT_REFRESH_FRACTION", "2"))
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
raise RuntimeError(
@ -27,6 +33,44 @@ if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
security = HTTPBearer(auto_error=False)
# ------------------------------------------------------------------
# JWT-Blacklist (Logout invalidiert Token serverseitig)
# ------------------------------------------------------------------
def _is_jti_blacklisted(jti: str) -> bool:
if not jti:
return False
with db() as conn:
row = conn.execute(
"SELECT 1 FROM jwt_blacklist WHERE jti=?", (jti,)
).fetchone()
return bool(row)
def blacklist_jti(jti: str, expires_at_iso: str):
"""Token-ID auf die Blacklist setzen. expires_at_iso entspricht dem urspr. exp."""
if not jti or not expires_at_iso:
return
with db() as conn:
conn.execute(
"INSERT OR IGNORE INTO jwt_blacklist (jti, expires_at) VALUES (?,?)",
(jti, expires_at_iso)
)
def _purge_expired_jwt() -> int:
"""Entfernt abgelaufene Blacklist-Einträge. Gibt Anzahl gelöschter Zeilen zurück.
Kann später aus dem Scheduler aufgerufen werden."""
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
cur = conn.execute(
"DELETE FROM jwt_blacklist WHERE expires_at < ?", (now,)
)
deleted = cur.rowcount or 0
if deleted:
logger.info("JWT-Blacklist Cleanup: %d abgelaufene Einträge entfernt.", deleted)
return deleted
# ------------------------------------------------------------------
# Passwort
# ------------------------------------------------------------------
@ -42,17 +86,25 @@ def verify_password(password: str, hashed: str) -> bool:
# JWT
# ------------------------------------------------------------------
def create_token(user_id: int, rolle: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"rolle": rolle,
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY),
"iat": datetime.now(timezone.utc),
"exp": now + timedelta(days=JWT_EXPIRY),
"iat": now,
"jti": uuid.uuid4().hex,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
def decode_token(token: str) -> dict:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
"""Dekodiert + prüft Signatur, Ablauf und Blacklist.
Wirft jwt.InvalidTokenError wenn Token auf der Blacklist steht."""
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
jti = payload.get("jti")
if jti and _is_jti_blacklisted(jti):
raise jwt.InvalidTokenError("Token wurde invalidiert (Logout).")
return payload
# ------------------------------------------------------------------
@ -68,6 +120,33 @@ def _get_token_from_request(
return request.cookies.get("by_token")
def _maybe_refresh_token(request: Request, payload: dict):
"""Sliding-Session: setzt das Cookie neu wenn das Token älter als JWT_EXPIRY/JWT_REFRESH_FRACTION ist.
Nur wirksam wenn das Token aus dem Cookie kommt (nicht Bearer-Header).
Hängt das neue Token an request.state.refresh_token, damit Middleware es ins Response-Cookie setzen kann."""
try:
# Nur refreshen wenn Token aus Cookie kam (Bearer-Clients verwalten Tokens selbst)
if not request.cookies.get("by_token"):
return
iat = payload.get("iat")
exp = payload.get("exp")
if not iat or not exp:
return
# Wenn mehr als 1/JWT_REFRESH_FRACTION der Laufzeit vergangen ist → neues Token ausstellen
now_ts = datetime.now(timezone.utc).timestamp()
lifetime = exp - iat
if lifetime <= 0 or JWT_REFRESH_FRACTION <= 0:
return
threshold = iat + (lifetime / JWT_REFRESH_FRACTION)
if now_ts < threshold:
return
new_token = create_token(int(payload["sub"]), payload.get("rolle", "user"))
request.state.refresh_token = new_token
except Exception:
# Refresh-Fehler dürfen die eigentliche Anfrage nie blockieren.
logger.exception("Sliding-Token-Refresh fehlgeschlagen")
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
@ -99,6 +178,9 @@ def get_current_user(
reason = user.get("ban_reason") or "Kein Grund angegeben."
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Account gesperrt: {reason}")
# Sliding-Session: ggf. neues Token vorbereiten (in Middleware ins Cookie schreiben).
_maybe_refresh_token(request, payload)
return user