diff --git a/backend/app/main.py b/backend/app/main.py index adaa724..2b43b5f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.routers import busylight from app.routers import audit from app.routers import special_assignments from app.routers import hours_payouts +from app.routers import public_stamp @asynccontextmanager @@ -85,6 +86,7 @@ app.include_router(auth.router, prefix=API_PREFIX) app.include_router(users.router, prefix=API_PREFIX) app.include_router(companies.router, prefix=API_PREFIX) app.include_router(time_entries.router, prefix=API_PREFIX) +app.include_router(public_stamp.router, prefix=API_PREFIX) app.include_router(absences.router, prefix=API_PREFIX) app.include_router(reports.router, prefix=API_PREFIX) app.include_router(ldap.router, prefix=API_PREFIX) diff --git a/backend/app/models/company.py b/backend/app/models/company.py index d3aa84f..fbd7e07 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -44,6 +44,13 @@ class Company(Base): busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True) busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Öffentliches QR-Stempeln: statischer firmenweiter QR-Code → /stamp?t=. + # Mitarbeiter scannt mit Privat-Handy, meldet sich per Personalnummer + PIN an. + # Opt-in (default OFF). Token gehasht in DB (SHA-256), Klartext nur beim Rotieren. + public_stamp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + public_stamp_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True) + public_stamp_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Kiosk-Konfiguration kiosk_require_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) kiosk_track_current_user: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/routers/companies.py b/backend/app/routers/companies.py index 053faf3..ac66969 100644 --- a/backend/app/routers/companies.py +++ b/backend/app/routers/companies.py @@ -1,12 +1,17 @@ +import hashlib +import secrets +from datetime import datetime, timezone from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import settings from app.core.database import get_db -from app.core.dependencies import CurrentUser, require_role +from app.core.dependencies import CurrentUser, get_client_ip, require_role from app.models import Company +from app.models.audit_log import AuditLog from app.models.department import Department from app.models.user import User, UserRole from app.schemas.company import ( @@ -15,6 +20,8 @@ from app.schemas.company import ( DepartmentCreate, DepartmentOut, DepartmentUpdate, + PublicStampTokenRotated, + PublicStampTokenStatus, ) router = APIRouter(prefix="/companies", tags=["Companies"]) @@ -22,6 +29,10 @@ router = APIRouter(prefix="/companies", tags=["Companies"]) _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) +def _public_stamp_url(token: str) -> str: + return f"{settings.frontend_url.rstrip('/')}/stamp?t={token}" + + @router.get("/me", response_model=CompanyOut) async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)): company = await db.get(Company, current_user.company_id) @@ -46,6 +57,71 @@ async def update_my_company( return CompanyOut.model_validate(company) +# ── Öffentliches QR-Stempel-Token ──────────────────────────────────────────── + +@router.get("/me/public-stamp-token", response_model=PublicStampTokenStatus) +async def get_public_stamp_token_status( + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + return PublicStampTokenStatus( + enabled=company.public_stamp_enabled, + configured=company.public_stamp_token_hash is not None, + created_at=company.public_stamp_token_created_at, + ) + + +@router.post("/me/public-stamp-token/rotate", response_model=PublicStampTokenRotated) +async def rotate_public_stamp_token( + request: Request, + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + token = secrets.token_urlsafe(32) + company.public_stamp_token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + company.public_stamp_token_created_at = datetime.now(timezone.utc) + + db.add(AuditLog( + company_id=company.id, + user_id=current_user.id, + action="public_stamp_token_rotated", + entity_type="company", + entity_id=company.id, + ip=get_client_ip(request), + )) + await db.commit() + return PublicStampTokenRotated( + token=token, + public_url=_public_stamp_url(token), + created_at=company.public_stamp_token_created_at, + ) + + +@router.delete("/me/public-stamp-token", status_code=204) +async def delete_public_stamp_token( + request: Request, + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + company = await db.get(Company, current_user.company_id) + if company.public_stamp_token_hash is None: + return + company.public_stamp_token_hash = None + company.public_stamp_token_created_at = None + + db.add(AuditLog( + company_id=company.id, + user_id=current_user.id, + action="public_stamp_token_revoked", + entity_type="company", + entity_id=company.id, + ip=get_client_ip(request), + )) + await db.commit() + + # ── Departments ────────────────────────────────────────────────────────────── @router.get("/me/departments", response_model=list[DepartmentOut]) diff --git a/backend/app/routers/public_stamp.py b/backend/app/routers/public_stamp.py new file mode 100644 index 0000000..1b0fc45 --- /dev/null +++ b/backend/app/routers/public_stamp.py @@ -0,0 +1,155 @@ +"""Öffentliches QR-Stempeln (statischer firmenweiter QR-Code). + +Kein Bearer-Token, KEINE Ed25519-Geräte-Signatur (anders als der Kiosk): +ein privates Handy hat keinen Geräteschlüssel. Stattdessen Identifikation per +Personalnummer + PIN. Härtung: Opt-in (default OFF), PIN-Lockout, IP-Rate-Limit, +gehashtes rotierbares Token, 120s-Session, AuditLog. + +⚠ SICHERHEIT: Dieser Pfad ist STRIKT SCHWÄCHER als der Kiosk-Pfad – keine +Geräte-Signatur, kein Replay-Nonce, keine IP-Whitelist. Er tauscht Sicherheit +gegen BYOD-Komfort. Pro Firma nur aktivieren wenn wirklich benötigt. +""" +from __future__ import annotations + +import hashlib + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_client_ip +from app.core.limiter import limiter +from app.models.audit_log import AuditLog +from app.models.company import Company +from app.models.time_entry import EntrySource +from app.models.user import User +from app.schemas.public_stamp import ( + PublicStampActionRequest, + PublicStampActionResponse, + PublicStampAuthRequest, + PublicStampAuthResponse, + PublicStampCompanyInfo, +) +from app.schemas.time_entry import StampInRequest, TimeEntryOut +from app.services.kiosk_auth_service import kiosk_auth_service, _display_name +from app.services.public_stamp_session_service import ( + PUBLIC_STAMP_SESSION_TTL, + public_stamp_session_service, +) +from app.services.time_service import time_service + +router = APIRouter(prefix="/time/public", tags=["Public Stamping"]) + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def _company_from_token(token: str, db: AsyncSession, *, require_enabled: bool) -> Company: + company = await db.scalar( + select(Company).where(Company.public_stamp_token_hash == _hash_token(token)) + ) + if company is None: + raise HTTPException(status_code=404, detail="QR-Code ungültig.") + if require_enabled and not company.public_stamp_enabled: + raise HTTPException(status_code=403, detail="QR-Stempeln ist für dieses Unternehmen deaktiviert.") + return company + + +async def _status(user: User, db: AsyncSession) -> dict: + """Aktuellen Stempel-Status + heutige Einträge ermitteln.""" + open_entry = await time_service._get_open_entry(user.id, db) + today = await time_service.get_today(user, db) + return { + "open": open_entry is not None, + "on_break": open_entry is not None and open_entry.break_start is not None, + "today": [TimeEntryOut.model_validate(e) for e in today], + } + + +@router.get("/company", response_model=PublicStampCompanyInfo) +@limiter.limit("30/minute") +async def public_company_info( + request: Request, + t: str = Query(..., min_length=8), + db: AsyncSession = Depends(get_db), +): + """Firmen-Header für die Stempel-Seite. Gibt auch bei deaktiviertem + Feature den Namen zurück, damit die Seite einen Hinweis zeigen kann.""" + company = await _company_from_token(t, db, require_enabled=False) + return PublicStampCompanyInfo(company_name=company.name, enabled=company.public_stamp_enabled) + + +@router.post("/auth", response_model=PublicStampAuthResponse) +@limiter.limit("10/minute") +async def public_auth( + request: Request, + body: PublicStampAuthRequest, + db: AsyncSession = Depends(get_db), +): + """Personalnummer + PIN → Kurz-Session + aktueller Status.""" + company = await _company_from_token(body.token, db, require_enabled=True) + user = await kiosk_auth_service.login_pin_public( + body.personnel_number, body.pin, company.id, db + ) + + session_token = await public_stamp_session_service.create_session(user.id, company.id) + + db.add(AuditLog( + company_id=company.id, + user_id=user.id, + action="public_stamp_login", + entity_type="user", + entity_id=user.id, + ip=get_client_ip(request), + )) + await db.commit() + + status = await _status(user, db) + return PublicStampAuthResponse( + session_token=session_token, + user_name=_display_name(user), + expires_in_seconds=PUBLIC_STAMP_SESSION_TTL, + **status, + ) + + +@router.post("/action", response_model=PublicStampActionResponse) +@limiter.limit("30/minute") +async def public_action( + request: Request, + body: PublicStampActionRequest, + db: AsyncSession = Depends(get_db), +): + """Stempel-Aktion über eine gültige Kurz-Session ausführen.""" + session = await public_stamp_session_service.require_session(body.session_token) + + user = await db.get(User, session["user_id"]) + if user is None or not user.is_active or str(user.company_id) != session["company_id"]: + raise HTTPException(status_code=401, detail="Session ungültig.") + + warnings: list[str] = [] + if body.action == "in": + _, warnings = await time_service.stamp_in( + user, StampInRequest(source=EntrySource.KIOSK, note=body.note), db + ) + elif body.action == "out": + _, warnings = await time_service.stamp_out(user, body.note, db) + elif body.action == "break_start": + await time_service.break_start(user, db) + elif body.action == "break_end": + await time_service.break_end(user, db) + + db.add(AuditLog( + company_id=user.company_id, + user_id=user.id, + action=f"public_stamp_{body.action}", + entity_type="user", + entity_id=user.id, + ip=get_client_ip(request), + )) + await db.commit() + + status = await _status(user, db) + return PublicStampActionResponse(warnings=warnings, **status) diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index 066bcfd..8ec8999 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -45,6 +46,21 @@ class CompanyOut(BaseModel): kiosk_require_approval: bool = True kiosk_track_current_user: bool = True kiosk_heartbeat_interval_sec: int = 30 + public_stamp_enabled: bool = False + + +class PublicStampTokenStatus(BaseModel): + """Status des öffentlichen QR-Stempel-Tokens (kein Klartext).""" + enabled: bool + configured: bool + created_at: datetime | None = None + + +class PublicStampTokenRotated(BaseModel): + """Antwort beim Rotieren – enthält Klartext-Token + fertige URL (nur einmalig).""" + token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.") + public_url: str + created_at: datetime class CompanyUpdate(BaseModel): @@ -65,6 +81,7 @@ class CompanyUpdate(BaseModel): kiosk_require_approval: bool | None = None kiosk_track_current_user: bool | None = None kiosk_heartbeat_interval_sec: int | None = Field(None, ge=10, le=120) + public_stamp_enabled: bool | None = None class DepartmentOut(BaseModel): diff --git a/backend/app/schemas/public_stamp.py b/backend/app/schemas/public_stamp.py new file mode 100644 index 0000000..06269b2 --- /dev/null +++ b/backend/app/schemas/public_stamp.py @@ -0,0 +1,47 @@ +"""Schemas für das öffentliche QR-Stempeln (statischer firmenweiter QR-Code). + +Flow: Handy scannt QR (/stamp?t=) → Seite zeigt Firmennamen → +Personalnummer + PIN → Kurz-Session (120s) → Ein-/Ausstempeln. +""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from app.schemas.time_entry import TimeEntryOut + + +class PublicStampCompanyInfo(BaseModel): + """Header-Info für die Stempel-Seite (Token-Auflösung).""" + company_name: str + enabled: bool + + +class PublicStampAuthRequest(BaseModel): + token: str = Field(..., min_length=8) + personnel_number: str = Field(..., min_length=1, max_length=50) + pin: str = Field(..., min_length=4, max_length=6, pattern=r"^\d+$") + + +class PublicStampStatus(BaseModel): + """Aktueller Stempel-Status des Mitarbeiters.""" + open: bool + on_break: bool + today: list[TimeEntryOut] = [] + + +class PublicStampAuthResponse(PublicStampStatus): + session_token: str + user_name: str + expires_in_seconds: int + + +class PublicStampActionRequest(BaseModel): + session_token: str + action: Literal["in", "out", "break_start", "break_end"] + note: str | None = None + + +class PublicStampActionResponse(PublicStampStatus): + warnings: list[str] = [] diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py index 4255805..ff8173c 100644 --- a/backend/app/services/kiosk_auth_service.py +++ b/backend/app/services/kiosk_auth_service.py @@ -126,6 +126,58 @@ class KioskAuthService: ) 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. + """ + import redis.asyncio as aioredis + from app.core.config import settings + + # Lockout-Key-Namespace klar vom Kiosk trennen + lock_id = f"public:{company_id}" + + redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) + try: + 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) + finally: + await redis_client.aclose() + + return user + async def login_nfc( self, nfc_uid: str, diff --git a/backend/app/services/public_stamp_session_service.py b/backend/app/services/public_stamp_session_service.py new file mode 100644 index 0000000..e614f0c --- /dev/null +++ b/backend/app/services/public_stamp_session_service.py @@ -0,0 +1,77 @@ +"""Kurzlebige Redis-Sessions für das öffentliche QR-Stempeln. + +Anders als der Kiosk (15 min, vertrauenswürdiges Wandterminal) läuft das +öffentliche Stempeln auf einem privaten Handy über einen ungesicherten +öffentlichen Endpunkt. Deshalb sehr kurze TTL (120s): nach Anmeldung +genügend Zeit zum Ein-/Ausstempeln, danach automatischer Verfall. + +Redis ist Pflicht. Bei Redis-Ausfall → 503. +""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone +from typing import Optional + +from fastapi import HTTPException + +PUBLIC_STAMP_SESSION_TTL = 120 # 2 Minuten +SESSION_KEY_PREFIX = "public_stamp_session:" + + +class PublicStampSessionService: + + def _get_redis(self): + from app.core.redis import get_redis_client + client = get_redis_client() + if client is None: + raise HTTPException( + status_code=503, + detail="Stempel-Service nicht verfügbar (Redis nicht erreichbar).", + ) + return client + + async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str: + redis = self._get_redis() + session_token = str(uuid.uuid4()) + key = SESSION_KEY_PREFIX + session_token + payload = { + "user_id": str(user_id), + "company_id": str(company_id), + "created_at": datetime.now(timezone.utc).isoformat(), + } + try: + redis.setex(key, PUBLIC_STAMP_SESSION_TTL, json.dumps(payload)) + except Exception as exc: + raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}") + return session_token + + async def get_session(self, session_token: str) -> Optional[dict]: + redis = self._get_redis() + try: + data = redis.get(SESSION_KEY_PREFIX + session_token) + except Exception as exc: + raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}") + if data is None: + return None + return json.loads(data) + + async def require_session(self, session_token: str) -> dict: + session = await self.get_session(session_token) + if session is None: + raise HTTPException( + status_code=401, + detail="Stempel-Session abgelaufen. Bitte Personalnummer und PIN erneut eingeben.", + ) + return session + + async def invalidate_session(self, session_token: str) -> None: + redis = self._get_redis() + try: + redis.delete(SESSION_KEY_PREFIX + session_token) + except Exception: + pass # Best-effort + + +public_stamp_session_service = PublicStampSessionService() diff --git a/backend/migrations/versions/0033_public_stamp_token.py b/backend/migrations/versions/0033_public_stamp_token.py new file mode 100644 index 0000000..b1443c0 --- /dev/null +++ b/backend/migrations/versions/0033_public_stamp_token.py @@ -0,0 +1,38 @@ +"""public stamp token + opt-in flag for static QR stamping + +Revision ID: 0033 +Revises: 0032 +Create Date: 2026-06-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = '0033' +down_revision = '0032' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'companies', + sa.Column('public_stamp_enabled', sa.Boolean(), nullable=False, server_default=sa.false()), + ) + op.add_column( + 'companies', + sa.Column('public_stamp_token_hash', sa.String(length=64), nullable=True), + ) + op.add_column( + 'companies', + sa.Column('public_stamp_token_created_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_unique_constraint( + 'uq_companies_public_stamp_token_hash', 'companies', ['public_stamp_token_hash'] + ) + + +def downgrade(): + op.drop_constraint('uq_companies_public_stamp_token_hash', 'companies', type_='unique') + op.drop_column('companies', 'public_stamp_token_created_at') + op.drop_column('companies', 'public_stamp_token_hash') + op.drop_column('companies', 'public_stamp_enabled') diff --git a/backend/tests/test_public_stamp.py b/backend/tests/test_public_stamp.py new file mode 100644 index 0000000..3901ec2 --- /dev/null +++ b/backend/tests/test_public_stamp.py @@ -0,0 +1,168 @@ +"""Tests für das öffentliche QR-Stempeln (Personalnummer + PIN, statischer QR). + +Benötigt Redis (PIN-Lockout + Kurz-Session) – läuft auf dem Server. +""" +import pytest +import pytest_asyncio +from httpx import AsyncClient + + +async def _register(client: AsyncClient, company: str, email: str, pin: str | None, + personnel: str = "0001", enable: bool = True): + """Registriert Firma+Admin, setzt Personalnr/PIN, aktiviert QR-Stempeln, + rotiert das Token. Gibt dict mit headers/token/personnel/pin zurück.""" + reg = await client.post("/api/v1/auth/register", json={ + "company_name": company, + "first_name": "QR", + "last_name": "Admin", + "email": email, + "password": "Secret123", + }) + assert reg.status_code == 201, reg.text + headers = {"Authorization": f"Bearer {reg.json()['access_token']}"} + + me = await client.get("/api/v1/auth/me", headers=headers) + user_id = me.json()["id"] + + pr = await client.patch(f"/api/v1/users/{user_id}", + json={"personnel_number": personnel}, headers=headers) + assert pr.status_code == 200, pr.text + + if pin is not None: + pr = await client.post(f"/api/v1/users/{user_id}/kiosk-pin", + json={"pin": pin}, headers=headers) + assert pr.status_code == 200, pr.text + + upd = await client.patch("/api/v1/companies/me", + json={"public_stamp_enabled": enable}, headers=headers) + assert upd.status_code == 200, upd.text + + rot = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=headers) + assert rot.status_code == 200, rot.text + return {"headers": headers, "user_id": user_id, + "token": rot.json()["token"], "personnel": personnel, "pin": pin} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def ps(client: AsyncClient): + return await _register(client, "QR Stamp GmbH", "qr@stamp.de", "1234") + + +# ── Token-Auflösung ────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_company_info_resolves(client: AsyncClient, ps): + resp = await client.get(f"/api/v1/time/public/company?t={ps['token']}") + assert resp.status_code == 200 + body = resp.json() + assert body["company_name"] == "QR Stamp GmbH" + assert body["enabled"] is True + + +@pytest.mark.asyncio +async def test_company_info_unknown_token_404(client: AsyncClient): + resp = await client.get("/api/v1/time/public/company?t=totally-invalid-token-xyz") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_rotate_returns_url_once(client: AsyncClient, ps): + resp = await client.post("/api/v1/companies/me/public-stamp-token/rotate", headers=ps["headers"]) + assert resp.status_code == 200 + body = resp.json() + assert "/stamp?t=" in body["public_url"] + assert len(body["token"]) >= 32 + # neues Token → ps['token'] (altes) ungültig + old = await client.get(f"/api/v1/time/public/company?t={ps['token']}") + assert old.status_code == 404 + # ps-Fixture aktualisieren, damit Folge-Tests das gültige Token nutzen + ps["token"] = body["token"] + + +# ── Auth ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_auth_success(client: AsyncClient, ps): + resp = await client.post("/api/v1/time/public/auth", json={ + "token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"], + }) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["session_token"] + assert body["expires_in_seconds"] > 0 + assert body["open"] is False + + +@pytest.mark.asyncio +async def test_auth_wrong_pin_401(client: AsyncClient, ps): + resp = await client.post("/api/v1/time/public/auth", json={ + "token": ps["token"], "personnel_number": ps["personnel"], "pin": "9999", + }) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_auth_disabled_company_403(client: AsyncClient): + setup = await _register(client, "QR Disabled GmbH", "qr@disabled.de", "1234", + personnel="0002", enable=False) + resp = await client.post("/api/v1/time/public/auth", json={ + "token": setup["token"], "personnel_number": "0002", "pin": "1234", + }) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_auth_lockout_after_failures(client: AsyncClient): + """5 Fehlversuche (auch bei unbekannter Personalnr.) → 429 Lockout.""" + setup = await _register(client, "QR Lockout GmbH", "qr@lockout.de", "1234", + personnel="0003") + codes = [] + for _ in range(6): + r = await client.post("/api/v1/time/public/auth", json={ + "token": setup["token"], "personnel_number": "999999", "pin": "0000", + }) + codes.append(r.status_code) + assert 429 in codes # Sperre greift + + +# ── Stempel-Aktionen ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_stamp_in_and_out(client: AsyncClient, ps): + auth = await client.post("/api/v1/time/public/auth", json={ + "token": ps["token"], "personnel_number": ps["personnel"], "pin": ps["pin"], + }) + assert auth.status_code == 200, auth.text + session = auth.json()["session_token"] + already_open = auth.json()["open"] + + if not already_open: + sin = await client.post("/api/v1/time/public/action", + json={"session_token": session, "action": "in"}) + assert sin.status_code == 200, sin.text + assert sin.json()["open"] is True + + sout = await client.post("/api/v1/time/public/action", + json={"session_token": session, "action": "out"}) + assert sout.status_code == 200, sout.text + assert sout.json()["open"] is False + + +@pytest.mark.asyncio +async def test_action_invalid_session_401(client: AsyncClient): + resp = await client.post("/api/v1/time/public/action", json={ + "session_token": "00000000-0000-0000-0000-000000000000", "action": "in", + }) + assert resp.status_code == 401 + + +# ── Token-Delete ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_delete_token_invalidates(client: AsyncClient): + setup = await _register(client, "QR Delete GmbH", "qr@delete.de", "1234", + personnel="0004") + dl = await client.delete("/api/v1/companies/me/public-stamp-token", headers=setup["headers"]) + assert dl.status_code == 204 + resp = await client.get(f"/api/v1/time/public/company?t={setup['token']}") + assert resp.status_code == 404 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 16b5bde..4ee210b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { KioskDevicesPage } from './pages/KioskDevicesPage' import { AuditLogPage } from './pages/AuditLogPage' import { KioskSetupPage } from './pages/KioskSetupPage' import { KioskStampPage } from './pages/KioskStampPage' +import { PublicStampPage } from './pages/PublicStampPage' import { MobilePage } from './pages/mobile/MobilePage' import { MobileLoginPage } from './pages/mobile/MobileLoginPage' import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage' @@ -40,6 +41,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> }> diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index 001e9ed..e45f815 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import QRCode from 'qrcode' import { api } from '../api/client' import { Layout } from '../components/Layout' @@ -75,6 +76,13 @@ export function CompanySettingsPage() { const [blPlaintext, setBlPlaintext] = useState(null) const [blBusy, setBlBusy] = useState(false) const [blCopied, setBlCopied] = useState(false) + // Öffentliches QR-Stempeln + const [psEnabled, setPsEnabled] = useState(false) + const [psStatus, setPsStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) + const [psUrl, setPsUrl] = useState(null) + const [psQr, setPsQr] = useState(null) + const [psBusy, setPsBusy] = useState(false) + const [psCopied, setPsCopied] = useState(false) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [error, setError] = useState(null) @@ -110,8 +118,68 @@ export function CompanySettingsPage() { api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) .catch(() => {}) + api.get<{ enabled: boolean; configured: boolean; created_at: string | null }>('/companies/me/public-stamp-token') + .then(s => { setPsStatus({ configured: s.configured, created_at: s.created_at }); setPsEnabled(s.enabled) }) + .catch(() => {}) }, []) + async function rotatePublicStampToken() { + setPsBusy(true) + setError(null) + try { + const res = await api.post<{ token: string; public_url: string; created_at: string }>( + '/companies/me/public-stamp-token/rotate', {} + ) + setPsUrl(res.public_url) + setPsStatus({ configured: true, created_at: res.created_at }) + setPsCopied(false) + setPsQr(await QRCode.toDataURL(res.public_url, { width: 320, margin: 2, errorCorrectionLevel: 'H' })) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Generieren des QR-Tokens') + } finally { + setPsBusy(false) + } + } + + async function revokePublicStampToken() { + if (!confirm('QR-Code wirklich deaktivieren? Bestehende ausgedruckte Codes funktionieren danach nicht mehr.')) return + setPsBusy(true) + setError(null) + try { + await api.del('/companies/me/public-stamp-token') + setPsStatus({ configured: false, created_at: null }) + setPsUrl(null) + setPsQr(null) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren') + } finally { + setPsBusy(false) + } + } + + async function copyPublicStampUrl() { + if (!psUrl) return + try { + await navigator.clipboard.writeText(psUrl) + setPsCopied(true) + setTimeout(() => setPsCopied(false), 2000) + } catch { /* clipboard nicht verfügbar */ } + } + + function printPublicStampQr() { + if (!psQr) return + const w = window.open('', '_blank', 'width=600,height=800') + if (!w) return + w.document.write(`QR-Stempel + +

