From 107cba99f9e52cb507e81ee673a0cde6dabbd1fa Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 00:12:44 +0200 Subject: [PATCH] fix(redis): INCR+EXPIRE atomar via Pipeline (MULTI/EXEC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login-, TOTP- und Kiosk-PIN-Lockout zählten Fehlversuche mit separaten INCR/EXPIRE-Calls. Zwischen beiden konnte der Fail-Counter-Key kurzzeitig ohne TTL bestehen bleiben (Crash-Fenster) - nicht atomar. Jetzt per redis.pipeline(transaction=True) als MULTI/EXEC. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn --- backend/app/routers/auth.py | 8 ++++++-- backend/app/services/auth_service.py | 8 ++++++-- backend/app/services/kiosk_auth_service.py | 8 ++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b9ec90a..f207f62 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -278,8 +278,12 @@ async def _record_totp_failure(user_id: str, redis) -> None: """Zählt TOTP-Fehlversuch und setzt Lockout nach TOTP_MAX_ATTEMPTS Fehlversuchen.""" fail_key = f"totp_fails:{user_id}" lock_key = f"totp_lockout:{user_id}" - fails = await redis.incr(fail_key) - await redis.expire(fail_key, TOTP_LOCKOUT_SECONDS) + # INCR+EXPIRE atomar (MULTI/EXEC) – sonst könnte fail_key zwischen beiden + # Calls kurzzeitig ohne TTL bestehen (Crash-Fenster). + async with redis.pipeline(transaction=True) as pipe: + pipe.incr(fail_key) + pipe.expire(fail_key, TOTP_LOCKOUT_SECONDS) + fails, _ = await pipe.execute() if fails >= TOTP_MAX_ATTEMPTS: await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS) await redis.delete(fail_key) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 1e1695d..64ab4a7 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -65,8 +65,12 @@ class AuthService: """Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen.""" fail_key = f"login_fails:{email.lower()}" lockout_key = f"login_lockout:{email.lower()}" - fails = await redis.incr(fail_key) - await redis.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC) + # INCR+EXPIRE atomar (MULTI/EXEC) – sonst könnte fail_key zwischen beiden + # Calls kurzzeitig ohne TTL bestehen (Crash-Fenster). + async with redis.pipeline(transaction=True) as pipe: + pipe.incr(fail_key) + pipe.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC) + fails, _ = await pipe.execute() if fails >= FAILED_LOGIN_MAX: await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC) await redis.delete(fail_key) diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py index 2dc9f9b..ec00e49 100644 --- a/backend/app/services/kiosk_auth_service.py +++ b/backend/app/services/kiosk_auth_service.py @@ -54,8 +54,12 @@ class KioskAuthService: fail_key = f"pin_fails:{device_id}:{personnel_number}" lockout_key = f"pin_lockout:{device_id}:{personnel_number}" - fails = await redis.incr(fail_key) - await redis.expire(fail_key, PIN_LOCKOUT_SECONDS) + # INCR+EXPIRE atomar (MULTI/EXEC) – sonst könnte fail_key zwischen beiden + # Calls kurzzeitig ohne TTL bestehen (Crash-Fenster). + async with redis.pipeline(transaction=True) as pipe: + pipe.incr(fail_key) + pipe.expire(fail_key, PIN_LOCKOUT_SECONDS) + fails, _ = await pipe.execute() if fails >= PIN_MAX_ATTEMPTS: await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS)