feat: agent-02-kiosk Phase 1 - NFC UID migration + session service
- Migration 0025: kiosk_nfc_uid column on users table with partial unique index per company - User model: kiosk_nfc_uid field after personnel_number - New service: kiosk_session_service.py (Redis-based 15min sessions) - New core module: app/core/redis.py (sync Redis client with ping-test) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
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):
|
||||
"""Redis-Client aus app.core.redis holen. Raises 503 wenn nicht verfügbar."""
|
||||
try:
|
||||
from app.core.redis import get_redis_client
|
||||
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(
|
||||
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:
|
||||
redis.setex(key, SESSION_TTL_SECONDS, json.dumps(payload))
|
||||
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 = 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:
|
||||
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 = 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()
|
||||
Reference in New Issue
Block a user