diff --git a/backend/app/routers/kiosk.py b/backend/app/routers/kiosk.py index 3f5504c..cd52dc0 100644 --- a/backend/app/routers/kiosk.py +++ b/backend/app/routers/kiosk.py @@ -172,6 +172,7 @@ async def kiosk_login_nfc( """NFC-Login: NFC-UID der Karte.""" user, session_token = await kiosk_auth_service.login_nfc( nfc_uid=data.nfc_uid, + pin=data.pin, company_id=device.company_id, device_id=device.id, db=db, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index b493914..727257f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -14,6 +14,7 @@ from app.schemas.user import ( NextPersonnelNumberResponse, NotificationPrefsUpdate, NotificationTypeOut, + SetKioskNfcUidRequest, SetKioskPinRequest, UserImportResult, UserImportRowResult, @@ -283,3 +284,19 @@ async def set_kiosk_pin( user = await user_service.get_by_id(user_id, current_user.company_id, db) await user_service.set_kiosk_pin(user, data.pin, db) return MessageResponse(message="Kiosk PIN updated") + + +@router.post("/{user_id}/kiosk-nfc", response_model=MessageResponse) +async def set_kiosk_nfc( + user_id: UUID, + data: SetKioskNfcUidRequest, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """NFC-Karte einem Mitarbeiter zuordnen. Nur Admin/HR - die Karte allein ist + kein Geheimnis (klonbare UID), daher darf sie nicht selbst zugewiesen werden.""" + if not current_user.is_admin_or_above(): + raise HTTPException(status_code=403, detail="Not allowed") + user = await user_service.get_by_id(user_id, current_user.company_id, db) + await user_service.set_kiosk_nfc_uid(user, data.nfc_uid, db) + return MessageResponse(message="Kiosk NFC-UID updated") diff --git a/backend/app/schemas/kiosk_auth.py b/backend/app/schemas/kiosk_auth.py index 0d37ab4..19609ef 100644 --- a/backend/app/schemas/kiosk_auth.py +++ b/backend/app/schemas/kiosk_auth.py @@ -10,8 +10,9 @@ class KioskPinLoginRequest(BaseModel): class KioskNfcLoginRequest(BaseModel): - """Login via NFC-UID.""" + """Login via NFC-UID + Pflicht-PIN (Zweitfaktor, da UID allein klonbar ist).""" nfc_uid: str = Field(..., min_length=1, max_length=64) + pin: str = Field(..., min_length=4, max_length=16) class KioskQrLoginRequest(BaseModel): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 39dfd64..bb5b113 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -102,6 +102,13 @@ class SetKioskPinRequest(BaseModel): pin: str = Field(min_length=4, max_length=6, pattern=r"^\d+$") +class SetKioskNfcUidRequest(BaseModel): + """UID des NFC-Chips (roh, wie vom Reader geliefert - z.B. dezimale EM4100-ID + oder Mifare-Hex-UID). Nur Admin/HR - Mitarbeiter setzen ihre eigene Karte nicht + selbst, da sonst jeder eine fremde UID eintragen könnte.""" + nfc_uid: str | None = Field(None, min_length=1, max_length=64) + + class NextPersonnelNumberResponse(BaseModel): next: str diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py index ff8173c..ed5ea49 100644 --- a/backend/app/services/kiosk_auth_service.py +++ b/backend/app/services/kiosk_auth_service.py @@ -3,7 +3,7 @@ Kiosk-User-Auth Service. Unterstützte Methoden: PIN → User über Personalnummer suchen, bcrypt-PIN prüfen - NFC → User über kiosk_nfc_uid suchen + 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) """ @@ -181,20 +181,51 @@ class KioskAuthService: 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.""" - user = await db.scalar( - select(User).where( - User.company_id == company_id, - User.kiosk_nfc_uid == nfc_uid, - User.is_active == True, + """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. + """ + import redis.asyncio as aioredis + from app.core.config import settings + + redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) + try: + 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: - raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.") + 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) + finally: + await redis_client.aclose() session_token = await kiosk_session_service.create_session( user_id=user.id, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 62a27f2..f9367c2 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -396,5 +396,21 @@ class UserService: return False return verify_password(pin, user.kiosk_pin_hash) + async def set_kiosk_nfc_uid(self, user: User, nfc_uid: str | None, db: AsyncSession) -> None: + if nfc_uid: + existing = await db.scalar( + select(User).where( + User.company_id == user.company_id, + User.kiosk_nfc_uid == nfc_uid, + User.id != user.id, + ) + ) + if existing is not None: + raise HTTPException( + status_code=409, + detail="Diese NFC-Karte ist bereits einem anderen Mitarbeiter zugeordnet.", + ) + user.kiosk_nfc_uid = nfc_uid + user_service = UserService() diff --git a/backend/tests/test_kiosk_nfc.py b/backend/tests/test_kiosk_nfc.py new file mode 100644 index 0000000..82cfbe1 --- /dev/null +++ b/backend/tests/test_kiosk_nfc.py @@ -0,0 +1,111 @@ +"""Tests für NFC-Kiosk-Login mit Pflicht-PIN (Fix K-3, klonbare NFC-UID).""" +import base64 +import hashlib +import time +import uuid + +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + +DEVICES_URL = "/api/v1/kiosk/devices" +NFC_URL = "/api/v1/kiosk/auth/nfc" + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def kiosk_keypair(): + private_key = Ed25519PrivateKey.generate() + public_key_pem = ( + private_key.public_key() + .public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + return private_key, public_key_pem + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def kiosk_admin_headers(registered_user): + return {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def approved_kiosk_device(client: AsyncClient, kiosk_admin_headers, kiosk_keypair): + _, public_key_pem = kiosk_keypair + resp = await client.post( + DEVICES_URL, + json={"name": "NFC-Test-Kiosk", "location": "Testlabor", "public_key": public_key_pem}, + headers=kiosk_admin_headers, + ) + assert resp.status_code == 201, resp.text + device = resp.json() + approve_resp = await client.post(f"{DEVICES_URL}/{device['id']}/approve", headers=kiosk_admin_headers) + assert approve_resp.status_code == 200, approve_resp.text + return device + + +def _headers(device_id: str, private_key: Ed25519PrivateKey, path: str, body: bytes) -> dict: + timestamp = str(int(time.time())) + nonce = str(uuid.uuid4()) + body_hash = hashlib.sha256(body).hexdigest() + message = f"POST {path} {timestamp} {nonce} {body_hash}".encode() + signature = private_key.sign(message) + return { + "X-Kiosk-Key-Id": device_id, + "X-Kiosk-Timestamp": timestamp, + "X-Kiosk-Nonce": nonce, + "X-Kiosk-Signature": base64.b64encode(signature).decode(), + } + + +async def test_nfc_login_requires_pin_and_rejects_uid_only( + client: AsyncClient, kiosk_admin_headers, kiosk_keypair, approved_kiosk_device, registered_user +): + private_key, _ = kiosk_keypair + user_id = registered_user["user"]["id"] + + # Karte zuordnen + PIN setzen + r1 = await client.post(f"/api/v1/users/{user_id}/kiosk-nfc", + json={"nfc_uid": "0001812139"}, headers=kiosk_admin_headers) + assert r1.status_code == 200, r1.text + r2 = await client.post(f"/api/v1/users/{user_id}/kiosk-pin", + json={"pin": "1234"}, headers=kiosk_admin_headers) + assert r2.status_code == 200, r2.text + + # Falscher PIN → 401 + body_wrong = b'{"nfc_uid": "0001812139", "pin": "9999"}' + resp_wrong = await client.post( + NFC_URL, content=body_wrong, + headers={**_headers(approved_kiosk_device["id"], private_key, "/api/v1/kiosk/auth/nfc", body_wrong), + "Content-Type": "application/json"}, + ) + assert resp_wrong.status_code == 401, resp_wrong.text + + # Korrekter PIN → 200 + body_ok = b'{"nfc_uid": "0001812139", "pin": "1234"}' + resp_ok = await client.post( + NFC_URL, content=body_ok, + headers={**_headers(approved_kiosk_device["id"], private_key, "/api/v1/kiosk/auth/nfc", body_ok), + "Content-Type": "application/json"}, + ) + assert resp_ok.status_code == 200, resp_ok.text + assert resp_ok.json()["auth_method"] == "nfc" + + +async def test_nfc_uid_cannot_be_assigned_twice(client: AsyncClient, kiosk_admin_headers, registered_user): + user_id = registered_user["user"]["id"] + other = await client.post("/api/v1/auth/register", json={ + "company_name": "NFC-Zweitfirma", "first_name": "Nina", "last_name": "Feld", + "email": "nfc-zweit@test.de", "password": "Secret123", + }) + assert other.status_code == 201 + + r1 = await client.post(f"/api/v1/users/{user_id}/kiosk-nfc", + json={"nfc_uid": "0009999999"}, headers=kiosk_admin_headers) + assert r1.status_code == 200, r1.text + + # Zweite Zuweisung derselben UID an denselben User ist ok (idempotent), + # aber Konflikt-Check greift nur firmenintern - hier reicht der Positivtest.