feat: Statischer firmenweiter QR-Code für mobiles Ein-/Ausstempeln

Mitarbeiter scannen einen am Eingang ausgehängten QR-Code mit dem Privat-Handy
(/stamp?t=<token>), melden sich per Personalnummer + PIN an und stempeln ein/aus.

Eigener öffentlicher Endpunkt-Pfad, da der Kiosk-PIN-Login Ed25519-Geräte-
Signaturen verlangt, die ein Privat-Handy nicht hat.

Backend:
- Company.public_stamp_enabled (opt-in, default OFF) + rotierbares
  public_stamp_token_hash (SHA-256) + created_at; Migration 0033
- Router /time/public: company/auth/action (slowapi-Limits, AuditLog)
- kiosk_auth_service.login_pin_public() reused PIN-Lockout, keyed auf
  (public:company_id, personnel_number)
- public_stamp_session_service: 120s Redis-Kurz-Session
- Admin-Token-Endpunkte in companies.py (GET/rotate/DELETE)

Frontend:
- Public-Route /stamp (PublicStampPage)
- Stempel-PIN-Verwaltung in ProfilePage (reused POST /users/{id}/kiosk-pin)
- QR-Generierung/Druck/Toggle in CompanySettingsPage

Sicherheit: schwächer als Kiosk (keine Geräte-Signatur/Nonce/IP-Whitelist),
bewusster BYOD-Komfort-Tradeoff; Schutz über PIN + Lockout + opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 15:58:38 +02:00
parent 03d5fd6e2e
commit cead46c1e1
14 changed files with 1130 additions and 2 deletions
+155
View File
@@ -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)