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,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