diff --git a/DEVLOG.md b/DEVLOG.md index 5eeb872..2f747a7 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -924,3 +924,19 @@ Keine Commits in dieser Session. - frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++-------- --- +## 2026-05-24 12:37 – 12:47 (10m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +- 094863f feat: agent-02-kiosk Phase 1 - NFC UID migration + session service +- 1db7164 fix(security): SSRF-Schutz für CalDAV-URLs + +### Geänderte Dateien +- backend/app/core/redis.py | 28 ++++++ +- backend/app/models/user.py | 3 + +- backend/app/services/kiosk_session_service.py | 114 ++++++++++++++++++++++ +- backend/migrations/versions/0025_kiosk_nfc_uid.py | 36 +++++++ +- frontend/DEVLOG.md | 51 ++++++++++ + +--- diff --git a/backend/app/routers/kiosk.py b/backend/app/routers/kiosk.py index f4bb87d..3f5504c 100644 --- a/backend/app/routers/kiosk.py +++ b/backend/app/routers/kiosk.py @@ -1,7 +1,8 @@ +import uuid as _uuid from datetime import datetime, timezone from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -18,7 +19,17 @@ from app.schemas.kiosk import ( KioskDeviceOut, KioskDeviceUpdate, ) +from app.schemas.kiosk_auth import ( + KioskNfcLoginRequest, + KioskPinLoginRequest, + KioskQrLoginRequest, + KioskSessionResponse, + KioskUserListEntry, + KioskUserListResponse, +) +from app.services.kiosk_auth_service import _display_name, kiosk_auth_service from app.services.kiosk_service import kiosk_service +from app.services.kiosk_session_service import SESSION_TTL_SECONDS, kiosk_session_service router = APIRouter(prefix="/kiosk", tags=["Kiosk"]) @@ -126,6 +137,123 @@ async def revoke_device( ) +# ── Kiosk-User-Auth (signierte Requests via Ed25519) ───────────────────────── + + +@router.post("/auth/pin", response_model=KioskSessionResponse) +async def kiosk_login_pin( + data: KioskPinLoginRequest, + device: KioskDevice = Depends(verify_kiosk_request), + db: AsyncSession = Depends(get_db), +): + """PIN-Login: Personalnummer + 4-16-stellige PIN.""" + user, session_token = await kiosk_auth_service.login_pin( + personnel_number=data.personnel_number, + pin=data.pin, + company_id=device.company_id, + device_id=device.id, + db=db, + ) + return KioskSessionResponse( + session_token=session_token, + user_id=user.id, + user_name=_display_name(user), + expires_in_seconds=SESSION_TTL_SECONDS, + auth_method="pin", + ) + + +@router.post("/auth/nfc", response_model=KioskSessionResponse) +async def kiosk_login_nfc( + data: KioskNfcLoginRequest, + device: KioskDevice = Depends(verify_kiosk_request), + db: AsyncSession = Depends(get_db), +): + """NFC-Login: NFC-UID der Karte.""" + user, session_token = await kiosk_auth_service.login_nfc( + nfc_uid=data.nfc_uid, + company_id=device.company_id, + device_id=device.id, + db=db, + ) + return KioskSessionResponse( + session_token=session_token, + user_id=user.id, + user_name=_display_name(user), + expires_in_seconds=SESSION_TTL_SECONDS, + auth_method="nfc", + ) + + +@router.post("/auth/qr", response_model=KioskSessionResponse) +async def kiosk_login_qr( + data: KioskQrLoginRequest, + device: KioskDevice = Depends(verify_kiosk_request), + db: AsyncSession = Depends(get_db), +): + """QR-Login: QR-Token (aus Web-App gescannt).""" + user, session_token = await kiosk_auth_service.login_qr( + qr_token=data.qr_token, + company_id=device.company_id, + device_id=device.id, + db=db, + ) + return KioskSessionResponse( + session_token=session_token, + user_id=user.id, + user_name=_display_name(user), + expires_in_seconds=SESSION_TTL_SECONDS, + auth_method="qr", + ) + + +@router.post("/auth/list", response_model=KioskSessionResponse) +async def kiosk_login_list( + user_id: _uuid.UUID = Body(..., embed=True), + device: KioskDevice = Depends(verify_kiosk_request), + db: AsyncSession = Depends(get_db), +): + """List-Login: User wählt sich aus Mitarbeiterliste aus (kein Passwort).""" + user, session_token = await kiosk_auth_service.login_list( + user_id=user_id, + company_id=device.company_id, + device_id=device.id, + db=db, + ) + return KioskSessionResponse( + session_token=session_token, + user_id=user.id, + user_name=_display_name(user), + expires_in_seconds=SESSION_TTL_SECONDS, + auth_method="list", + ) + + +@router.get("/auth/users", response_model=KioskUserListResponse) +async def kiosk_user_list( + device: KioskDevice = Depends(verify_kiosk_request), + db: AsyncSession = Depends(get_db), +): + """Mitarbeiterliste für Kiosk-Auswahl.""" + users = await kiosk_auth_service.get_user_list(device.company_id, db) + return KioskUserListResponse( + users=[ + KioskUserListEntry(id=u.id, display_name=_display_name(u)) + for u in users + ] + ) + + +@router.post("/auth/logout") +async def kiosk_logout( + session_token: str = Body(..., embed=True), + device: KioskDevice = Depends(verify_kiosk_request), +): + """Session invalidieren.""" + await kiosk_session_service.invalidate_session(session_token) + return {"message": "Abgemeldet."} + + # ── Kiosk-Endpunkte (signierte Requests via Ed25519) ────────────────────────── @router.post("/heartbeat", response_model=HeartbeatResponse) diff --git a/backend/app/schemas/kiosk_auth.py b/backend/app/schemas/kiosk_auth.py new file mode 100644 index 0000000..0d37ab4 --- /dev/null +++ b/backend/app/schemas/kiosk_auth.py @@ -0,0 +1,38 @@ +"""Schemas für Kiosk-User-Authentifizierung.""" +import uuid +from pydantic import BaseModel, Field + + +class KioskPinLoginRequest(BaseModel): + """Login via Personalnummer + PIN.""" + personnel_number: str = Field(..., min_length=1) + pin: str = Field(..., min_length=4, max_length=16) + + +class KioskNfcLoginRequest(BaseModel): + """Login via NFC-UID.""" + nfc_uid: str = Field(..., min_length=1, max_length=64) + + +class KioskQrLoginRequest(BaseModel): + """Login via QR-Code (enthält verschlüsselten Token).""" + qr_token: str = Field(..., min_length=1) + + +class KioskSessionResponse(BaseModel): + """Antwort nach erfolgreichem Kiosk-Login.""" + session_token: str + user_id: uuid.UUID + user_name: str # "Max M." (Vorname + Nachname abgekürzt für Datenschutz) + expires_in_seconds: int # 900 (15 min) + auth_method: str # "pin" | "nfc" | "qr" | "list" + + +class KioskUserListEntry(BaseModel): + """Minimale User-Info für Auswahl in der Mitarbeiterliste.""" + id: uuid.UUID + display_name: str # "Max M." oder Kürzel + + +class KioskUserListResponse(BaseModel): + users: list[KioskUserListEntry] diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py new file mode 100644 index 0000000..453c053 --- /dev/null +++ b/backend/app/services/kiosk_auth_service.py @@ -0,0 +1,195 @@ +""" +Kiosk-User-Auth Service. + +Unterstützte Methoden: + PIN → User über Personalnummer suchen, bcrypt-PIN prüfen + NFC → User über kiosk_nfc_uid suchen + QR → QR-Token ist ein kurzlebiger Redis-Key (5 min, einmalig) + List → Kein Passwort, User wählt sich aus Liste (für vertrauenswürdige Umgebungen) +""" +from __future__ import annotations + +import json +import logging +import secrets +import uuid + +import bcrypt +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.services.kiosk_session_service import SESSION_TTL_SECONDS, kiosk_session_service + +log = logging.getLogger(__name__) + +QR_TOKEN_PREFIX = "kiosk_qr:" +QR_TOKEN_TTL = 5 * 60 # 5 Minuten + + +class KioskAuthService: + + async def login_pin( + self, + personnel_number: str, + pin: str, + company_id: uuid.UUID, + device_id: uuid.UUID, + db: AsyncSession, + ) -> tuple[User, str]: + """Authentifizierung per Personalnummer + PIN. Returns (user, session_token).""" + user = await db.scalar( + select(User).where( + User.company_id == company_id, + User.personnel_number == personnel_number, + User.is_active == True, + ) + ) + if user is None: + raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.") + + if not user.kiosk_pin_hash: + raise HTTPException( + status_code=401, + detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.", + ) + + if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): + raise HTTPException(status_code=401, detail="Falscher PIN.") + + session_token = await kiosk_session_service.create_session( + user_id=user.id, + company_id=company_id, + device_id=device_id, + auth_method="pin", + ) + return user, session_token + + async def login_nfc( + self, + nfc_uid: str, + company_id: uuid.UUID, + device_id: uuid.UUID, + db: AsyncSession, + ) -> tuple[User, str]: + """Authentifizierung per NFC-UID.""" + user = await db.scalar( + select(User).where( + User.company_id == company_id, + User.kiosk_nfc_uid == nfc_uid, + User.is_active == True, + ) + ) + if user is None: + raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.") + + session_token = await kiosk_session_service.create_session( + user_id=user.id, + company_id=company_id, + device_id=device_id, + auth_method="nfc", + ) + return user, session_token + + async def generate_qr_token(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str: + """ + Erzeugt einen einmaligen QR-Token (für die Web-App: User scannt QR am Kiosk). + Token ist 5 min gültig und wird in Redis gespeichert. + """ + from app.core.redis import get_redis_client + redis = get_redis_client() + if redis is None: + raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") + + token = secrets.token_urlsafe(32) + key = QR_TOKEN_PREFIX + token + redis.setex(key, QR_TOKEN_TTL, json.dumps({ + "user_id": str(user_id), + "company_id": str(company_id), + })) + return token + + async def login_qr( + self, + qr_token: str, + company_id: uuid.UUID, + device_id: uuid.UUID, + db: AsyncSession, + ) -> tuple[User, str]: + """Validiert QR-Token (einmalig) und erstellt Session.""" + from app.core.redis import get_redis_client + redis = get_redis_client() + if redis is None: + raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") + + key = QR_TOKEN_PREFIX + qr_token + data = redis.get(key) + if data is None: + raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.") + + # Einmalig: Token sofort löschen + redis.delete(key) + + payload = json.loads(data) + if str(payload.get("company_id")) != str(company_id): + raise HTTPException(status_code=401, detail="QR-Code für falsches Unternehmen.") + + user_id = uuid.UUID(payload["user_id"]) + user = await db.get(User, user_id) + if user is None or not user.is_active: + raise HTTPException(status_code=401, detail="User nicht gefunden oder inaktiv.") + + session_token = await kiosk_session_service.create_session( + user_id=user.id, + company_id=company_id, + device_id=device_id, + auth_method="qr", + ) + return user, session_token + + async def login_list( + self, + user_id: uuid.UUID, + company_id: uuid.UUID, + device_id: uuid.UUID, + db: AsyncSession, + ) -> tuple[User, str]: + """Login per Auswahl aus der Mitarbeiterliste (nur für vertrauenswürdige Umgebungen).""" + user = await db.scalar( + select(User).where( + User.id == user_id, + User.company_id == company_id, + User.is_active == True, + ) + ) + if user is None: + raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.") + + session_token = await kiosk_session_service.create_session( + user_id=user.id, + company_id=company_id, + device_id=device_id, + auth_method="list", + ) + return user, session_token + + async def get_user_list( + self, company_id: uuid.UUID, db: AsyncSession + ) -> list[User]: + """Alle aktiven Mitarbeiter der Firma für Kiosk-Auswahlliste.""" + result = await db.scalars( + select(User).where( + User.company_id == company_id, + User.is_active == True, + ).order_by(User.last_name, User.first_name) + ) + return list(result.all()) + + +def _display_name(user: User) -> str: + """Kurzname für Kiosk-Anzeige: 'Max M.'""" + return f"{user.first_name} {user.last_name[:1]}." + + +kiosk_auth_service = KioskAuthService()