06bb1c1664
FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal
Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert
Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host
Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)
Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv
Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA
Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog
Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout
Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed
Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy
Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role
Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
353 lines
14 KiB
Python
353 lines
14 KiB
Python
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)
|
||
user.is_active = True
|
||
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)
|
||
|
||
|
||
user_service = UserService()
|