From 71db3bdd57af92c1383a87f25752de74b00c4630 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 10 Sep 2026 14:47:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(kiosk):=20QR-Login-Token-Endpunkt=20f?= =?UTF-8?q?=C3=BCr=20Employee-App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /kiosk/my-qr-token (normale JWT-Auth) – Mitarbeiter fordert einen 5min gültigen, einmalig einlösbaren QR-Token an, den die Employee-App als QR-Code zeigt. Kiosk scannt ihn (POST /kiosk/auth/qr, bereits vorhanden) und meldet den Mitarbeiter an. Backend-Funktion (generate_qr_token) existierte schon, war aber nie über einen Endpunkt erreichbar. Test für den kompletten Ablauf inkl. Einmaligkeit (zweiter Versuch mit selbem Token → 401). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1 --- backend/app/routers/kiosk.py | 16 ++++++++++++++-- backend/app/schemas/kiosk_auth.py | 7 +++++++ backend/tests/test_kiosk_security.py | 26 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/kiosk.py b/backend/app/routers/kiosk.py index e3fc6b5..8a8ef65 100644 --- a/backend/app/routers/kiosk.py +++ b/backend/app/routers/kiosk.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.core.dependencies import require_role +from app.core.dependencies import CurrentUser, require_role from app.core.kiosk_security import verify_kiosk_request from app.models.company import Company from app.models.kiosk_device import KioskDevice, KioskDeviceStatus @@ -25,6 +25,7 @@ from app.schemas.kiosk_auth import ( KioskNfcLoginRequest, KioskPinLoginRequest, KioskQrLoginRequest, + KioskQrTokenResponse, KioskSessionResponse, KioskStampRequest, KioskStampResponse, @@ -35,7 +36,7 @@ from app.schemas.kiosk_auth import ( ) from app.schemas.time_entry import StampInRequest from app.models.time_entry import EntrySource -from app.services.kiosk_auth_service import _display_name, kiosk_auth_service +from app.services.kiosk_auth_service import QR_TOKEN_TTL, _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 from app.services.time_service import time_service @@ -45,6 +46,17 @@ router = APIRouter(prefix="/kiosk", tags=["Kiosk"]) _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) +# ── Mitarbeiter-QR-Login (normale JWT-Auth, für die Employee-App) ──────────── + +@router.post("/my-qr-token", response_model=KioskQrTokenResponse) +async def get_my_qr_token(current_user: CurrentUser): + """Erzeugt einen 5 min gültigen, einmalig einlösbaren QR-Login-Token für + den eingeloggten Mitarbeiter – die Employee-App zeigt ihn als QR-Code, + der Kiosk scannt ihn (POST /kiosk/auth/qr) und meldet den Mitarbeiter an.""" + token = await kiosk_auth_service.generate_qr_token(current_user.id, current_user.company_id) + return KioskQrTokenResponse(qr_token=token, expires_in_seconds=QR_TOKEN_TTL) + + # ── Geräteverwaltung (COMPANY_ADMIN) ────────────────────────────────────────── @router.get("/devices", response_model=list[KioskDeviceOut]) diff --git a/backend/app/schemas/kiosk_auth.py b/backend/app/schemas/kiosk_auth.py index e92819f..371a710 100644 --- a/backend/app/schemas/kiosk_auth.py +++ b/backend/app/schemas/kiosk_auth.py @@ -61,3 +61,10 @@ class KioskStampStatusResponse(BaseModel): stamped_in: bool on_break: bool start_time: str | None = None + + +class KioskQrTokenResponse(BaseModel): + """Für die Mitarbeiter-App: eigenen QR-Login-Token anfordern, der am + Kiosk gescannt werden kann (5 min gültig, einmalig einlösbar).""" + qr_token: str + expires_in_seconds: int diff --git a/backend/tests/test_kiosk_security.py b/backend/tests/test_kiosk_security.py index 528070a..85b21fa 100644 --- a/backend/tests/test_kiosk_security.py +++ b/backend/tests/test_kiosk_security.py @@ -517,3 +517,29 @@ async def test_create_bearer_device_via_admin_api(client: AsyncClient, kiosk_adm headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) assert hb.status_code == 200, hb.text + + +async def test_my_qr_token_and_kiosk_login(client: AsyncClient, kiosk_admin_headers, approved_bearer_device): + """Mitarbeiter fordert eigenen QR-Token an, Kiosk scannt ihn → Session.""" + _, bearer_token = approved_bearer_device + + r = await client.post("/api/v1/kiosk/my-qr-token", headers=kiosk_admin_headers) + assert r.status_code == 200, r.text + qr_token = r.json()["qr_token"] + assert r.json()["expires_in_seconds"] > 0 + + login = await client.post( + "/api/v1/kiosk/auth/qr", + json={"qr_token": qr_token}, + headers={"Authorization": f"Bearer {bearer_token}", "Content-Type": "application/json"}, + ) + assert login.status_code == 200, login.text + assert login.json()["auth_method"] == "qr" + + # Einmalig: zweiter Versuch mit demselben Token scheitert. + login2 = await client.post( + "/api/v1/kiosk/auth/qr", + json={"qr_token": qr_token}, + headers={"Authorization": f"Bearer {bearer_token}", "Content-Type": "application/json"}, + ) + assert login2.status_code == 401, login2.text