feat: agent-02-kiosk Phase 2A - Auth endpoints (PIN/NFC/QR/List)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:49:09 +02:00
parent 094863f94b
commit 30828c69e9
4 changed files with 378 additions and 1 deletions
+16
View File
@@ -924,3 +924,19 @@ Keine Commits in dieser Session.
- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++-------- - frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++--------
--- ---
## 2026-05-24 12:37 12:47 (10m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
- 094863f feat: agent-02-kiosk Phase 1 - NFC UID migration + session service
- 1db7164 fix(security): SSRF-Schutz für CalDAV-URLs
### Geänderte Dateien
- backend/app/core/redis.py | 28 ++++++
- backend/app/models/user.py | 3 +
- backend/app/services/kiosk_session_service.py | 114 ++++++++++++++++++++++
- backend/migrations/versions/0025_kiosk_nfc_uid.py | 36 +++++++
- frontend/DEVLOG.md | 51 ++++++++++
---
+129 -1
View File
@@ -1,7 +1,8 @@
import uuid as _uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@@ -18,7 +19,17 @@ from app.schemas.kiosk import (
KioskDeviceOut, KioskDeviceOut,
KioskDeviceUpdate, 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_service import kiosk_service
from app.services.kiosk_session_service import SESSION_TTL_SECONDS, kiosk_session_service
router = APIRouter(prefix="/kiosk", tags=["Kiosk"]) router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
@@ -126,6 +137,123 @@ async def revoke_device(
) )
# ── 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) ────────────────────────── # ── Kiosk-Endpunkte (signierte Requests via Ed25519) ──────────────────────────
@router.post("/heartbeat", response_model=HeartbeatResponse) @router.post("/heartbeat", response_model=HeartbeatResponse)
+38
View File
@@ -0,0 +1,38 @@
"""Schemas für Kiosk-User-Authentifizierung."""
import uuid
from pydantic import BaseModel, Field
class KioskPinLoginRequest(BaseModel):
"""Login via Personalnummer + PIN."""
personnel_number: str = Field(..., min_length=1)
pin: str = Field(..., min_length=4, max_length=16)
class KioskNfcLoginRequest(BaseModel):
"""Login via NFC-UID."""
nfc_uid: str = Field(..., min_length=1, max_length=64)
class KioskQrLoginRequest(BaseModel):
"""Login via QR-Code (enthält verschlüsselten Token)."""
qr_token: str = Field(..., min_length=1)
class KioskSessionResponse(BaseModel):
"""Antwort nach erfolgreichem Kiosk-Login."""
session_token: str
user_id: uuid.UUID
user_name: str # "Max M." (Vorname + Nachname abgekürzt für Datenschutz)
expires_in_seconds: int # 900 (15 min)
auth_method: str # "pin" | "nfc" | "qr" | "list"
class KioskUserListEntry(BaseModel):
"""Minimale User-Info für Auswahl in der Mitarbeiterliste."""
id: uuid.UUID
display_name: str # "Max M." oder Kürzel
class KioskUserListResponse(BaseModel):
users: list[KioskUserListEntry]
+195
View File
@@ -0,0 +1,195 @@
"""
Kiosk-User-Auth Service.
Unterstützte Methoden:
PIN → User über Personalnummer suchen, bcrypt-PIN prüfen
NFC → User über kiosk_nfc_uid suchen
QR → QR-Token ist ein kurzlebiger Redis-Key (5 min, einmalig)
List → Kein Passwort, User wählt sich aus Liste (für vertrauenswürdige Umgebungen)
"""
from __future__ import annotations
import json
import logging
import secrets
import uuid
import bcrypt
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.kiosk_session_service import SESSION_TTL_SECONDS, kiosk_session_service
log = logging.getLogger(__name__)
QR_TOKEN_PREFIX = "kiosk_qr:"
QR_TOKEN_TTL = 5 * 60 # 5 Minuten
class KioskAuthService:
async def login_pin(
self,
personnel_number: str,
pin: str,
company_id: uuid.UUID,
device_id: uuid.UUID,
db: AsyncSession,
) -> tuple[User, str]:
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
User.is_active == True,
)
)
if user is None:
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
if not user.kiosk_pin_hash:
raise HTTPException(
status_code=401,
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
)
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
raise HTTPException(status_code=401, detail="Falscher PIN.")
session_token = await kiosk_session_service.create_session(
user_id=user.id,
company_id=company_id,
device_id=device_id,
auth_method="pin",
)
return user, session_token
async def login_nfc(
self,
nfc_uid: str,
company_id: uuid.UUID,
device_id: uuid.UUID,
db: AsyncSession,
) -> tuple[User, str]:
"""Authentifizierung per NFC-UID."""
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.kiosk_nfc_uid == nfc_uid,
User.is_active == True,
)
)
if user is None:
raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.")
session_token = await kiosk_session_service.create_session(
user_id=user.id,
company_id=company_id,
device_id=device_id,
auth_method="nfc",
)
return user, session_token
async def generate_qr_token(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str:
"""
Erzeugt einen einmaligen QR-Token (für die Web-App: User scannt QR am Kiosk).
Token ist 5 min gültig und wird in Redis gespeichert.
"""
from app.core.redis import get_redis_client
redis = get_redis_client()
if redis is None:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
token = secrets.token_urlsafe(32)
key = QR_TOKEN_PREFIX + token
redis.setex(key, QR_TOKEN_TTL, json.dumps({
"user_id": str(user_id),
"company_id": str(company_id),
}))
return token
async def login_qr(
self,
qr_token: str,
company_id: uuid.UUID,
device_id: uuid.UUID,
db: AsyncSession,
) -> tuple[User, str]:
"""Validiert QR-Token (einmalig) und erstellt Session."""
from app.core.redis import get_redis_client
redis = get_redis_client()
if redis is None:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
key = QR_TOKEN_PREFIX + qr_token
data = redis.get(key)
if data is None:
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
# Einmalig: Token sofort löschen
redis.delete(key)
payload = json.loads(data)
if str(payload.get("company_id")) != str(company_id):
raise HTTPException(status_code=401, detail="QR-Code für falsches Unternehmen.")
user_id = uuid.UUID(payload["user_id"])
user = await db.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="User nicht gefunden oder inaktiv.")
session_token = await kiosk_session_service.create_session(
user_id=user.id,
company_id=company_id,
device_id=device_id,
auth_method="qr",
)
return user, session_token
async def login_list(
self,
user_id: uuid.UUID,
company_id: uuid.UUID,
device_id: uuid.UUID,
db: AsyncSession,
) -> tuple[User, str]:
"""Login per Auswahl aus der Mitarbeiterliste (nur für vertrauenswürdige Umgebungen)."""
user = await db.scalar(
select(User).where(
User.id == user_id,
User.company_id == company_id,
User.is_active == True,
)
)
if user is None:
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
session_token = await kiosk_session_service.create_session(
user_id=user.id,
company_id=company_id,
device_id=device_id,
auth_method="list",
)
return user, session_token
async def get_user_list(
self, company_id: uuid.UUID, db: AsyncSession
) -> list[User]:
"""Alle aktiven Mitarbeiter der Firma für Kiosk-Auswahlliste."""
result = await db.scalars(
select(User).where(
User.company_id == company_id,
User.is_active == True,
).order_by(User.last_name, User.first_name)
)
return list(result.all())
def _display_name(user: User) -> str:
"""Kurzname für Kiosk-Anzeige: 'Max M.'"""
return f"{user.first_name} {user.last_name[:1]}."
kiosk_auth_service = KioskAuthService()