Files
timemaster/backend/app/routers/public_stamp.py
T
patrick cead46c1e1 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>
2026-06-02 15:58:38 +02:00

156 lines
5.6 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.
"""Ö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)