fix(redis): Pool-Rollout vervollständigen + TOTP-Lockout fail-closed mit 503
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:
@@ -82,41 +82,36 @@ class KioskAuthService:
|
||||
db: AsyncSession,
|
||||
) -> tuple[User, str]:
|
||||
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_async_redis
|
||||
|
||||
# Redis für Brute-Force-Schutz (async)
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_pin_lockout(device_id, personnel_number, redis_client)
|
||||
redis_client = get_async_redis()
|
||||
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_pin_lockout(device_id, personnel_number, redis_client)
|
||||
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
if user is None:
|
||||
# Fehlversuch zählen auch bei unbekannter Personalnummer
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
|
||||
)
|
||||
if user is None:
|
||||
# Fehlversuch zählen auch bei unbekannter Personalnummer
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
|
||||
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_pin_failures(device_id, personnel_number, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_pin_failures(device_id, personnel_number, redis_client)
|
||||
|
||||
session_token = await kiosk_session_service.create_session(
|
||||
user_id=user.id,
|
||||
@@ -140,41 +135,37 @@ class KioskAuthService:
|
||||
Gerät existiert. Erzeugt KEINE Kiosk-Session – der Aufrufer legt eine
|
||||
separate öffentliche Kurz-Session an. Gibt nur den User zurück.
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_async_redis
|
||||
|
||||
# Lockout-Key-Namespace klar vom Kiosk trennen
|
||||
lock_id = f"public:{company_id}"
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
|
||||
redis_client = get_async_redis()
|
||||
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
|
||||
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
if user is None:
|
||||
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
|
||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
||||
)
|
||||
if user is None:
|
||||
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
|
||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
||||
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
|
||||
)
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
|
||||
)
|
||||
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
||||
|
||||
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
|
||||
|
||||
return user
|
||||
|
||||
@@ -195,37 +186,33 @@ class KioskAuthService:
|
||||
gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin,
|
||||
keyed auf die NFC-UID statt Personalnummer.
|
||||
"""
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
from app.core.redis import get_async_redis
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
await self._check_pin_lockout(device_id, nfc_uid, redis_client)
|
||||
redis_client = get_async_redis()
|
||||
await self._check_pin_lockout(device_id, nfc_uid, redis_client)
|
||||
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.kiosk_nfc_uid == nfc_uid,
|
||||
User.is_active == True,
|
||||
)
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.kiosk_nfc_uid == nfc_uid,
|
||||
User.is_active == True,
|
||||
)
|
||||
if user is None:
|
||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
||||
raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.")
|
||||
)
|
||||
if user is None:
|
||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
||||
raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.")
|
||||
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
|
||||
await self._clear_pin_failures(device_id, nfc_uid, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
await self._clear_pin_failures(device_id, nfc_uid, redis_client)
|
||||
|
||||
session_token = await kiosk_session_service.create_session(
|
||||
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).
|
||||
Token ist 5 min gültig und wird in Redis gespeichert.
|
||||
"""
|
||||
from app.core.redis import get_redis_client
|
||||
redis = get_redis_client()
|
||||
if redis is None:
|
||||
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
|
||||
|
||||
from app.core.redis import get_async_redis
|
||||
redis = get_async_redis()
|
||||
token = secrets.token_urlsafe(32)
|
||||
key = QR_TOKEN_PREFIX + token
|
||||
redis.setex(key, QR_TOKEN_TTL, json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"company_id": str(company_id),
|
||||
}))
|
||||
try:
|
||||
await redis.set(key, json.dumps({
|
||||
"user_id": str(user_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
|
||||
|
||||
async def login_qr(
|
||||
@@ -261,18 +248,18 @@ class KioskAuthService:
|
||||
db: AsyncSession,
|
||||
) -> tuple[User, str]:
|
||||
"""Validiert QR-Token (einmalig) und erstellt Session."""
|
||||
from app.core.redis import get_redis_client
|
||||
redis = get_redis_client()
|
||||
if redis is None:
|
||||
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
|
||||
|
||||
from app.core.redis import get_async_redis
|
||||
redis = get_async_redis()
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
|
||||
|
||||
# Einmalig: Token sofort löschen
|
||||
redis.delete(key)
|
||||
await redis.delete(key)
|
||||
|
||||
payload = json.loads(data)
|
||||
if str(payload.get("company_id")) != str(company_id):
|
||||
|
||||
Reference in New Issue
Block a user