Files
timemaster/backend/app/routers/users.py
T
patrickandClaude Sonnet 5 3650da8022 fix(kiosk): NFC-Login verlangt Pflicht-PIN als Zweitfaktor (Security-Audit K-3)
Bisher genügte die reine NFC-UID zum Einstempeln. Getestete Reader-Hardware
(günstiger USB-HID-RFID-Leser, EM4100 125kHz) liefert nur eine unverschlüsselte,
trivial klonbare Chip-ID - identisch zum in security_audit_kiosk_qr_nfc_2026_05_26
(K-3) beschriebenen Risiko, das bisher offen war.

- login_nfc() verlangt jetzt PIN, nutzt denselben Brute-Force-Lockout wie
  login_pin (keyed auf nfc_uid statt Personalnummer)
- Neuer Endpunkt POST /users/{id}/kiosk-nfc (Admin/HR) zum Zuordnen einer
  Karte zu einem Mitarbeiter - existierte bisher gar nicht, kiosk_nfc_uid
  war nur im Model vorhanden, nirgends setzbar
- Company-interner Unique-Check (eine Karte = ein Mitarbeiter)

Kein bestehendes Frontend nutzt NFC-Login bisher, daher kein Breaking Change.
Höhere Sicherheitsstufe (NTAG424 SUN, klon-resistent) bleibt vorgemerkt für
späteren Hardware-Wechsel (aktueller Reader kann keine Kryptografie).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
2026-08-27 13:24:19 +02:00

