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,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
|
||||||
@@ -59,6 +59,9 @@ class User(Base):
|
|||||||
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
||||||
personnel_number: Mapped[str | None] = mapped_column(String(50))
|
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 / 2FA
|
||||||
totp_secret: Mapped[str | None] = mapped_column(String(64))
|
totp_secret: Mapped[str | None] = mapped_column(String(64))
|
||||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
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")
|
||||||
@@ -487,3 +487,54 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++--------
|
- 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 ++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user