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>
105 lines
3.5 KiB
Python
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
|