Files
timemaster/backend/app/schemas/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

105 lines
3.5 KiB
Python

import uuid
from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator
def _calc_heartbeat_status(last_heartbeat_at: datetime | None) -> str:
"""Berechnet den Liveness-Status aus dem letzten Heartbeat-Zeitstempel."""
if last_heartbeat_at is None:
return "offline"
now = datetime.now(timezone.utc)
# last_heartbeat_at könnte timezone-naive sein (ältere DB-Einträge)
if last_heartbeat_at.tzinfo is None:
last_heartbeat_at = last_heartbeat_at.replace(tzinfo=timezone.utc)
delta = (now - last_heartbeat_at).total_seconds()
if delta <= 90:
return "online"
if delta <= 300: # 5 Minuten
return "stale"
return "offline"
# ── Admin-Schemas ─────────────────────────────────────────────────────────────
class KioskDeviceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
public_key: str # Ed25519 PEM oder OpenSSH
ip_whitelist: str | None = None # CIDR-Liste kommasepariert, z.B. "10.0.0.0/24,192.168.1.0/24"
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
@field_validator("public_key")
@classmethod
def public_key_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("Public Key darf nicht leer sein.")
return v.strip()
class KioskDeviceUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
location: str | None = Field(None, max_length=255)
ip_whitelist: str | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str | None) -> str | None:
if v is not None:
if not v.strip():
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
return v.strip()
return v
class KioskDeviceOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
company_id: uuid.UUID
name: str
location: str | None
status: str # pending / approved / revoked
public_key: str | None
key_algorithm: str
last_heartbeat_at: datetime | None
client_version: str | None
offline_queue_size: int
ip_whitelist: str | None
created_at: datetime
heartbeat_status: str = "offline" # wird in model_validator gesetzt
@classmethod
def model_validate(cls, obj, *args, **kwargs): # type: ignore[override]
instance = super().model_validate(obj, *args, **kwargs)
instance.heartbeat_status = _calc_heartbeat_status(instance.last_heartbeat_at)
return instance
class KioskApproveResponse(BaseModel):
id: uuid.UUID
status: str
message: str
# ── Heartbeat-Schemas ─────────────────────────────────────────────────────────
class HeartbeatRequest(BaseModel):
uptime_seconds: int | None = None
current_user_id: uuid.UUID | None = None
browser_version: str | None = None
queued_offline_entries: int = 0
client_version: str | None = None
class HeartbeatResponse(BaseModel):
server_time: str # ISO-8601-Zeitstempel des Servers
heartbeat_interval_sec: int
device_status: str # pending / approved / revoked