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:
@@ -1,12 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
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
|
||||
@@ -15,10 +35,18 @@ class KioskDeviceCreate(BaseModel):
|
||||
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
|
||||
@@ -28,7 +56,6 @@ class KioskDeviceUpdate(BaseModel):
|
||||
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||
return v.strip()
|
||||
return v
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class KioskDeviceOut(BaseModel):
|
||||
@@ -38,11 +65,40 @@ class KioskDeviceOut(BaseModel):
|
||||
company_id: uuid.UUID
|
||||
name: str
|
||||
location: str | None
|
||||
is_active: bool
|
||||
last_seen_at: datetime | 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 KioskDeviceCreated(KioskDeviceOut):
|
||||
"""Wird nur einmalig bei Erstellung zurückgegeben – enthält den Klartext-Token."""
|
||||
token: str
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user