303 lines
11 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.
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import PlainTextResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.models.user import User, UserRole
from app.schemas.auth import MessageResponse
from app.schemas.user import (
InviteRequest,
NextPersonnelNumberResponse,
NotificationPrefsUpdate,
NotificationTypeOut,
SetKioskNfcUidRequest,
SetKioskPinRequest,
UserImportResult,
UserImportRowResult,
UserListResponse,
UserOut,
UserUpdate,
)
from app.services import user_import_service
from app.services.gdpr_service import gdpr_service
from app.services.user_service import user_service
router = APIRouter(prefix="/users", tags=["Users"])
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
@router.get("/", response_model=UserListResponse)
async def list_users(
current_user: User = require_role(*_hr_roles),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
active_only: bool = Query(True),
search: str | None = Query(None, max_length=100),
db: AsyncSession = Depends(get_db),
):
total, users = await user_service.list_users(
current_user.company_id, db, skip, limit, active_only, search,
)
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
@router.post("/invite", response_model=UserOut, status_code=201)
async def invite_user(
data: InviteRequest,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.invite(data, current_user.company_id, current_user, db)
return UserOut.model_validate(user)
@router.get("/colleagues")
async def list_colleagues(
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Schlanke Kollegenliste (id + Name) für alle Mitarbeiter z.B. zur
Vertreter-Auswahl. Nur aktive User der eigenen Firma (RLS-gefenced),
ohne sensible Felder."""
from sqlalchemy import select
rows = await db.scalars(
select(User)
.where(
User.company_id == current_user.company_id,
User.is_active.is_(True),
User.id != current_user.id,
)
.order_by(User.last_name, User.first_name)
)
return [{"id": str(u.id), "full_name": u.full_name} for u in rows.all()]
@router.get("/me", response_model=UserOut)
async def get_me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.get("/me/notification-prefs", response_model=list[NotificationTypeOut])
async def get_notification_prefs(current_user: CurrentUser):
from app.core.notifications import NOTIFICATION_TYPES, pref_enabled
return [
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
for k, (label, _default) in NOTIFICATION_TYPES.items()
]
@router.patch("/me/notification-prefs", response_model=list[NotificationTypeOut])
async def update_notification_prefs(
data: NotificationPrefsUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
from app.core.notifications import NOTIFICATION_TYPES, normalize_prefs, pref_enabled
merged = dict(current_user.notification_prefs or {})
merged.update(normalize_prefs(data.prefs))
current_user.notification_prefs = merged # neues dict → JSONB-Änderung erkannt
await db.commit()
return [
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
for k, (label, _default) in NOTIFICATION_TYPES.items()
]
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
async def next_personnel_number(
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
return NextPersonnelNumberResponse(next=suggestion)
@router.get("/by-personnel/{number}", response_model=UserOut)
async def get_user_by_personnel(
number: str,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
return UserOut.model_validate(user)
@router.get("/import-template.csv", response_class=PlainTextResponse)
async def import_template(
current_user: User = require_role(*_admin_roles),
):
csv_text = user_import_service.build_template_csv()
return PlainTextResponse(
content=csv_text,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
)
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
async def _read_upload(file: UploadFile) -> bytes:
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
content = await file.read(_MAX_UPLOAD_BYTES + 1)
if len(content) > _MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
)
return content
@router.post("/import/preview", response_model=UserImportResult)
async def user_import_preview(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await _read_upload(file)
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
@router.post("/import/apply", response_model=UserImportResult)
async def user_import_apply(
file: Annotated[UploadFile, File()],
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
content = await _read_upload(file)
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
return _to_import_result_schema(result)
def _to_import_result_schema(result) -> UserImportResult:
return UserImportResult(
total_rows=result.total_rows,
created=result.created,
reactivated=result.reactivated,
errors=result.errors,
items=[
UserImportRowResult(
row=i.row, email=i.email, personnel_number=i.personnel_number,
action=i.action, message=i.message,
)
for i in result.items
],
)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: UUID,
current_user: User = require_role(*_hr_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.get_by_id(user_id, current_user.company_id, db)
return UserOut.model_validate(user)
@router.patch("/{user_id}", response_model=UserOut)
async def update_user(
user_id: UUID,
data: UserUpdate,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.update(user_id, data, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/deactivate", response_model=UserOut)
async def deactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.deactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/reactivate", response_model=UserOut)
async def reactivate_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
user = await user_service.reactivate(user_id, current_user, db)
return UserOut.model_validate(user)
@router.post("/{user_id}/anonymize", response_model=UserOut)
async def anonymize_user(
user_id: UUID,
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""DSGVO Art. 17: Personenbezug des Kontos entfernen (nicht umkehrbar).
Referenzierte Daten (Zeit/Abwesenheit/Auszahlung) bleiben entkoppelt erhalten."""
user = await user_service.anonymize_user(user_id, current_user, db)
return UserOut.model_validate(user)
@router.get("/{user_id}/export-data")
async def export_user_data(
user_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""DSGVO Art. 15 (Auskunftsrecht): strukturierter Export aller
personenbezogenen Daten des Users als JSON. Eigenes Konto immer erlaubt,
fremde Konten nur für HR/Admin/Manager der eigenen Firma."""
if user_id != current_user.id and current_user.role not in _hr_roles:
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Konto")
try:
data = await gdpr_service.export_user_data(user_id, current_user.company_id, db)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
content = gdpr_service.to_json(data)
return Response(
content=content, media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=datenauskunft_{user_id}.json"},
)
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
async def set_kiosk_pin(
user_id: UUID,
data: SetKioskPinRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
# Users can set their own PIN; admins can set for any user in company
if user_id != current_user.id and not current_user.is_admin_or_above():
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Not allowed")
user = await user_service.get_by_id(user_id, current_user.company_id, db)
await user_service.set_kiosk_pin(user, data.pin, db)
return MessageResponse(message="Kiosk PIN updated")
@router.post("/{user_id}/kiosk-nfc", response_model=MessageResponse)
async def set_kiosk_nfc(
user_id: UUID,
data: SetKioskNfcUidRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""NFC-Karte einem Mitarbeiter zuordnen. Nur Admin/HR - die Karte allein ist
kein Geheimnis (klonbare UID), daher darf sie nicht selbst zugewiesen werden."""
if not current_user.is_admin_or_above():
raise HTTPException(status_code=403, detail="Not allowed")
user = await user_service.get_by_id(user_id, current_user.company_id, db)
await user_service.set_kiosk_nfc_uid(user, data.nfc_uid, db)
return MessageResponse(message="Kiosk NFC-UID updated")