548aebe994
public_action/auth riefen db.commit() vor _status() auf. SET LOCAL app.bypass_rls gilt nur pro Transaktion; nach dem Commit filterte RLS (mangels app.company_id auf der öffentlichen Route) alle Zeilen weg, sodass open immer False und today leer war. Status jetzt vor dem Commit ermitteln. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
6.0 KiB
Python
163 lines
6.0 KiB
Python
"""Ö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)
|