From c733ddfe408ddeca43ea4714cede61e13f9dd45c Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 2 Sep 2026 22:29:08 +0200 Subject: [PATCH] fix(redis): gepoolten async-Redis-Client statt Connect/Close pro Request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOTP-Login und Kiosk-Nonce-Check öffneten/schlossen bisher pro Request eine neue aioredis-Verbindung. Neuer get_async_redis()-Pool in core/redis.py wird von beiden Stellen genutzt, sauberer Shutdown im FastAPI-Lifespan. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn --- backend/app/core/kiosk_security.py | 5 ++--- backend/app/core/redis.py | 30 ++++++++++++++++++++++++++++-- backend/app/main.py | 2 ++ backend/app/routers/auth.py | 28 ++++++++++++---------------- frontend/src/api/client.ts | 6 ++++++ 5 files changed, 50 insertions(+), 21 deletions(-) diff --git a/backend/app/core/kiosk_security.py b/backend/app/core/kiosk_security.py index d48a207..c7b26ee 100644 --- a/backend/app/core/kiosk_security.py +++ b/backend/app/core/kiosk_security.py @@ -68,12 +68,11 @@ async def _check_and_set_nonce(nonce: str) -> bool: Replay im Fallback-Fenster → Redis sollte in Production HA sein. """ try: - import redis.asyncio as aioredis - r: Any = aioredis.from_url(settings.redis_url, decode_responses=True) + from app.core.redis import get_async_redis + r: Any = get_async_redis() key = f"kiosk:nonce:{nonce}" # SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt result = await r.set(key, "1", ex=_NONCE_TTL, nx=True) - await r.aclose() return result is not None # None = bereits vorhanden except Exception as e: logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e) diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py index 06515ca..4beb7e3 100644 --- a/backend/app/core/redis.py +++ b/backend/app/core/redis.py @@ -1,4 +1,4 @@ -"""Redis-Client für TimeMaster (sync, für Kiosk-Nonce-Cache und Sessions).""" +"""Redis-Client für TimeMaster (sync + async, für Kiosk-Nonce-Cache, Sessions, Locks).""" from __future__ import annotations import logging @@ -7,10 +7,11 @@ from typing import Optional log = logging.getLogger(__name__) _redis_client = None +_async_redis_client = None def get_redis_client(): - """Gibt den Redis-Client zurück oder None wenn nicht konfiguriert/erreichbar.""" + """Gibt den (sync) Redis-Client zurück oder None wenn nicht konfiguriert/erreichbar.""" global _redis_client if _redis_client is not None: return _redis_client @@ -26,3 +27,28 @@ def get_redis_client(): except Exception as exc: log.warning("Redis nicht verfügbar: %s", exc) return None + + +def get_async_redis(): + """Gepoolter async Redis-Client (eine Verbindungspool-Instanz pro Prozess). + + Nicht pro Request neu verbinden/schließen (frühere Falle in totp_login und + kiosk_security._check_and_set_nonce) – der Pool verwaltet Connections selbst. + Verbindungsfehler zeigen sich erst beim ersten Call (kein Ping hier), Aufrufer + müssen weiterhin except behandeln (Nonce-Fallback, TOTP-Lockout). + """ + global _async_redis_client + if _async_redis_client is None: + import redis.asyncio as aioredis + from app.core.config import settings + url = getattr(settings, "redis_url", "redis://localhost:6379/0") + _async_redis_client = aioredis.from_url(url, decode_responses=True) + return _async_redis_client + + +async def close_async_redis() -> None: + """Pool sauber schließen – im FastAPI-Lifespan-Shutdown aufrufen.""" + global _async_redis_client + if _async_redis_client is not None: + await _async_redis_client.aclose() + _async_redis_client = None diff --git a/backend/app/main.py b/backend/app/main.py index 0298d7a..a5ecd72 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,6 +54,8 @@ async def lifespan(app: FastAPI): # Shutdown from app.services.scheduler_service import shutdown as shutdown_scheduler shutdown_scheduler() + from app.core.redis import close_async_redis + await close_async_redis() await engine.dispose() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index dcaab51..c949f62 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -301,9 +301,8 @@ async def totp_login( ): """Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens.""" import pyotp - import redis.asyncio as aioredis from uuid import UUID - from app.core.config import settings + from app.core.redis import get_async_redis from app.core.security import decode_partial_token from app.models.user import User from jwt import PyJWTError as JWTError @@ -319,22 +318,19 @@ async def totp_login( if not user.totp_enabled or not user.totp_secret: raise HTTPException(400, "2FA nicht aktiv") - redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) - try: - # M-5: Lockout-Check vor TOTP-Verifikation - await _check_totp_lockout(user_id, redis_client) + redis_client = get_async_redis() + # M-5: Lockout-Check vor TOTP-Verifikation + await _check_totp_lockout(user_id, redis_client) - plain_secret = _totp_plain(user) - totp = pyotp.TOTP(plain_secret or "") - if not totp.verify(data.code, valid_window=1): - # M-5: Fehlversuch zählen - await _record_totp_failure(user_id, redis_client) - raise HTTPException(400, "Ungültiger Code") + plain_secret = _totp_plain(user) + totp = pyotp.TOTP(plain_secret or "") + if not totp.verify(data.code, valid_window=1): + # M-5: Fehlversuch zählen + await _record_totp_failure(user_id, redis_client) + raise HTTPException(400, "Ungültiger Code") - # M-5: Erfolg → Fehlversuche zurücksetzen - await _clear_totp_failures(user_id, redis_client) - finally: - await redis_client.aclose() + # M-5: Erfolg → Fehlversuche zurücksetzen + await _clear_totp_failures(user_id, redis_client) from datetime import datetime, timezone user.last_login = datetime.now(timezone.utc) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7213e1e..cc554dc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,11 @@ const BASE_URL = '/api/v1' +// ADR: Access-Token bewusst in localStorage (nicht in-memory), Tradeoff akzeptiert. +// Grund: 30min-Lifetime begrenzt XSS-Fenster; Refresh-Token liegt bereits als +// HttpOnly-Cookie (siehe M-2 unten). Voller HttpOnly-Umbau des Access-Tokens +// wäre größerer Architektur-Eingriff (Backend müsste jede Response als Cookie +// setzen) – noch nicht umgesetzt, siehe Security-Review 2026-09. + // Läuft ein Refresh bereits? Damit parallele Requests nicht mehrfach refreshen let _refreshing: Promise | null = null