Files
timemaster/backend/app/services/kiosk_service.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

140 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()