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.
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user