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
+96 -44
View File
@@ -1,4 +1,3 @@
import secrets
from datetime import datetime, timezone
from uuid import UUID
@@ -6,37 +5,34 @@ from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.kiosk_device import KioskDevice
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
from app.models.company import Company
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
class KioskService:
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
result = await db.scalars(
# ── Lesende Operationen ───────────────────────────────────────────────────
async def list_devices(
self,
company_id: UUID,
db: AsyncSession,
status_filter: KioskDeviceStatus | None = None,
) -> list[KioskDevice]:
query = (
select(KioskDevice)
.where(KioskDevice.company_id == company_id)
.order_by(KioskDevice.created_at.desc())
)
if status_filter is not None:
query = query.where(KioskDevice.status == status_filter)
result = await db.scalars(query)
return list(result.all())
async def create_device(
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Gerät anlegen. Gibt (device, raw_token) zurück raw_token nur einmalig."""
raw_token = secrets.token_urlsafe(48)
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
token_hash=hash_token(raw_token),
)
db.add(device)
await db.flush()
return device, raw_token
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
async def get_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> KioskDevice:
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.id == device_id,
@@ -47,8 +43,40 @@ class KioskService:
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
return device
# ── Schreibende Operationen ───────────────────────────────────────────────
async def create_device(
self,
company_id: UUID,
data: KioskDeviceCreate,
db: AsyncSession,
) -> KioskDevice:
"""
Gerät anlegen.
Status = pending wenn kiosk_require_approval, sonst approved.
"""
company = await db.get(Company, company_id)
require_approval = company.kiosk_require_approval if company else True
device = KioskDevice(
company_id=company_id,
name=data.name,
location=data.location,
public_key=data.public_key,
ip_whitelist=data.ip_whitelist,
key_algorithm="ed25519",
status=KioskDeviceStatus.PENDING if require_approval else KioskDeviceStatus.APPROVED,
)
db.add(device)
await db.flush()
return device
async def update_device(
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
self,
device_id: UUID,
company_id: UUID,
data: KioskDeviceUpdate,
db: AsyncSession,
) -> KioskDevice:
device = await self.get_device(device_id, company_id, db)
changes = data.model_dump(exclude_none=True)
@@ -56,32 +84,56 @@ class KioskService:
setattr(device, field, value)
return device
async def rotate_token(
async def delete_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> tuple[KioskDevice, str]:
"""Token rotieren altes Token wird sofort ungültig."""
device = await self.get_device(device_id, company_id, db)
raw_token = secrets.token_urlsafe(48)
device.token_hash = hash_token(raw_token)
return device, raw_token
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
) -> None:
device = await self.get_device(device_id, company_id, db)
await db.delete(device)
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
token_hash = hash_token(raw_token)
device = await db.scalar(
select(KioskDevice).where(
KioskDevice.token_hash == token_hash,
KioskDevice.is_active.is_(True),
)
)
if device is None:
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
device.last_seen_at = datetime.now(timezone.utc)
# ── Status-Verwaltung ─────────────────────────────────────────────────────
async def approve_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> KioskDevice:
"""Gerät freigeben: Status → approved."""
device = await self.get_device(device_id, company_id, db)
device.status = KioskDeviceStatus.APPROVED
return device
async def revoke_device(
self, device_id: UUID, company_id: UUID, db: AsyncSession
) -> KioskDevice:
"""Gerät sperren: Status → revoked."""
device = await self.get_device(device_id, company_id, db)
device.status = KioskDeviceStatus.REVOKED
return device
# ── Heartbeat ─────────────────────────────────────────────────────────────
async def process_heartbeat(
self,
device: KioskDevice,
data: HeartbeatRequest,
company: Company,
db: AsyncSession,
) -> None:
"""
Heartbeat-Daten vom Kiosk-Gerät verarbeiten.
last_heartbeat_at wird bereits in verify_kiosk_request gesetzt
hier werden nur die zusätzlichen Felder aktualisiert.
"""
if data.client_version is not None:
device.client_version = data.client_version
device.offline_queue_size = data.queued_offline_entries
if data.current_user_id is not None and company.kiosk_track_current_user:
device.current_user_id = data.current_user_id
elif not company.kiosk_track_current_user:
# DSGVO-Opt-Out: aktuellen User nicht speichern
device.current_user_id = None
await db.flush()
kiosk_service = KioskService()