fix(redis): Pool-Rollout vervollständigen + TOTP-Lockout fail-closed mit 503
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Redis-Review deckte auf, dass der Pool-Fix vom letzten Commit nur
totp_login/kiosk_security erreichte. Login/Refresh (auth_service.py) und
PIN/NFC/QR-Kiosk-Login (kiosk_auth_service.py) öffneten weiterhin pro
Request eine neue aioredis-Verbindung. Zusätzlich nutzten
kiosk_session_service.py und public_stamp_session_service.py den
*sync* Redis-Client aus async-Code – blockierender Socket-Call im
Event-Loop bei jedem Kiosk-/Stempel-Request.

Alle auf get_async_redis() umgestellt. TOTP-Lockout wirft jetzt 503
statt eines ungefangenen 500 bei Redis-Ausfall (RedisError explizit
gefangen, eigene HTTPExceptions unberührt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
2026-09-03 00:09:35 +02:00
co-authored by Claude Sonnet 5
parent 5ba5a99e02
commit 56f6f16e27
5 changed files with 211 additions and 243 deletions
+5
View File
@@ -318,7 +318,10 @@ 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")
from redis.exceptions import RedisError
redis_client = get_async_redis() 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)
@@ -331,6 +334,8 @@ async def totp_login(
# 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)
except RedisError as exc:
raise HTTPException(503, "2FA-Login vorübergehend nicht verfügbar (Redis).") from exc
from datetime import datetime, timezone from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc) user.last_login = datetime.now(timezone.utc)
+4 -10
View File
@@ -111,15 +111,14 @@ class AuthService:
return tokens return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse: async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
import redis.asyncio as aioredis from app.core.redis import get_async_redis
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.user import AuthProvider from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service from app.services.ldap_service import ldap_service
client_ip = _get_client_ip(request) client_ip = _get_client_ip(request)
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) redis_client = get_async_redis()
try:
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing) # Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
await self._check_login_lockout(data.email, redis_client) await self._check_login_lockout(data.email, redis_client)
@@ -182,8 +181,6 @@ class AuthService:
# Erfolgreicher Login: Fehlversuche zurücksetzen # Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_login_failures(data.email, redis_client) await self._clear_login_failures(data.email, redis_client)
finally:
await redis_client.aclose()
# AuditLog: Erfolgreicher Login # AuditLog: Erfolgreicher Login
db.add(AuditLog( db.add(AuditLog(
@@ -212,14 +209,13 @@ class AuthService:
return await self._create_session(user, db, request=request) return await self._create_session(user, db, request=request)
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse: async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
import redis.asyncio as aioredis from app.core.redis import get_async_redis
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
token_hash = hash_token(raw_token) token_hash = hash_token(raw_token)
burned_key = f"burned_token:{token_hash}" burned_key = f"burned_token:{token_hash}"
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) redis_client = get_async_redis()
try:
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde # Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
burned_user_id = await redis_client.get(burned_key) burned_user_id = await redis_client.get(burned_key)
if burned_user_id: if burned_user_id:
@@ -266,8 +262,6 @@ class AuthService:
# Verbrannten Token-Hash 48h in Redis merken # Verbrannten Token-Hash 48h in Redis merken
await redis_client.set(burned_key, user_id_str, ex=48 * 3600) await redis_client.set(burned_key, user_id_str, ex=48 * 3600)
finally:
await redis_client.aclose()
return await self._create_session(user, db) return await self._create_session(user, db)
+20 -33
View File
@@ -82,12 +82,9 @@ class KioskAuthService:
db: AsyncSession, db: AsyncSession,
) -> tuple[User, str]: ) -> tuple[User, str]:
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token).""" """Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
import redis.asyncio as aioredis from app.core.redis import get_async_redis
from app.core.config import settings
# Redis für Brute-Force-Schutz (async) redis_client = get_async_redis()
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing) # 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
await self._check_pin_lockout(device_id, personnel_number, redis_client) await self._check_pin_lockout(device_id, personnel_number, redis_client)
@@ -115,8 +112,6 @@ class KioskAuthService:
# Erfolgreicher Login: Fehlversuche zurücksetzen # Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_pin_failures(device_id, personnel_number, redis_client) await self._clear_pin_failures(device_id, personnel_number, redis_client)
finally:
await redis_client.aclose()
session_token = await kiosk_session_service.create_session( session_token = await kiosk_session_service.create_session(
user_id=user.id, user_id=user.id,
@@ -140,14 +135,12 @@ class KioskAuthService:
Gerät existiert. Erzeugt KEINE Kiosk-Session der Aufrufer legt eine Gerät existiert. Erzeugt KEINE Kiosk-Session der Aufrufer legt eine
separate öffentliche Kurz-Session an. Gibt nur den User zurück. separate öffentliche Kurz-Session an. Gibt nur den User zurück.
""" """
import redis.asyncio as aioredis from app.core.redis import get_async_redis
from app.core.config import settings
# Lockout-Key-Namespace klar vom Kiosk trennen # Lockout-Key-Namespace klar vom Kiosk trennen
lock_id = f"public:{company_id}" lock_id = f"public:{company_id}"
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) redis_client = get_async_redis()
try:
await self._check_pin_lockout(lock_id, personnel_number, redis_client) await self._check_pin_lockout(lock_id, personnel_number, redis_client)
user = await db.scalar( user = await db.scalar(
@@ -173,8 +166,6 @@ class KioskAuthService:
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.") raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
await self._clear_pin_failures(lock_id, personnel_number, redis_client) await self._clear_pin_failures(lock_id, personnel_number, redis_client)
finally:
await redis_client.aclose()
return user return user
@@ -195,11 +186,9 @@ class KioskAuthService:
gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin, gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin,
keyed auf die NFC-UID statt Personalnummer. keyed auf die NFC-UID statt Personalnummer.
""" """
import redis.asyncio as aioredis from app.core.redis import get_async_redis
from app.core.config import settings
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) redis_client = get_async_redis()
try:
await self._check_pin_lockout(device_id, nfc_uid, redis_client) await self._check_pin_lockout(device_id, nfc_uid, redis_client)
user = await db.scalar( user = await db.scalar(
@@ -224,8 +213,6 @@ class KioskAuthService:
raise HTTPException(status_code=401, detail="Falscher PIN.") raise HTTPException(status_code=401, detail="Falscher PIN.")
await self._clear_pin_failures(device_id, nfc_uid, redis_client) await self._clear_pin_failures(device_id, nfc_uid, redis_client)
finally:
await redis_client.aclose()
session_token = await kiosk_session_service.create_session( session_token = await kiosk_session_service.create_session(
user_id=user.id, user_id=user.id,
@@ -240,17 +227,17 @@ class KioskAuthService:
Erzeugt einen einmaligen QR-Token (für die Web-App: User scannt QR am Kiosk). Erzeugt einen einmaligen QR-Token (für die Web-App: User scannt QR am Kiosk).
Token ist 5 min gültig und wird in Redis gespeichert. Token ist 5 min gültig und wird in Redis gespeichert.
""" """
from app.core.redis import get_redis_client from app.core.redis import get_async_redis
redis = get_redis_client() redis = get_async_redis()
if redis is None:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
key = QR_TOKEN_PREFIX + token key = QR_TOKEN_PREFIX + token
redis.setex(key, QR_TOKEN_TTL, json.dumps({ try:
await redis.set(key, json.dumps({
"user_id": str(user_id), "user_id": str(user_id),
"company_id": str(company_id), "company_id": str(company_id),
})) }), ex=QR_TOKEN_TTL)
except Exception as exc:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc
return token return token
async def login_qr( async def login_qr(
@@ -261,18 +248,18 @@ class KioskAuthService:
db: AsyncSession, db: AsyncSession,
) -> tuple[User, str]: ) -> tuple[User, str]:
"""Validiert QR-Token (einmalig) und erstellt Session.""" """Validiert QR-Token (einmalig) und erstellt Session."""
from app.core.redis import get_redis_client from app.core.redis import get_async_redis
redis = get_redis_client() redis = get_async_redis()
if redis is None:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
key = QR_TOKEN_PREFIX + qr_token key = QR_TOKEN_PREFIX + qr_token
data = redis.get(key) try:
data = await redis.get(key)
except Exception as exc:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc
if data is None: if data is None:
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.") raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
# Einmalig: Token sofort löschen # Einmalig: Token sofort löschen
redis.delete(key) await redis.delete(key)
payload = json.loads(data) payload = json.loads(data)
if str(payload.get("company_id")) != str(company_id): if str(payload.get("company_id")) != str(company_id):
+7 -19
View File
@@ -23,21 +23,9 @@ SESSION_KEY_PREFIX = "kiosk_session:"
class KioskSessionService: class KioskSessionService:
def _get_redis(self): def _get_redis(self):
"""Redis-Client aus app.core.redis holen. Raises 503 wenn nicht verfügbar.""" """Gepoolter async Redis-Client aus app.core.redis."""
try: from app.core.redis import get_async_redis
from app.core.redis import get_redis_client return get_async_redis()
client = get_redis_client()
if client is None:
raise HTTPException(
status_code=503,
detail="Session-Service nicht verfügbar (Redis nicht erreichbar)."
)
return client
except ImportError:
raise HTTPException(
status_code=503,
detail="Session-Service nicht konfiguriert."
)
async def create_session( async def create_session(
self, self,
@@ -58,7 +46,7 @@ class KioskSessionService:
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
try: try:
redis.setex(key, SESSION_TTL_SECONDS, json.dumps(payload)) await redis.set(key, json.dumps(payload), ex=SESSION_TTL_SECONDS)
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -71,7 +59,7 @@ class KioskSessionService:
redis = self._get_redis() redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token key = SESSION_KEY_PREFIX + session_token
try: try:
data = redis.get(key) data = await redis.get(key)
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -86,7 +74,7 @@ class KioskSessionService:
redis = self._get_redis() redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token key = SESSION_KEY_PREFIX + session_token
try: try:
redis.delete(key) await redis.delete(key)
except Exception: except Exception:
pass # Best-effort pass # Best-effort
@@ -95,7 +83,7 @@ class KioskSessionService:
redis = self._get_redis() redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token key = SESSION_KEY_PREFIX + session_token
try: try:
result = redis.expire(key, SESSION_TTL_SECONDS) result = await redis.expire(key, SESSION_TTL_SECONDS)
return bool(result) return bool(result)
except Exception: except Exception:
return False return False
@@ -23,14 +23,8 @@ SESSION_KEY_PREFIX = "public_stamp_session:"
class PublicStampSessionService: class PublicStampSessionService:
def _get_redis(self): def _get_redis(self):
from app.core.redis import get_redis_client from app.core.redis import get_async_redis
client = get_redis_client() return get_async_redis()
if client is None:
raise HTTPException(
status_code=503,
detail="Stempel-Service nicht verfügbar (Redis nicht erreichbar).",
)
return client
async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str: async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str:
redis = self._get_redis() redis = self._get_redis()
@@ -42,7 +36,7 @@ class PublicStampSessionService:
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
} }
try: try:
redis.setex(key, PUBLIC_STAMP_SESSION_TTL, json.dumps(payload)) await redis.set(key, json.dumps(payload), ex=PUBLIC_STAMP_SESSION_TTL)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}") raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}")
return session_token return session_token
@@ -50,7 +44,7 @@ class PublicStampSessionService:
async def get_session(self, session_token: str) -> Optional[dict]: async def get_session(self, session_token: str) -> Optional[dict]:
redis = self._get_redis() redis = self._get_redis()
try: try:
data = redis.get(SESSION_KEY_PREFIX + session_token) data = await redis.get(SESSION_KEY_PREFIX + session_token)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}") raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}")
if data is None: if data is None:
@@ -69,7 +63,7 @@ class PublicStampSessionService:
async def invalidate_session(self, session_token: str) -> None: async def invalidate_session(self, session_token: str) -> None:
redis = self._get_redis() redis = self._get_redis()
try: try:
redis.delete(SESSION_KEY_PREFIX + session_token) await redis.delete(SESSION_KEY_PREFIX + session_token)
except Exception: except Exception:
pass # Best-effort pass # Best-effort