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:
@@ -924,3 +924,19 @@ Keine Commits in dieser Session.
|
||||
- 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 ++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
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 app.core.database import get_db
|
||||
@@ -18,7 +19,17 @@ from app.schemas.kiosk import (
|
||||
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"])
|
||||
|
||||
@@ -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) ──────────────────────────
|
||||
|
||||
@router.post("/heartbeat", response_model=HeartbeatResponse)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user