Files
timemaster/backend/app/services/kiosk_session_service.py
T
patrickandClaude Sonnet 5 56f6f16e27
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
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
2026-09-03 00:09:35 +02:00

103 lines
3.4 KiB
Python

"""
Kiosk-Session-Service: Redis-basierte Short-Lived Sessions für Kiosk-User-Auth.
Nach erfolgreicher PIN/QR/NFC-Auth bekommt der Kiosk-Client ein Session-Token
(UUID, 15min TTL). Dieses Token wird in allen Stempel-Requests als Header
X-Kiosk-Session-Token mitgeschickt.
Redis ist Pflicht für Sessions. Bei Redis-Ausfall → 503.
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
SESSION_TTL_SECONDS = 15 * 60 # 15 Minuten
SESSION_KEY_PREFIX = "kiosk_session:"
class KioskSessionService:
def _get_redis(self):
"""Gepoolter async Redis-Client aus app.core.redis."""
from app.core.redis import get_async_redis
return get_async_redis()
async def create_session(
self,
user_id: uuid.UUID,
company_id: uuid.UUID,
device_id: uuid.UUID,
auth_method: str, # "pin" | "nfc" | "qr" | "list"
) -> str:
"""Erzeugt eine neue Kiosk-Session und speichert sie in Redis."""
redis = self._get_redis()
session_token = str(uuid.uuid4())
key = SESSION_KEY_PREFIX + session_token
payload = {
"user_id": str(user_id),
"company_id": str(company_id),
"device_id": str(device_id),
"auth_method": auth_method,
"created_at": datetime.now(timezone.utc).isoformat(),
}
try:
await redis.set(key, json.dumps(payload), ex=SESSION_TTL_SECONDS)
except Exception as exc:
raise HTTPException(
status_code=503,
detail=f"Session konnte nicht erstellt werden: {exc}"
)
return session_token
async def get_session(self, session_token: str) -> Optional[dict]:
"""Gibt Session-Daten zurück oder None wenn abgelaufen/nicht vorhanden."""
redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token
try:
data = await redis.get(key)
except Exception as exc:
raise HTTPException(
status_code=503,
detail=f"Session-Lookup fehlgeschlagen: {exc}"
)
if data is None:
return None
return json.loads(data)
async def invalidate_session(self, session_token: str) -> None:
"""Session sofort ungültig machen (Logout)."""
redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token
try:
await redis.delete(key)
except Exception:
pass # Best-effort
async def refresh_session(self, session_token: str) -> bool:
"""TTL einer aktiven Session zurücksetzen. Returns False wenn Session nicht mehr existiert."""
redis = self._get_redis()
key = SESSION_KEY_PREFIX + session_token
try:
result = await redis.expire(key, SESSION_TTL_SECONDS)
return bool(result)
except Exception:
return False
async def require_session(self, session_token: str) -> dict:
"""Wie get_session, aber wirft 401 wenn Session ungültig/abgelaufen."""
session = await self.get_session(session_token)
if session is None:
raise HTTPException(
status_code=401,
detail="Kiosk-Session abgelaufen oder ungültig. Bitte neu anmelden."
)
return session
kiosk_session_service = KioskSessionService()