diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..06515ca --- /dev/null +++ b/backend/app/core/redis.py @@ -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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 04985cf..c619568 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/services/kiosk_session_service.py b/backend/app/services/kiosk_session_service.py new file mode 100644 index 0000000..7609581 --- /dev/null +++ b/backend/app/services/kiosk_session_service.py @@ -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() diff --git a/backend/migrations/versions/0025_kiosk_nfc_uid.py b/backend/migrations/versions/0025_kiosk_nfc_uid.py new file mode 100644 index 0000000..17977ed --- /dev/null +++ b/backend/migrations/versions/0025_kiosk_nfc_uid.py @@ -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") diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index 396f427..e7d85f8 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -487,3 +487,54 @@ Keine Commits in dieser Session. - frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++-------- --- +## 2026-05-24 12:22 – 12:24 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 35fcea9 feat(kiosk): Stufe 3 – ServiceWorker, WebCrypto Setup-Flow, Kiosk-UI, 15 Security-Tests + +### Geänderte Dateien +- DEVLOG.md | 20 ++ +- backend/tests/test_kiosk_security.py | 387 ++++++++++++++++++++++++++++++++++ +- frontend/DEVLOG.md | 20 ++ +- frontend/public/kiosk-sw.js | 187 ++++++++++++++++ +- frontend/src/App.tsx | 4 + +- frontend/src/pages/KioskSetupPage.tsx | 307 +++++++++++++++++++++++++++ +- frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++ + +--- +## 2026-05-24 12:26 – 12:28 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 20 ++ +- backend/tests/test_kiosk_security.py | 387 ++++++++++++++++++++++++++++++++++ +- frontend/DEVLOG.md | 20 ++ +- frontend/public/kiosk-sw.js | 187 ++++++++++++++++ +- frontend/src/App.tsx | 4 + +- frontend/src/pages/KioskSetupPage.tsx | 307 +++++++++++++++++++++++++++ +- frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++ + +--- +## 2026-05-24 12:31 – 12:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 20 ++ +- backend/tests/test_kiosk_security.py | 387 ++++++++++++++++++++++++++++++++++ +- frontend/DEVLOG.md | 20 ++ +- frontend/public/kiosk-sw.js | 187 ++++++++++++++++ +- frontend/src/App.tsx | 4 + +- frontend/src/pages/KioskSetupPage.tsx | 307 +++++++++++++++++++++++++++ +- frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++ + +---