"""Ö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), )) # Status vor dem Commit ermitteln (siehe public_action: commit() setzt # SET LOCAL app.bypass_rls zurück → RLS würde danach alle Zeilen filtern). status = await _status(user, db) await db.commit() 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), )) # Status VOR dem Commit ermitteln: ein zwischenzeitliches commit() beendet die # Transaktion und setzt `SET LOCAL app.bypass_rls` zurück – danach würde RLS # (mangels app.company_id auf dieser öffentlichen Route) alle Zeilen filtern. status = await _status(user, db) await db.commit() return PublicStampActionResponse(warnings=warnings, **status)