Files
timemaster/backend/app/routers/kiosk.py
T
patrickandClaude Sonnet 5 2464c74215
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat(kiosk): Stempel-Endpunkte für Kiosk-Sessions (fehlten bisher komplett)
Login (PIN/NFC/QR/Liste) lieferte einen session_token, aber es gab keinen
Endpunkt der ihn tatsächlich zum Stempeln nutzt (X-Kiosk-Session-Token war
nur als Konzept in kiosk_session_service dokumentiert, nirgends verdrahtet).

Neu: POST /kiosk/stamp/{in,out,break-start,break-end,status}. Gerät wird
weiterhin per Ed25519 verifiziert (verify_kiosk_request), zusätzlich validiert
_user_from_session() dass der session_token zu genau diesem Gerät gehört und
der User noch aktiv/in der richtigen Firma ist. Reuse von time_service
(stamp_in/out/break_start/break_end/get_today), source=KIOSK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
2026-08-27 13:32:46 +02:00

377 lines
13 KiB
Python

import uuid as _uuid
from datetime import datetime, timezone
from uuid import UUID
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.kiosk_security import verify_kiosk_request
from app.models.company import Company
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
from app.models.user import User, UserRole
from app.schemas.kiosk import (
HeartbeatRequest,
HeartbeatResponse,
KioskApproveResponse,
KioskDeviceCreate,
KioskDeviceOut,
KioskDeviceUpdate,
)
from app.schemas.kiosk_auth import (
KioskNfcLoginRequest,
KioskPinLoginRequest,
KioskQrLoginRequest,
KioskSessionResponse,
KioskStampRequest,
KioskStampResponse,
KioskStampStatusRequest,
KioskStampStatusResponse,
KioskUserListEntry,
KioskUserListResponse,
)
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_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
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
# ── Geräteverwaltung (COMPANY_ADMIN) ──────────────────────────────────────────
@router.get("/devices", response_model=list[KioskDeviceOut])
async def list_devices(
status: str | None = Query(None, description="Filter: pending, approved oder revoked"),
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Alle registrierten Kiosk-Geräte der Firma auflisten (optional nach Status filtern)."""
status_filter: KioskDeviceStatus | None = None
if status is not None:
try:
status_filter = KioskDeviceStatus(status)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Ungültiger Status-Filter '{status}'. Erlaubt: pending, approved, revoked.",
)
devices = await kiosk_service.list_devices(current_user.company_id, db, status_filter)
return [KioskDeviceOut.model_validate(d) for d in devices]
@router.post("/devices", response_model=KioskDeviceOut, status_code=201)
async def create_device(
data: KioskDeviceCreate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Neues Kiosk-Gerät mit Ed25519-Public-Key registrieren."""
device = await kiosk_service.create_device(current_user.company_id, data, db)
await db.commit()
return KioskDeviceOut.model_validate(device)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
async def get_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Einzelnes Kiosk-Gerät abrufen."""
device = await kiosk_service.get_device(device_id, current_user.company_id, db)
return KioskDeviceOut.model_validate(device)
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
async def update_device(
device_id: UUID,
data: KioskDeviceUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Name, Standort oder IP-Whitelist eines Kiosk-Geräts aktualisieren."""
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
await db.commit()
return KioskDeviceOut.model_validate(device)
@router.delete("/devices/{device_id}", status_code=204)
async def delete_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät dauerhaft löschen."""
await kiosk_service.delete_device(device_id, current_user.company_id, db)
await db.commit()
@router.post("/devices/{device_id}/approve", response_model=KioskApproveResponse)
async def approve_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
device = await kiosk_service.approve_device(device_id, current_user.company_id, db)
await db.commit()
return KioskApproveResponse(
id=device.id,
status=device.status.value,
message=f"Gerät '{device.name}' wurde freigegeben.",
)
@router.post("/devices/{device_id}/revoke", response_model=KioskApproveResponse)
async def revoke_device(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Kiosk-Gerät sperren (Status → revoked). Erfordert Re-Enrollment."""
device = await kiosk_service.revoke_device(device_id, current_user.company_id, db)
await db.commit()
return KioskApproveResponse(
id=device.id,
status=device.status.value,
message=f"Gerät '{device.name}' wurde gesperrt. Re-Enrollment erforderlich.",
)
# ── 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,
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="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."}
# ── Stempel-Aktionen (signiertes Gerät + eingeloggte Kiosk-Session) ──────────
async def _user_from_session(
session_token: str, device: KioskDevice, db: AsyncSession
) -> User:
"""Lädt den User zur Kiosk-Session, prüft dass Session zu diesem Gerät gehört."""
session = await kiosk_session_service.require_session(session_token)
if session["device_id"] != str(device.id):
raise HTTPException(status_code=401, detail="Session gehört zu einem anderen Gerät.")
user = await db.get(User, _uuid.UUID(session["user_id"]))
if user is None or not user.is_active or user.company_id != device.company_id:
raise HTTPException(status_code=401, detail="Mitarbeiter nicht mehr aktiv.")
return user
@router.post("/stamp/in", response_model=KioskStampResponse)
async def kiosk_stamp_in(
data: KioskStampRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
user = await _user_from_session(data.session_token, device, db)
entry, warnings = await time_service.stamp_in(
user, StampInRequest(source=EntrySource.KIOSK, note=data.note), db
)
await db.commit()
return KioskStampResponse(entry_id=entry.id, status="stamped_in", warnings=warnings)
@router.post("/stamp/out", response_model=KioskStampResponse)
async def kiosk_stamp_out(
data: KioskStampRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
user = await _user_from_session(data.session_token, device, db)
entry, warnings = await time_service.stamp_out(user, data.note, db)
await db.commit()
return KioskStampResponse(entry_id=entry.id, status="stamped_out", warnings=warnings)
@router.post("/stamp/break-start", response_model=KioskStampResponse)
async def kiosk_break_start(
data: KioskStampRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
user = await _user_from_session(data.session_token, device, db)
entry = await time_service.break_start(user, db)
await db.commit()
return KioskStampResponse(entry_id=entry.id, status="break_started")
@router.post("/stamp/break-end", response_model=KioskStampResponse)
async def kiosk_break_end(
data: KioskStampRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
user = await _user_from_session(data.session_token, device, db)
entry = await time_service.break_end(user, db)
await db.commit()
return KioskStampResponse(entry_id=entry.id, status="break_ended")
@router.post("/stamp/status", response_model=KioskStampStatusResponse)
async def kiosk_stamp_status(
data: KioskStampStatusRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
"""Aktueller Stempel-Status des eingeloggten Mitarbeiters (für Button-Zustand)."""
user = await _user_from_session(data.session_token, device, db)
entries = await time_service.get_today(user, db)
open_entry = next((e for e in entries if e.end_time is None), None)
if open_entry is None:
return KioskStampStatusResponse(stamped_in=False, on_break=False)
return KioskStampStatusResponse(
stamped_in=True,
on_break=open_entry.break_start is not None,
start_time=open_entry.start_time.isoformat(),
)
# ── Kiosk-Endpunkte (signierte Requests via Ed25519) ──────────────────────────
@router.post("/heartbeat", response_model=HeartbeatResponse)
async def heartbeat(
data: HeartbeatRequest,
device: KioskDevice = Depends(verify_kiosk_request),
db: AsyncSession = Depends(get_db),
):
"""
Heartbeat-Endpunkt für Kiosk-Geräte.
Muss mit Ed25519-Signatur-Headern aufgerufen werden:
X-Kiosk-Key-Id, X-Kiosk-Timestamp, X-Kiosk-Nonce, X-Kiosk-Signature
Gibt Server-Zeit und Konfiguration zurück.
"""
company = await db.get(Company, device.company_id)
if company is None:
raise HTTPException(status_code=500, detail="Firma nicht gefunden.")
await kiosk_service.process_heartbeat(device, data, company, db)
await db.commit()
return HeartbeatResponse(
server_time=datetime.now(timezone.utc).isoformat(),
heartbeat_interval_sec=company.kiosk_heartbeat_interval_sec,
device_status=device.status.value,
)