30828c69e9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
285 lines
9.5 KiB
Python
285 lines
9.5 KiB
Python
import uuid as _uuid
|
|
from datetime import datetime, timezone
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Body, 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 (
|
|
HeartbeatRequest,
|
|
HeartbeatResponse,
|
|
KioskApproveResponse,
|
|
KioskDeviceCreate,
|
|
KioskDeviceOut,
|
|
KioskDeviceUpdate,
|
|
)
|
|
from app.schemas.kiosk_auth import (
|
|
KioskNfcLoginRequest,
|
|
KioskPinLoginRequest,
|
|
KioskQrLoginRequest,
|
|
KioskSessionResponse,
|
|
KioskUserListEntry,
|
|
KioskUserListResponse,
|
|
)
|
|
from app.services.kiosk_auth_service import _display_name, kiosk_auth_service
|
|
from app.services.kiosk_service import kiosk_service
|
|
from app.services.kiosk_session_service import SESSION_TTL_SECONDS, kiosk_session_service
|
|
|
|
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
|
|
|
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
|
|
|
|
|
# ── Geräteverwaltung (COMPANY_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 (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=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 mit Ed25519-Public-Key registrieren."""
|
|
device = await kiosk_service.create_device(current_user.company_id, data, db)
|
|
await db.commit()
|
|
return KioskDeviceOut.model_validate(device)
|
|
|
|
|
|
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
|
|
async def get_device(
|
|
device_id: UUID,
|
|
current_user: User = require_role(*_admin_roles),
|
|
db: AsyncSession = Depends(get_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)
|
|
async def update_device(
|
|
device_id: UUID,
|
|
data: KioskDeviceUpdate,
|
|
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.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()
|
|
|
|
|
|
@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 freigeben (Status: pending → approved)."""
|
|
device = await kiosk_service.approve_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 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-User-Auth (signierte Requests via Ed25519) ─────────────────────────
|
|
|
|
|
|
@router.post("/auth/pin", response_model=KioskSessionResponse)
|
|
async def kiosk_login_pin(
|
|
data: KioskPinLoginRequest,
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""PIN-Login: Personalnummer + 4-16-stellige PIN."""
|
|
user, session_token = await kiosk_auth_service.login_pin(
|
|
personnel_number=data.personnel_number,
|
|
pin=data.pin,
|
|
company_id=device.company_id,
|
|
device_id=device.id,
|
|
db=db,
|
|
)
|
|
return KioskSessionResponse(
|
|
session_token=session_token,
|
|
user_id=user.id,
|
|
user_name=_display_name(user),
|
|
expires_in_seconds=SESSION_TTL_SECONDS,
|
|
auth_method="pin",
|
|
)
|
|
|
|
|
|
@router.post("/auth/nfc", response_model=KioskSessionResponse)
|
|
async def kiosk_login_nfc(
|
|
data: KioskNfcLoginRequest,
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""NFC-Login: NFC-UID der Karte."""
|
|
user, session_token = await kiosk_auth_service.login_nfc(
|
|
nfc_uid=data.nfc_uid,
|
|
company_id=device.company_id,
|
|
device_id=device.id,
|
|
db=db,
|
|
)
|
|
return KioskSessionResponse(
|
|
session_token=session_token,
|
|
user_id=user.id,
|
|
user_name=_display_name(user),
|
|
expires_in_seconds=SESSION_TTL_SECONDS,
|
|
auth_method="nfc",
|
|
)
|
|
|
|
|
|
@router.post("/auth/qr", response_model=KioskSessionResponse)
|
|
async def kiosk_login_qr(
|
|
data: KioskQrLoginRequest,
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""QR-Login: QR-Token (aus Web-App gescannt)."""
|
|
user, session_token = await kiosk_auth_service.login_qr(
|
|
qr_token=data.qr_token,
|
|
company_id=device.company_id,
|
|
device_id=device.id,
|
|
db=db,
|
|
)
|
|
return KioskSessionResponse(
|
|
session_token=session_token,
|
|
user_id=user.id,
|
|
user_name=_display_name(user),
|
|
expires_in_seconds=SESSION_TTL_SECONDS,
|
|
auth_method="qr",
|
|
)
|
|
|
|
|
|
@router.post("/auth/list", response_model=KioskSessionResponse)
|
|
async def kiosk_login_list(
|
|
user_id: _uuid.UUID = Body(..., embed=True),
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List-Login: User wählt sich aus Mitarbeiterliste aus (kein Passwort)."""
|
|
user, session_token = await kiosk_auth_service.login_list(
|
|
user_id=user_id,
|
|
company_id=device.company_id,
|
|
device_id=device.id,
|
|
db=db,
|
|
)
|
|
return KioskSessionResponse(
|
|
session_token=session_token,
|
|
user_id=user.id,
|
|
user_name=_display_name(user),
|
|
expires_in_seconds=SESSION_TTL_SECONDS,
|
|
auth_method="list",
|
|
)
|
|
|
|
|
|
@router.get("/auth/users", response_model=KioskUserListResponse)
|
|
async def kiosk_user_list(
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Mitarbeiterliste für Kiosk-Auswahl."""
|
|
users = await kiosk_auth_service.get_user_list(device.company_id, db)
|
|
return KioskUserListResponse(
|
|
users=[
|
|
KioskUserListEntry(id=u.id, display_name=_display_name(u))
|
|
for u in users
|
|
]
|
|
)
|
|
|
|
|
|
@router.post("/auth/logout")
|
|
async def kiosk_logout(
|
|
session_token: str = Body(..., embed=True),
|
|
device: KioskDevice = Depends(verify_kiosk_request),
|
|
):
|
|
"""Session invalidieren."""
|
|
await kiosk_session_service.invalidate_session(session_token)
|
|
return {"message": "Abgemeldet."}
|
|
|
|
|
|
# ── 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,
|
|
)
|