0f83d13c0c
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>
140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
from datetime import datetime, timezone
|
||
from uuid import UUID
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.company import Company
|
||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||
|
||
|
||
class KioskService:
|
||
|
||
# ── 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 get_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> KioskDevice:
|
||
device = await db.scalar(
|
||
select(KioskDevice).where(
|
||
KioskDevice.id == device_id,
|
||
KioskDevice.company_id == company_id,
|
||
)
|
||
)
|
||
if device is None:
|
||
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,
|
||
) -> KioskDevice:
|
||
device = await self.get_device(device_id, company_id, db)
|
||
changes = data.model_dump(exclude_none=True)
|
||
for field, value in changes.items():
|
||
setattr(device, field, value)
|
||
return device
|
||
|
||
async def delete_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> None:
|
||
device = await self.get_device(device_id, company_id, db)
|
||
await db.delete(device)
|
||
|
||
# ── 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()
|