fix(redis): gepoolten async-Redis-Client statt Connect/Close pro Request
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,8 +318,7 @@ 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:
|
||||
redis_client = get_async_redis()
|
||||
# M-5: Lockout-Check vor TOTP-Verifikation
|
||||
await _check_totp_lockout(user_id, redis_client)
|
||||
|
||||
@@ -333,8 +331,6 @@ async def totp_login(
|
||||
|
||||
# M-5: Erfolg → Fehlversuche zurücksetzen
|
||||
await _clear_totp_failures(user_id, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
|
||||
from datetime import datetime, timezone
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
|
||||
@@ -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<string | null> | null = null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user