fix(redis): gepoolten async-Redis-Client statt Connect/Close pro Request
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
2026-09-02 22:29:08 +02:00
co-authored by Claude Sonnet 5
parent 52ecd9e5ce
commit c733ddfe40
5 changed files with 50 additions and 21 deletions
+2 -3
View File
@@ -68,12 +68,11 @@ async def _check_and_set_nonce(nonce: str) -> bool:
Replay im Fallback-Fenster → Redis sollte in Production HA sein. Replay im Fallback-Fenster → Redis sollte in Production HA sein.
""" """
try: try:
import redis.asyncio as aioredis from app.core.redis import get_async_redis
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True) r: Any = get_async_redis()
key = f"kiosk:nonce:{nonce}" key = f"kiosk:nonce:{nonce}"
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt # SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True) result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
await r.aclose()
return result is not None # None = bereits vorhanden return result is not None # None = bereits vorhanden
except Exception as e: except Exception as e:
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e) logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e)
+28 -2
View File
@@ -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 from __future__ import annotations
import logging import logging
@@ -7,10 +7,11 @@ from typing import Optional
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_redis_client = None _redis_client = None
_async_redis_client = None
def get_redis_client(): 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 global _redis_client
if _redis_client is not None: if _redis_client is not None:
return _redis_client return _redis_client
@@ -26,3 +27,28 @@ def get_redis_client():
except Exception as exc: except Exception as exc:
log.warning("Redis nicht verfügbar: %s", exc) log.warning("Redis nicht verfügbar: %s", exc)
return None 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
+2
View File
@@ -54,6 +54,8 @@ async def lifespan(app: FastAPI):
# Shutdown # Shutdown
from app.services.scheduler_service import shutdown as shutdown_scheduler from app.services.scheduler_service import shutdown as shutdown_scheduler
shutdown_scheduler() shutdown_scheduler()
from app.core.redis import close_async_redis
await close_async_redis()
await engine.dispose() await engine.dispose()
+12 -16
View File
@@ -301,9 +301,8 @@ async def totp_login(
): ):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens.""" """Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp import pyotp
import redis.asyncio as aioredis
from uuid import UUID 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.core.security import decode_partial_token
from app.models.user import User from app.models.user import User
from jwt import PyJWTError as JWTError from jwt import PyJWTError as JWTError
@@ -319,22 +318,19 @@ async def totp_login(
if not user.totp_enabled or not user.totp_secret: if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv") raise HTTPException(400, "2FA nicht aktiv")
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) redis_client = get_async_redis()
try: # M-5: Lockout-Check vor TOTP-Verifikation
# M-5: Lockout-Check vor TOTP-Verifikation await _check_totp_lockout(user_id, redis_client)
await _check_totp_lockout(user_id, redis_client)
plain_secret = _totp_plain(user) plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "") totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1): if not totp.verify(data.code, valid_window=1):
# M-5: Fehlversuch zählen # M-5: Fehlversuch zählen
await _record_totp_failure(user_id, redis_client) await _record_totp_failure(user_id, redis_client)
raise HTTPException(400, "Ungültiger Code") raise HTTPException(400, "Ungültiger Code")
# M-5: Erfolg → Fehlversuche zurücksetzen # M-5: Erfolg → Fehlversuche zurücksetzen
await _clear_totp_failures(user_id, redis_client) await _clear_totp_failures(user_id, redis_client)
finally:
await redis_client.aclose()
from datetime import datetime, timezone from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc) user.last_login = datetime.now(timezone.utc)
+6
View File
@@ -1,5 +1,11 @@
const BASE_URL = '/api/v1' 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 // Läuft ein Refresh bereits? Damit parallele Requests nicht mehrfach refreshen
let _refreshing: Promise<string | null> | null = null let _refreshing: Promise<string | null> | null = null