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
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue