fix(redis): INCR+EXPIRE atomar via Pipeline (MULTI/EXEC)
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

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
2026-09-03 00:12:44 +02:00
co-authored by Claude Sonnet 5
parent 56f6f16e27
commit 107cba99f9
3 changed files with 18 additions and 6 deletions
+6 -2
View File
@@ -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.""" """Zählt TOTP-Fehlversuch und setzt Lockout nach TOTP_MAX_ATTEMPTS Fehlversuchen."""
fail_key = f"totp_fails:{user_id}" fail_key = f"totp_fails:{user_id}"
lock_key = f"totp_lockout:{user_id}" lock_key = f"totp_lockout:{user_id}"
fails = await redis.incr(fail_key) # INCR+EXPIRE atomar (MULTI/EXEC) sonst könnte fail_key zwischen beiden
await redis.expire(fail_key, TOTP_LOCKOUT_SECONDS) # 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: if fails >= TOTP_MAX_ATTEMPTS:
await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS) await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS)
await redis.delete(fail_key) await redis.delete(fail_key)
+6 -2
View File
@@ -65,8 +65,12 @@ class AuthService:
"""Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen.""" """Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen."""
fail_key = f"login_fails:{email.lower()}" fail_key = f"login_fails:{email.lower()}"
lockout_key = f"login_lockout:{email.lower()}" lockout_key = f"login_lockout:{email.lower()}"
fails = await redis.incr(fail_key) # INCR+EXPIRE atomar (MULTI/EXEC) sonst könnte fail_key zwischen beiden
await redis.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC) # 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: if fails >= FAILED_LOGIN_MAX:
await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC) await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC)
await redis.delete(fail_key) await redis.delete(fail_key)
+6 -2
View File
@@ -54,8 +54,12 @@ class KioskAuthService:
fail_key = f"pin_fails:{device_id}:{personnel_number}" fail_key = f"pin_fails:{device_id}:{personnel_number}"
lockout_key = f"pin_lockout:{device_id}:{personnel_number}" lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
fails = await redis.incr(fail_key) # INCR+EXPIRE atomar (MULTI/EXEC) sonst könnte fail_key zwischen beiden
await redis.expire(fail_key, PIN_LOCKOUT_SECONDS) # 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: if fails >= PIN_MAX_ATTEMPTS:
await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS) await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS)