Files
timemaster/backend/app/services/user_service.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

417 lines
17 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 datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
generate_invite_token,
hash_password,
hash_token,
verify_password,
)
from app.models import User, UserRole
from app.models.audit_log import AuditLog
from app.models.company import Company, PersonnelNumberMode
from app.schemas.user import InviteAccept, InviteRequest, UserUpdate
from app.services.email_service import email_service
PERSONNEL_NUMBER_MIN_DIGITS = 4
class UserService:
# ── Personalnummer-Helpers ────────────────────────────────────────────────
async def _get_company(self, company_id: UUID, db: AsyncSession) -> Company:
company = await db.get(Company, company_id)
if not company:
raise HTTPException(status_code=404, detail="Company not found")
return company
@staticmethod
def _format_personnel_number(value: int) -> str:
return str(value).zfill(PERSONNEL_NUMBER_MIN_DIGITS)
async def _next_personnel_number(self, company_id: UUID, db: AsyncSession) -> str:
"""Atomic increment + return next personnel number for the company.
Uses UPDATE ... RETURNING to avoid race conditions with parallel inserts.
Skips numbers that are already taken (e.g. manual override) by retrying.
"""
for _ in range(50): # safety bound, in practice 1-2 iterations max
result = await db.execute(
text(
"UPDATE companies "
"SET personnel_number_next = personnel_number_next + 1 "
"WHERE id = :cid "
"RETURNING personnel_number_next - 1 AS used"
),
{"cid": company_id},
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail="Company not found")
candidate = self._format_personnel_number(int(row.used))
existing = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if existing is None:
return candidate
raise HTTPException(status_code=500, detail="Could not allocate personnel number")
async def next_personnel_suggestion(self, company_id: UUID, db: AsyncSession) -> str:
"""Preview next personnel number without consuming the counter."""
company = await self._get_company(company_id, db)
candidate_int = company.personnel_number_next
while True:
candidate = self._format_personnel_number(candidate_int)
taken = await db.scalar(
select(User.id).where(
User.company_id == company_id,
User.personnel_number == candidate,
)
)
if taken is None:
return candidate
candidate_int += 1
async def _check_personnel_unique(
self,
company_id: UUID,
number: str,
db: AsyncSession,
exclude_user_id: UUID | None = None,
) -> None:
"""Raise 409 if personnel number is already taken (incl. deactivated/reserved)."""
q = select(User.id).where(
User.company_id == company_id,
User.personnel_number == number,
)
if exclude_user_id is not None:
q = q.where(User.id != exclude_user_id)
existing = await db.scalar(q)
if existing is not None:
raise HTTPException(
status_code=409,
detail=f"Personalnummer '{number}' ist bereits vergeben (auch reservierte Nummern bleiben belegt).",
)
async def get_by_personnel_number(
self, number: str, company_id: UUID, db: AsyncSession
) -> User:
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == number,
)
)
if user is None:
raise HTTPException(status_code=404, detail="Personalnummer nicht gefunden")
return user
# ── Invite ────────────────────────────────────────────────────────────────
async def invite(
self,
data: InviteRequest,
company_id: UUID,
invited_by: User,
db: AsyncSession,
) -> User:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
company = await self._get_company(company_id, db)
personnel_number = data.personnel_number
if personnel_number:
await self._check_personnel_unique(company_id, personnel_number, db)
else:
if company.personnel_number_mode == PersonnelNumberMode.AUTO.value:
personnel_number = await self._next_personnel_number(company_id, db)
elif company.personnel_number_required:
raise HTTPException(
status_code=400,
detail="Personalnummer ist in dieser Firma Pflicht.",
)
if data.initial_password:
# Direktanlage mit Passwort sofort aktiv, kein E-Mail-Invite
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(data.initial_password),
is_active=True,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_created",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value, "direct": True},
))
else:
raw_token, token_hash = generate_invite_token()
user = User(
company_id=company_id,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=data.role,
department_id=data.department_id,
personnel_number=personnel_number,
password_hash=hash_password(raw_token), # Temp overwritten on accept
invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False,
)
db.add(user)
await db.flush()
db.add(AuditLog(
company_id=company_id,
user_id=invited_by.id,
action="user_invited",
entity_type="user",
entity_id=user.id,
new_value={"email": user.email, "role": user.role.value},
))
await email_service.send_invite(user, invited_by, raw_token, db)
return user
async def accept_invite(self, data: InviteAccept, db: AsyncSession) -> User:
token_hash = hash_token(data.token)
user = await db.scalar(
select(User).where(User.invite_token_hash == token_hash)
)
if not user:
raise HTTPException(status_code=400, detail="Invalid invite token")
if user.invite_expires and user.invite_expires < datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="Invite token expired")
user.password_hash = hash_password(data.password)
user.invite_token_hash = None
user.invite_expires = None
user.is_active = True
return user
# ── Listing ───────────────────────────────────────────────────────────────
async def list_users(
self,
company_id: UUID,
db: AsyncSession,
skip: int = 0,
limit: int = 50,
active_only: bool = True,
search: str | None = None,
) -> tuple[int, list[User]]:
q = select(User).where(User.company_id == company_id)
if active_only:
q = q.where(User.is_active == True)
if search:
pattern = f"%{search.strip()}%"
q = q.where(
or_(
User.email.ilike(pattern),
User.first_name.ilike(pattern),
User.last_name.ilike(pattern),
User.personnel_number.ilike(pattern),
)
)
total = await db.scalar(select(func.count()).select_from(q.subquery()))
users = await db.scalars(q.offset(skip).limit(limit))
return total, list(users.all())
async def get_by_id(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> User:
user = await db.get(User, user_id)
if not user or user.company_id != company_id:
raise HTTPException(status_code=404, detail="User not found")
return user
# ── Update / De/Reactivate ────────────────────────────────────────────────
async def update(
self,
user_id: UUID,
data: UserUpdate,
current_user: User,
db: AsyncSession,
) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
changes = data.model_dump(exclude_unset=True)
old_personnel = user.personnel_number
if "personnel_number" in changes:
new_value = changes["personnel_number"]
if new_value:
await self._check_personnel_unique(
current_user.company_id, new_value, db, exclude_user_id=user.id
)
elif user.personnel_number is not None:
# Explizites Löschen erlauben? Plan sagt Reservierung wir verbieten Clear.
raise HTTPException(
status_code=400,
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
)
# Rolle-Änderung nur mit expliziter Berechtigung (Fix K-1: Privilege Escalation)
if "role" in changes and changes["role"] != user.role:
new_role = changes["role"]
# SUPER_ADMIN-Zuteilung: nur SUPER_ADMIN selbst darf das
if new_role == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=403,
detail="Nur SUPER_ADMIN darf die Rolle SUPER_ADMIN vergeben",
)
# COMPANY_ADMIN darf nur Rollen <= COMPANY_ADMIN vergeben (nicht SUPER_ADMIN)
allowed_roles_by_admin = {
UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN
}
if current_user.role == UserRole.COMPANY_ADMIN and new_role not in allowed_roles_by_admin:
raise HTTPException(status_code=403, detail="Ungültige Rollenzuteilung")
# Letzten COMPANY_ADMIN nicht demoten
if user.role == UserRole.COMPANY_ADMIN and new_role != UserRole.COMPANY_ADMIN:
from sqlalchemy import select, func
count_result = await db.execute(
select(func.count()).where(
User.company_id == user.company_id,
User.role == UserRole.COMPANY_ADMIN,
User.is_active == True,
User.id != user.id,
)
)
if count_result.scalar() == 0:
raise HTTPException(
status_code=400,
detail="Kann letzten COMPANY_ADMIN nicht downgraden",
)
# AuditLog für Rollen-Änderung
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="role_changed",
entity_type="user",
entity_id=user.id,
old_value={"role": user.role.value if hasattr(user.role, "value") else str(user.role)},
new_value={"role": new_role.value if hasattr(new_role, "value") else str(new_role)},
))
for field, value in changes.items():
setattr(user, field, value)
if "personnel_number" in changes and changes["personnel_number"] != old_personnel:
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_personnel_number_changed",
entity_type="user",
entity_id=user.id,
old_value={"personnel_number": old_personnel},
new_value={"personnel_number": user.personnel_number},
))
return user
async def deactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
user = await self.get_by_id(user_id, current_user.company_id, db)
user.is_active = False
return user
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
user = await self.get_by_id(user_id, current_user.company_id, db)
if user.anonymized_at is not None:
raise HTTPException(status_code=409, detail="Anonymisierte Konten können nicht reaktiviert werden.")
user.is_active = True
return user
async def anonymize_user(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
"""DSGVO Art. 17: Personenbezug eines Users entfernen (statt Hard-Delete).
Die User-Zeile + alle Referenzen (Zeit, Abwesenheit, Auszahlungen, AuditLog)
bleiben für die gesetzliche Aufbewahrung erhalten, verlieren aber den Personenbezug.
Personalnummer bleibt reserviert. Nicht umkehrbar.
"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Das eigene Konto kann nicht anonymisiert werden.")
user = await self.get_by_id(user_id, current_user.company_id, db)
if user.anonymized_at is not None:
raise HTTPException(status_code=409, detail="Konto ist bereits anonymisiert.")
old = {"first_name": user.first_name, "last_name": user.last_name, "email": user.email}
# Personenbezogene Felder überschreiben / Auth-Material entfernen
user.first_name = "Anonymisiert"
user.last_name = str(user.id)[:8]
user.email = f"anon-{user.id}@deleted.local"
user.password_hash = None
user.kiosk_pin_hash = None
user.kiosk_qr_token = None
user.kiosk_nfc_uid = None
user.totp_secret = None
user.totp_enabled = False
user.ldap_dn = None
user.kuerzel = None
user.invite_token_hash = None
user.is_active = False
user.anonymized_at = datetime.now(timezone.utc)
# personnel_number bleibt erhalten (reserviert, kein Personenbezug mehr)
# Aktive Sessions des Users beenden (Refresh-Tokens entwerten)
await db.execute(text("DELETE FROM sessions WHERE user_id = :uid"), {"uid": str(user.id)})
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="user_anonymized",
entity_type="user",
entity_id=user.id,
old_value=old,
new_value={"anonymized_at": user.anonymized_at.isoformat(), "target_user_id": str(user.id)},
))
return user
# ── Kiosk ─────────────────────────────────────────────────────────────────
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
user.kiosk_pin_hash = hash_password(pin)
async def verify_kiosk_pin(self, user: User, pin: str) -> bool:
if not user.kiosk_pin_hash:
return False
return verify_password(pin, user.kiosk_pin_hash)
async def set_kiosk_nfc_uid(self, user: User, nfc_uid: str | None, db: AsyncSession) -> None:
if nfc_uid:
existing = await db.scalar(
select(User).where(
User.company_id == user.company_id,
User.kiosk_nfc_uid == nfc_uid,
User.id != user.id,
)
)
if existing is not None:
raise HTTPException(
status_code=409,
detail="Diese NFC-Karte ist bereits einem anderen Mitarbeiter zugeordnet.",
)
user.kiosk_nfc_uid = nfc_uid
user_service = UserService()