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:
2026-05-24 12:45:47 +02:00
parent 1db7164837
commit 094863f94b
5 changed files with 232 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
"""Redis-Client für TimeMaster (sync, für Kiosk-Nonce-Cache und Sessions)."""
from __future__ import annotations
import logging
from typing import Optional
log = logging.getLogger(__name__)
_redis_client = None
def get_redis_client():
"""Gibt den Redis-Client zurück oder None wenn nicht konfiguriert/erreichbar."""
global _redis_client
if _redis_client is not None:
return _redis_client
try:
import redis as redis_lib
from app.core.config import settings
url = getattr(settings, "redis_url", "redis://localhost:6379/0")
_redis_client = redis_lib.from_url(url, decode_responses=True, socket_connect_timeout=2)
# Verbindung testen
_redis_client.ping()
log.info("Redis-Verbindung hergestellt: %s", url)
return _redis_client
except Exception as exc:
log.warning("Redis nicht verfügbar: %s", exc)
return None
+3
View File
@@ -59,6 +59,9 @@ class User(Base):
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
personnel_number: Mapped[str | None] = mapped_column(String(50))
# NFC-UID für Kiosk-Login (optional, eindeutig pro Firma)
kiosk_nfc_uid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# TOTP / 2FA
totp_secret: Mapped[str | None] = mapped_column(String(64))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@@ -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()
@@ -0,0 +1,36 @@
"""Add kiosk_nfc_uid to users table
Revision ID: 0025
Revises: 0024
Create Date: 2026-05-24
"""
from alembic import op
import sqlalchemy as sa
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
def upgrade() -> None:
# NFC-UID für Kiosk-Login (optional, eindeutig pro Firma über partial unique index)
op.add_column("users", sa.Column("kiosk_nfc_uid", sa.String(64), nullable=True))
op.create_index(
"ix_users_kiosk_nfc_uid",
"users",
["kiosk_nfc_uid"],
unique=False, # Uniqueness nur innerhalb company → partial index
)
# Partial unique index: NFC-UID eindeutig innerhalb einer Firma
op.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_nfc_uid_per_company
ON users (company_id, kiosk_nfc_uid)
WHERE kiosk_nfc_uid IS NOT NULL
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_users_nfc_uid_per_company")
op.drop_index("ix_users_kiosk_nfc_uid", table_name="users")
op.drop_column("users", "kiosk_nfc_uid")