Zeiterfassung – ${name || company?.name || ''}

+ QR +

Mit dem Handy scannen, dann Personalnummer + PIN eingeben und ein-/ausstempeln.

+ `) + w.document.close() + } + async function rotateBusylightToken() { setBlBusy(true) setError(null) @@ -179,6 +247,7 @@ export function CompanySettingsPage() { kiosk_require_approval: kioskRequireApproval, kiosk_track_current_user: kioskTrackCurrentUser, kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec, + public_stamp_enabled: psEnabled, overtime_cap_hours: overtimeCapEnabled ? overtimeCapHours : null, overtime_expiry_enabled: overtimeExpiryEnabled, overtime_expiry_month: overtimeExpiryMonth, @@ -572,6 +641,104 @@ export function CompanySettingsPage() { )} + {/* Öffentliches QR-Stempeln */} + {isAdmin && ( +
+
+ 📲 +

QR-Stempeln (Handy)

+
+

+ Statischer QR-Code zum Aushängen am Eingang. Mitarbeiter scannen mit dem privaten Handy, + melden sich per Personalnummer + PIN an und stempeln ein/aus. +

+ +
+ ⚠ Schwächer als Kiosk-Terminals: keine Geräte-Signatur. Schutz nur über PIN + Sperre nach + Fehlversuchen. Nur aktivieren, wenn benötigt. +
+ + {/* Toggle aktiv */} +
+
+

QR-Stempeln aktivieren

+

Speichern nicht vergessen.

+
+ +
+ +
+ {psStatus?.configured ? ( +
+ + QR-Code aktiv + {psStatus.created_at && ( + (seit {new Date(psStatus.created_at).toLocaleString('de-DE')}) + )} + +
+ ) : ( +
+ + Kein QR-Code generiert +
+ )} +
+ + {psUrl && ( +
+

+ ⚠ QR-Code jetzt drucken/sichern – die URL wird nur einmal angezeigt. +

+ {psQr && ( +
+ QR-Code +
+ )} +
+ e.currentTarget.select()} + className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" /> + +
+ +
+ )} + +
+ + {psStatus?.configured && ( + + )} + {psStatus?.configured && ( + Beim Neugenerieren wird der bisherige QR-Code ungültig. + )} +
+
+ )} + {/* Mobile-Einstellungen */}
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index f6362bb..c707989 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -178,6 +178,12 @@ export function ProfilePage() { const [saving, setSaving] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState(null) + // Stempel-PIN (Kiosk / öffentliches QR-Stempeln) + const [pin, setPin] = useState('') + const [pinConfirm, setPinConfirm] = useState('') + const [pinSaving, setPinSaving] = useState(false) + const [pinSuccess, setPinSuccess] = useState(false) + const [pinError, setPinError] = useState(null) const loadMe = () => { api.get('/auth/me').then(u => { @@ -204,6 +210,23 @@ export function ProfilePage() { } } + async function savePin(e: React.FormEvent) { + e.preventDefault() + if (!me) return + if (pin !== pinConfirm) { setPinError('PINs stimmen nicht überein'); return } + if (!/^\d{4,6}$/.test(pin)) { setPinError('PIN muss 4 bis 6 Ziffern haben'); return } + setPinSaving(true); setPinError(null); setPinSuccess(false) + try { + await api.post(`/users/${me.id}/kiosk-pin`, { pin }) + setPinSuccess(true) + setPin(''); setPinConfirm('') + } catch (e: unknown) { + setPinError(e instanceof Error ? e.message : 'Fehler beim Setzen der PIN') + } finally { + setPinSaving(false) + } + } + return (
@@ -242,6 +265,38 @@ export function ProfilePage() {
+ {/* Stempel-PIN */} +
+
+

Stempel-PIN

+

+ 4–6 Ziffern · für Kiosk-Terminals und das mobile QR-Stempeln (Personalnummer + PIN). +

+
+
+
+ + setPin(e.target.value.replace(/\D/g, '').slice(0, 6))} + autoComplete='off' + className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500' /> +
+
+ + setPinConfirm(e.target.value.replace(/\D/g, '').slice(0, 6))} + autoComplete='off' + className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500' /> +
+ {pinError &&

{pinError}

} + {pinSuccess &&

PIN erfolgreich gesetzt

} + +
+
+ {/* Passwort ändern */}

Passwort ändern

diff --git a/frontend/src/pages/PublicStampPage.tsx b/frontend/src/pages/PublicStampPage.tsx new file mode 100644 index 0000000..0a3f0c9 --- /dev/null +++ b/frontend/src/pages/PublicStampPage.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +const BASE = '/api/v1' + +// Öffentliche Endpunkte: KEIN Bearer-Token, daher nicht der api-Client (der +// hängt Authorization an und triggert Token-Refresh). Schlankes fetch. +async function publicPost(path: string, body: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + const e = new Error(typeof err.detail === 'string' ? err.detail : res.statusText) + ;(e as Error & { status?: number }).status = res.status + throw e + } + return res.json() +} + +interface TimeEntry { id: string; start_time: string; end_time: string | null } +interface StampStatus { open: boolean; on_break: boolean; today: TimeEntry[] } +interface AuthResponse extends StampStatus { session_token: string; user_name: string; expires_in_seconds: number } +interface ActionResponse extends StampStatus { warnings: string[] } + +function fmtTime(iso: string | null): string { + if (!iso) return '–' + return iso.slice(0, 5) +} + +export function PublicStampPage() { + const [params] = useSearchParams() + const token = params.get('t') ?? '' + + const [companyName, setCompanyName] = useState(null) + const [enabled, setEnabled] = useState(true) + const [loadingCompany, setLoadingCompany] = useState(true) + + const [personnelNumber, setPersonnelNumber] = useState('') + const [pin, setPin] = useState('') + + const [sessionToken, setSessionToken] = useState(null) + const [userName, setUserName] = useState('') + const [status, setStatus] = useState(null) + + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + const [info, setInfo] = useState(null) + + useEffect(() => { + if (!token) { setLoadingCompany(false); return } + fetch(`${BASE}/time/public/company?t=${encodeURIComponent(token)}`) + .then(r => r.ok ? r.json() : Promise.reject(new Error('ungültig'))) + .then((c: { company_name: string; enabled: boolean }) => { + setCompanyName(c.company_name) + setEnabled(c.enabled) + }) + .catch(() => setCompanyName(null)) + .finally(() => setLoadingCompany(false)) + }, [token]) + + const resetToLogin = useCallback((msg: string) => { + setSessionToken(null) + setStatus(null) + setPin('') + setInfo(msg) + }, []) + + async function authenticate(e: React.FormEvent) { + e.preventDefault() + setBusy(true); setError(null); setWarnings([]); setInfo(null) + try { + const res = await publicPost('/time/public/auth', { + token, personnel_number: personnelNumber, pin, + }) + setSessionToken(res.session_token) + setUserName(res.user_name) + setStatus({ open: res.open, on_break: res.on_break, today: res.today }) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen') + } finally { + setBusy(false) + } + } + + async function doAction(action: 'in' | 'out' | 'break_start' | 'break_end') { + if (!sessionToken) return + setBusy(true); setError(null); setWarnings([]) + try { + const res = await publicPost('/time/public/action', { + session_token: sessionToken, action, + }) + setStatus({ open: res.open, on_break: res.on_break, today: res.today }) + if (res.warnings.length) setWarnings(res.warnings) + } catch (err: unknown) { + const status = (err as Error & { status?: number }).status + if (status === 401) { + resetToLogin('Sitzung abgelaufen. Bitte erneut anmelden.') + } else { + setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen') + } + } finally { + setBusy(false) + } + } + + // ── Render-Zustände ───────────────────────────────────────────────────────── + + if (loadingCompany) { + return ( + +
+
+

Wird geladen…

+
+ + ) + } + + if (!token || companyName === null) { + return ( + +
+

🚫

+

QR-Code ungültig

+

Dieser QR-Code ist nicht (mehr) gültig. Bitte an die Verwaltung wenden.

+
+
+ ) + } + + if (!enabled) { + return ( + +
+

🔒

+

QR-Stempeln deaktiviert

+

Das mobile Stempeln per QR ist für dieses Unternehmen derzeit nicht aktiviert.

+
+
+ ) + } + + // Angemeldet → Stempel-Ansicht + if (sessionToken && status) { + const isOpen = status.open + const onBreak = status.on_break + return ( + +
+

Angemeldet als

+

{userName}

+ + {warnings.length > 0 && ( +
+
    + {warnings.map((w, i) =>
  • {w}
  • )} +
+
+ )} + {error &&
{error}
} + +
+ + {onBreak ? 'In Pause' : isOpen ? 'Eingestempelt' : 'Nicht eingestempelt'} +
+ + {!isOpen ? ( + + ) : onBreak ? ( + + ) : ( + <> + + + + )} + + {status.today.length > 0 && ( +
+

Heute

+
    + {status.today.map(e => ( +
  • + {fmtTime(e.start_time)} – {fmtTime(e.end_time)} +
  • + ))} +
+
+ )} + + +
+
+ ) + } + + // PIN-Anmeldung + return ( + +
+ {info &&
{info}
} + {error &&
{error}
} + +
+ + setPersonnelNumber(e.target.value.replace(/\D/g, ''))} + className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' + required + /> +
+
+ + setPin(e.target.value.replace(/\D/g, '').slice(0, 6))} + className='w-full text-center text-2xl tracking-widest font-mono border border-gray-300 rounded-xl px-3 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500' + required + /> +

PIN im Mitarbeiter-Portal unter „Mein Profil“ setzen/ändern.

+
+ +
+
+ ) +} + +function Shell({ company, children }: { company?: string; children: React.ReactNode }) { + return ( +
+
+
+

Zeiterfassung

+

{company ?? 'Stempeln'}

+
+ {children} +
+
+ ) +}