Files
timemaster/backend/app/routers/kiosk.py
T
patrick 0f83d13c0c feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage
2A – Backend Ed25519-Verifizierung:
- app/core/kiosk_security.py (NEU): verify_kiosk_request() Dependency
  - Timestamp-Check (30s Drift), Nonce-Cache (Redis/In-Memory), IP-Whitelist
  - Ed25519-Signatur über METHOD+PATH+TIMESTAMP+NONCE+sha256(BODY)
  - PEM + OpenSSH Key-Format unterstützt
- app/routers/kiosk.py: approve/revoke Endpunkte, POST /heartbeat (Ed25519-signiert)
- app/services/kiosk_service.py: token-basierte Methoden entfernt, approve/revoke/heartbeat
- app/schemas/kiosk.py: KioskDeviceOut mit heartbeat_status, HeartbeatRequest/Response

2B – CLI-Tool:
- cli.py (NEU, 529 Zeilen): Typer-CLI mit kiosk add/list/approve/revoke/info
  - Public-Key-Fingerprint (SHA256), Rich-Tabellen, CIDR-Validierung
  - Direkter DB-Zugriff mit RLS-Bypass

2C – Frontend:
- KioskDevicesPage.tsx: Zwei-Tab-Layout (Wartet/Aktiv), Status-Ampel,
  Auto-Refresh 30s, Ed25519-Workflow (kein Token mehr)
- Layout.tsx: KioskHealthBadge (online/total, 30s Refresh, nur COMPANY_ADMIN)

requirements.txt: typer>=0.12.0, rich>=13.7.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:13:46 +02:00

157 lines
5.6 KiB
Python

from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, 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.services.kiosk_service import kiosk_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-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,
)