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>
This commit is contained in:
2026-05-24 12:13:46 +02:00
parent 981bde3dc1
commit 0f83d13c0c
10 changed files with 1438 additions and 226 deletions
+92 -35
View File
@@ -1,12 +1,23 @@
from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException
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 KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
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"])
@@ -18,26 +29,34 @@ _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_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."""
return await kiosk_service.list_devices(current_user.company_id, 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=KioskDeviceCreated, status_code=201)
@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 registrieren. Token wird nur einmalig zurückgegeben."""
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, 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 KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
return KioskDeviceOut.model_validate(device)
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
@@ -46,7 +65,9 @@ async def get_device(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
return await kiosk_service.get_device(device_id, current_user.company_id, 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)
@@ -56,44 +77,80 @@ async def update_device(
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.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
async def rotate_token(
device_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Token rotieren das alte Token wird sofort ungültig."""
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
await db.commit()
return KioskDeviceCreated(
**KioskDeviceOut.model_validate(device).model_dump(),
token=raw_token,
)
@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()
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
@router.get("/me", response_model=KioskDeviceOut)
async def kiosk_me(
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
@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 prüft seine eigene Identität / aktualisiert last_seen_at."""
device = await kiosk_service.authenticate_device(x_kiosk_token, 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 KioskDeviceOut.model_validate(device)
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,
)