Files
timemaster/backend/app/services/kiosk_auth_service.py
T
patrickandClaude Sonnet 5 107cba99f9
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s
fix(redis): INCR+EXPIRE atomar via Pipeline (MULTI/EXEC)
Login-, TOTP- und Kiosk-PIN-Lockout zählten Fehlversuche mit separaten
INCR/EXPIRE-Calls. Zwischen beiden konnte der Fail-Counter-Key kurzzeitig
ohne TTL bestehen bleiben (Crash-Fenster) - nicht atomar. Jetzt per
redis.pipeline(transaction=True) als MULTI/EXEC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
2026-09-03 00:12:44 +02:00

330 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Kiosk-User-Auth Service.
Unterstützte Methoden:
PIN → User über Personalnummer suchen, bcrypt-PIN prüfen
NFC → User über kiosk_nfc_uid suchen, PIN als Pflicht-Zweitfaktor (K-3-Fix)
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
# PIN-Brute-Force-Schutz
PIN_MAX_ATTEMPTS = 5
PIN_LOCKOUT_SECONDS = 900 # 15 Minuten
class KioskAuthService:
async def _check_pin_lockout(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Prüft ob PIN-Login für diese Kombination gesperrt ist. Wirft 429 wenn ja."""
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
if await redis.exists(lockout_key):
ttl = await redis.ttl(lockout_key)
wait_min = ttl // 60 + 1
raise HTTPException(
status_code=429,
detail=f"Zu viele Fehlversuche. Bitte {wait_min} Minute(n) warten.",
)
async def _record_pin_failure(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Zählt einen Fehlversuch und sperrt bei Überschreitung von PIN_MAX_ATTEMPTS."""
fail_key = f"pin_fails:{device_id}:{personnel_number}"
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
# INCR+EXPIRE atomar (MULTI/EXEC) sonst könnte fail_key zwischen beiden
# Calls kurzzeitig ohne TTL bestehen (Crash-Fenster).
async with redis.pipeline(transaction=True) as pipe:
pipe.incr(fail_key)
pipe.expire(fail_key, PIN_LOCKOUT_SECONDS)
fails, _ = await pipe.execute()
if fails >= PIN_MAX_ATTEMPTS:
await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS)
await redis.delete(fail_key)
log.warning(
"PIN-Lockout ausgelöst: device=%s personnel_number=%s",
device_id,
personnel_number,
)
async def _clear_pin_failures(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Löscht Fehlversuche nach erfolgreichem Login."""
await redis.delete(f"pin_fails:{device_id}:{personnel_number}")
await redis.delete(f"pin_lockout:{device_id}:{personnel_number}")
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)."""
from app.core.redis import get_async_redis
redis_client = get_async_redis()
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
await self._check_pin_lockout(device_id, personnel_number, redis_client)
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:
# Fehlversuch zählen auch bei unbekannter Personalnummer
await self._record_pin_failure(device_id, personnel_number, redis_client)
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()):
await self._record_pin_failure(device_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Falscher PIN.")
# Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_pin_failures(device_id, personnel_number, redis_client)
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_pin_public(
self,
personnel_number: str,
pin: str,
company_id: uuid.UUID,
db: AsyncSession,
) -> User:
"""PIN-Auth für das öffentliche QR-Stempeln (ohne Kiosk-Gerät).
Wiederverwendet den Brute-Force-Lockout, aber keyed auf
(company_id, personnel_number) statt (device_id, ...), da hier kein
Gerät existiert. Erzeugt KEINE Kiosk-Session der Aufrufer legt eine
separate öffentliche Kurz-Session an. Gibt nur den User zurück.
"""
from app.core.redis import get_async_redis
# Lockout-Key-Namespace klar vom Kiosk trennen
lock_id = f"public:{company_id}"
redis_client = get_async_redis()
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
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:
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
await self._record_pin_failure(lock_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
if not user.kiosk_pin_hash:
raise HTTPException(
status_code=401,
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
)
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
await self._record_pin_failure(lock_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
return user
async def login_nfc(
self,
nfc_uid: str,
pin: str,
company_id: uuid.UUID,
device_id: uuid.UUID,
db: AsyncSession,
) -> tuple[User, str]:
"""Authentifizierung per NFC-UID + Pflicht-PIN als Zweitfaktor.
Reine NFC-UIDs (MIFARE Classic/NTAG213 o.ä. wie sie günstige
HID-Keyboard-Reader liefern) sind trivial klonbar (Security-Audit
2026-05-26, K-3) die Karte allein ist daher kein ausreichender
Auth-Faktor. PIN-Pflicht macht aus "Karte kopiert" allein noch keinen
gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin,
keyed auf die NFC-UID statt Personalnummer.
"""
from app.core.redis import get_async_redis
redis_client = get_async_redis()
await self._check_pin_lockout(device_id, nfc_uid, redis_client)
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:
await self._record_pin_failure(device_id, nfc_uid, redis_client)
raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.")
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()):
await self._record_pin_failure(device_id, nfc_uid, redis_client)
raise HTTPException(status_code=401, detail="Falscher PIN.")
await self._clear_pin_failures(device_id, nfc_uid, redis_client)
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_async_redis
redis = get_async_redis()
token = secrets.token_urlsafe(32)
key = QR_TOKEN_PREFIX + token
try:
await redis.set(key, json.dumps({
"user_id": str(user_id),
"company_id": str(company_id),
}), ex=QR_TOKEN_TTL)
except Exception as exc:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc
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_async_redis
redis = get_async_redis()
key = QR_TOKEN_PREFIX + qr_token
try:
data = await redis.get(key)
except Exception as exc:
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc
if data is None:
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
# Einmalig: Token sofort löschen
await 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()