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:
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Ed25519-basierte Request-Verifizierung für Kiosk-Geräte.
|
||||
|
||||
Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
|
||||
X-Kiosk-Key-Id : UUID des Geräts (entspricht KioskDevice.id)
|
||||
X-Kiosk-Timestamp : Unix-Zeit in Sekunden (max 30s Drift zum Server)
|
||||
X-Kiosk-Nonce : Einmalige UUID (Replay-Schutz, 60s-Fenster via Redis)
|
||||
X-Kiosk-Signature : Base64(Ed25519-Sig über "METHOD PATH TIMESTAMP NONCE sha256(BODY)")
|
||||
|
||||
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_ssh_public_key
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Nonce-Cache (Redis wenn verfügbar, sonst In-Memory-Fallback) ─────────────
|
||||
|
||||
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
|
||||
_NONCE_TTL = 60 # Sekunden
|
||||
|
||||
|
||||
def _cleanup_nonce_cache() -> None:
|
||||
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen."""
|
||||
now = time.time()
|
||||
expired = [k for k, v in _nonce_cache.items() if v <= now]
|
||||
for k in expired:
|
||||
del _nonce_cache[k]
|
||||
|
||||
|
||||
async def _check_and_set_nonce(nonce: str) -> bool:
|
||||
"""
|
||||
Prüft ob die Nonce schon gesehen wurde und speichert sie.
|
||||
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
|
||||
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
|
||||
"""
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
key = f"kiosk:nonce:{nonce}"
|
||||
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
|
||||
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
|
||||
await r.aclose()
|
||||
return result is not None # None = bereits vorhanden
|
||||
except Exception as e:
|
||||
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache: %s", e)
|
||||
# Fallback: In-Memory
|
||||
_cleanup_nonce_cache()
|
||||
now = time.time()
|
||||
if nonce in _nonce_cache:
|
||||
return False
|
||||
_nonce_cache[nonce] = now + _NONCE_TTL
|
||||
return True
|
||||
|
||||
|
||||
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────
|
||||
|
||||
def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
|
||||
"""
|
||||
Lädt einen Ed25519-Public-Key aus PEM- oder OpenSSH-Format.
|
||||
Wirft ValueError bei ungültigem Format.
|
||||
"""
|
||||
key_bytes = public_key_str.strip().encode()
|
||||
|
||||
# OpenSSH-Format (beginnt mit "ssh-ed25519 ...")
|
||||
if key_bytes.startswith(b"ssh-ed25519"):
|
||||
key = load_ssh_public_key(key_bytes)
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
||||
return key
|
||||
|
||||
# PEM-Format (beginnt mit "-----BEGIN ...")
|
||||
if key_bytes.startswith(b"-----"):
|
||||
key = load_pem_public_key(key_bytes)
|
||||
if not isinstance(key, Ed25519PublicKey):
|
||||
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
||||
return key
|
||||
|
||||
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
|
||||
|
||||
|
||||
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
|
||||
|
||||
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
|
||||
"""
|
||||
Prüft ob client_ip in einer kommaseparierten CIDR-Liste enthalten ist.
|
||||
Gibt True zurück wenn erlaubt, False wenn nicht.
|
||||
"""
|
||||
try:
|
||||
client_addr = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
logger.warning("Ungültige Client-IP: %s", client_ip)
|
||||
return False
|
||||
|
||||
for cidr in ip_whitelist.split(","):
|
||||
cidr = cidr.strip()
|
||||
if not cidr:
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
if client_addr in network:
|
||||
return True
|
||||
except ValueError:
|
||||
logger.warning("Ungültiger CIDR-Eintrag in ip_whitelist: %s", cidr)
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# ── FastAPI Dependency ────────────────────────────────────────────────────────
|
||||
|
||||
async def verify_kiosk_request(
|
||||
request: Request,
|
||||
x_kiosk_key_id: str = Header(..., alias="X-Kiosk-Key-Id"),
|
||||
x_kiosk_timestamp: str = Header(..., alias="X-Kiosk-Timestamp"),
|
||||
x_kiosk_nonce: str = Header(..., alias="X-Kiosk-Nonce"),
|
||||
x_kiosk_signature: str = Header(..., alias="X-Kiosk-Signature"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KioskDevice:
|
||||
"""
|
||||
FastAPI-Dependency: Verifiziert einen signierten Kiosk-Request.
|
||||
|
||||
Schritte:
|
||||
1. Timestamp-Check (max 30s Drift)
|
||||
2. Nonce-Check (Replay-Schutz)
|
||||
3. Gerät laden + Status prüfen
|
||||
4. IP-Whitelist (falls konfiguriert)
|
||||
5. Ed25519-Signatur verifizieren
|
||||
6. last_heartbeat_at aktualisieren
|
||||
|
||||
Gibt das verifizierte KioskDevice zurück.
|
||||
"""
|
||||
# 1. Timestamp-Check
|
||||
try:
|
||||
request_time = float(x_kiosk_timestamp)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Ungültiger Timestamp.")
|
||||
|
||||
drift = abs(time.time() - request_time)
|
||||
if drift > 30:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Timestamp liegt zu weit in der Vergangenheit oder Zukunft ({drift:.1f}s Abweichung, max 30s).",
|
||||
)
|
||||
|
||||
# 2. Nonce-Check
|
||||
# UUID-Format validieren (grobe Prüfung)
|
||||
try:
|
||||
UUID(x_kiosk_nonce)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Nonce muss eine gültige UUID sein.")
|
||||
|
||||
nonce_ok = await _check_and_set_nonce(x_kiosk_nonce)
|
||||
if not nonce_ok:
|
||||
raise HTTPException(status_code=401, detail="Replay-Angriff erkannt: Nonce bereits verwendet.")
|
||||
|
||||
# 3. Gerät laden
|
||||
try:
|
||||
device_uuid = UUID(x_kiosk_key_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="X-Kiosk-Key-Id muss eine gültige UUID sein.")
|
||||
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(KioskDevice.id == device_uuid)
|
||||
)
|
||||
|
||||
if device is None:
|
||||
raise HTTPException(status_code=401, detail="Kiosk-Gerät nicht gefunden.")
|
||||
|
||||
if device.status != KioskDeviceStatus.APPROVED:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Kiosk-Gerät ist nicht freigegeben (Status: {device.status.value}).",
|
||||
)
|
||||
|
||||
if not device.public_key:
|
||||
raise HTTPException(status_code=401, detail="Kein Public Key für dieses Gerät registriert.")
|
||||
|
||||
# 4. IP-Whitelist prüfen (optional)
|
||||
if device.ip_whitelist:
|
||||
client_ip = request.client.host if request.client else ""
|
||||
if not client_ip:
|
||||
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
|
||||
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Client-IP {client_ip} ist nicht in der erlaubten IP-Whitelist.",
|
||||
)
|
||||
|
||||
# 5. Signatur verifizieren
|
||||
body = await request.body()
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
message = f"{request.method} {request.url.path} {x_kiosk_timestamp} {x_kiosk_nonce} {body_hash}"
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(x_kiosk_signature)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Signatur ist kein gültiges Base64.")
|
||||
|
||||
try:
|
||||
pub_key = _load_ed25519_public_key(device.public_key)
|
||||
except ValueError as e:
|
||||
logger.error("Fehler beim Laden des Public Keys für Gerät %s: %s", device.id, e)
|
||||
raise HTTPException(status_code=401, detail="Public Key des Geräts ist ungültig.")
|
||||
|
||||
try:
|
||||
pub_key.verify(signature_bytes, message.encode())
|
||||
except InvalidSignature:
|
||||
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
|
||||
|
||||
# 6. Heartbeat-Zeitstempel aktualisieren
|
||||
device.last_heartbeat_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
|
||||
return device
|
||||
@@ -1,12 +1,23 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import require_role
|
||||
from app.core.kiosk_security import verify_kiosk_request
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
|
||||
from app.schemas.kiosk import (
|
||||
HeartbeatRequest,
|
||||
HeartbeatResponse,
|
||||
KioskApproveResponse,
|
||||
KioskDeviceCreate,
|
||||
KioskDeviceOut,
|
||||
KioskDeviceUpdate,
|
||||
)
|
||||
from app.services.kiosk_service import kiosk_service
|
||||
|
||||
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
|
||||
@@ -18,26 +29,34 @@ _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
|
||||
@router.get("/devices", response_model=list[KioskDeviceOut])
|
||||
async def list_devices(
|
||||
status: str | None = Query(None, description="Filter: pending, approved oder revoked"),
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
|
||||
return await kiosk_service.list_devices(current_user.company_id, db)
|
||||
"""Alle registrierten Kiosk-Geräte der Firma auflisten (optional nach Status filtern)."""
|
||||
status_filter: KioskDeviceStatus | None = None
|
||||
if status is not None:
|
||||
try:
|
||||
status_filter = KioskDeviceStatus(status)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungültiger Status-Filter '{status}'. Erlaubt: pending, approved, revoked.",
|
||||
)
|
||||
devices = await kiosk_service.list_devices(current_user.company_id, db, status_filter)
|
||||
return [KioskDeviceOut.model_validate(d) for d in devices]
|
||||
|
||||
|
||||
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
|
||||
@router.post("/devices", response_model=KioskDeviceOut, status_code=201)
|
||||
async def create_device(
|
||||
data: KioskDeviceCreate,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
|
||||
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
"""Neues Kiosk-Gerät mit Ed25519-Public-Key registrieren."""
|
||||
device = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||
await db.commit()
|
||||
return KioskDeviceCreated(
|
||||
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||
token=raw_token,
|
||||
)
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||
@@ -46,7 +65,9 @@ async def get_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await kiosk_service.get_device(device_id, current_user.company_id, db)
|
||||
"""Einzelnes Kiosk-Gerät abrufen."""
|
||||
device = await kiosk_service.get_device(device_id, current_user.company_id, db)
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||
@@ -56,44 +77,80 @@ async def update_device(
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Name, Standort oder IP-Whitelist eines Kiosk-Geräts aktualisieren."""
|
||||
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
|
||||
await db.commit()
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
|
||||
async def rotate_token(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Token rotieren – das alte Token wird sofort ungültig."""
|
||||
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskDeviceCreated(
|
||||
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||
token=raw_token,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=204)
|
||||
async def delete_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät dauerhaft löschen."""
|
||||
await kiosk_service.delete_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
|
||||
|
||||
@router.get("/me", response_model=KioskDeviceOut)
|
||||
async def kiosk_me(
|
||||
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
|
||||
@router.post("/devices/{device_id}/approve", response_model=KioskApproveResponse)
|
||||
async def approve_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
|
||||
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
|
||||
"""Kiosk-Gerät freigeben (Status: pending → approved)."""
|
||||
device = await kiosk_service.approve_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskDeviceOut.model_validate(device)
|
||||
return KioskApproveResponse(
|
||||
id=device.id,
|
||||
status=device.status.value,
|
||||
message=f"Gerät '{device.name}' wurde freigegeben.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/revoke", response_model=KioskApproveResponse)
|
||||
async def revoke_device(
|
||||
device_id: UUID,
|
||||
current_user: User = require_role(*_admin_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Kiosk-Gerät sperren (Status → revoked). Erfordert Re-Enrollment."""
|
||||
device = await kiosk_service.revoke_device(device_id, current_user.company_id, db)
|
||||
await db.commit()
|
||||
return KioskApproveResponse(
|
||||
id=device.id,
|
||||
status=device.status.value,
|
||||
message=f"Gerät '{device.name}' wurde gesperrt. Re-Enrollment erforderlich.",
|
||||
)
|
||||
|
||||
|
||||
# ── Kiosk-Endpunkte (signierte Requests via Ed25519) ──────────────────────────
|
||||
|
||||
@router.post("/heartbeat", response_model=HeartbeatResponse)
|
||||
async def heartbeat(
|
||||
data: HeartbeatRequest,
|
||||
device: KioskDevice = Depends(verify_kiosk_request),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Heartbeat-Endpunkt für Kiosk-Geräte.
|
||||
|
||||
Muss mit Ed25519-Signatur-Headern aufgerufen werden:
|
||||
X-Kiosk-Key-Id, X-Kiosk-Timestamp, X-Kiosk-Nonce, X-Kiosk-Signature
|
||||
|
||||
Gibt Server-Zeit und Konfiguration zurück.
|
||||
"""
|
||||
company = await db.get(Company, device.company_id)
|
||||
if company is None:
|
||||
raise HTTPException(status_code=500, detail="Firma nicht gefunden.")
|
||||
|
||||
await kiosk_service.process_heartbeat(device, data, company, db)
|
||||
await db.commit()
|
||||
|
||||
return HeartbeatResponse(
|
||||
server_time=datetime.now(timezone.utc).isoformat(),
|
||||
heartbeat_interval_sec=company.kiosk_heartbeat_interval_sec,
|
||||
device_status=device.status.value,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -6,37 +5,34 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_token
|
||||
from app.models.kiosk_device import KioskDevice
|
||||
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||||
|
||||
|
||||
class KioskService:
|
||||
|
||||
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
|
||||
result = await db.scalars(
|
||||
# ── 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 create_device(
|
||||
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""Gerät anlegen. Gibt (device, raw_token) zurück – raw_token nur einmalig."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
device = KioskDevice(
|
||||
company_id=company_id,
|
||||
name=data.name,
|
||||
location=data.location,
|
||||
token_hash=hash_token(raw_token),
|
||||
)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device, raw_token
|
||||
|
||||
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
|
||||
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,
|
||||
@@ -47,8 +43,40 @@ class KioskService:
|
||||
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
|
||||
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)
|
||||
@@ -56,32 +84,56 @@ class KioskService:
|
||||
setattr(device, field, value)
|
||||
return device
|
||||
|
||||
async def rotate_token(
|
||||
async def delete_device(
|
||||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||
) -> tuple[KioskDevice, str]:
|
||||
"""Token rotieren – altes Token wird sofort ungültig."""
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
device.token_hash = hash_token(raw_token)
|
||||
return device, raw_token
|
||||
|
||||
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
|
||||
) -> None:
|
||||
device = await self.get_device(device_id, company_id, db)
|
||||
await db.delete(device)
|
||||
|
||||
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
|
||||
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
|
||||
token_hash = hash_token(raw_token)
|
||||
device = await db.scalar(
|
||||
select(KioskDevice).where(
|
||||
KioskDevice.token_hash == token_hash,
|
||||
KioskDevice.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
|
||||
device.last_seen_at = datetime.now(timezone.utc)
|
||||
# ── 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()
|
||||
|
||||
Reference in New Issue
Block a user