feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)

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>
This commit is contained in:
2026-05-26 11:13:42 +02:00
parent c9cb6d7459
commit 06bb1c1664
19 changed files with 693 additions and 109 deletions
+22 -9
View File
@@ -205,6 +205,7 @@ class AbsenceService:
half_day_start=data.half_day_start,
half_day_end=data.half_day_end,
working_days=working_days,
fza_hours=data.fza_hours if hasattr(data, "fza_hours") else None,
status=status,
approved_by=approved_by,
substitute_id=data.substitute_id,
@@ -314,7 +315,9 @@ class AbsenceService:
# Überstunden zurückbuchen wenn Freizeitausgleich
absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type and absence_type.affects_overtime_balance:
await self._refund_overtime(absence.user_id, absence.working_days, db)
await self._refund_overtime(
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
)
elif absence.status != AbsenceStatus.PENDING:
raise HTTPException(
status_code=409,
@@ -380,7 +383,9 @@ class AbsenceService:
# Überstundenkonto abziehen wenn Freizeitausgleich
fza_warnings: list[str] = []
if absence_type and absence_type.affects_overtime_balance:
fza_warnings = await self._deduct_overtime(absence.user_id, absence.working_days, db)
fza_warnings = await self._deduct_overtime(
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
)
# Audit-Log (DSGVO)
db.add(AuditLog(
@@ -601,13 +606,17 @@ class AbsenceService:
return daily_hours
async def _deduct_overtime(
self, user_id: UUID, working_days: float, db: AsyncSession
self, user_id: UUID, working_days: float, db: AsyncSession,
fza_hours: "Decimal | None" = None,
) -> list[str]:
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab.
"""Zieht working_days × tägliche Stunden (oder direkt fza_hours) vom Überstundenkonto ab.
Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
user = await db.get(User, user_id)
daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_deduct = Decimal(str(working_days)) * daily_hours
if fza_hours is not None:
hours_to_deduct = Decimal(str(fza_hours))
else:
daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_deduct = Decimal(str(working_days)) * daily_hours
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is None:
@@ -647,11 +656,15 @@ class AbsenceService:
return warnings
async def _refund_overtime(
self, user_id: UUID, working_days: float, db: AsyncSession
self, user_id: UUID, working_days: float, db: AsyncSession,
fza_hours: "Decimal | None" = None,
) -> None:
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_refund = Decimal(str(working_days)) * daily_hours
if fza_hours is not None:
hours_to_refund = Decimal(str(fza_hours))
else:
daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_refund = Decimal(str(working_days)) * daily_hours
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
if ob is not None:
+95 -18
View File
@@ -20,6 +20,10 @@ from app.models import Company, PasswordReset, Session, User, UserRole
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.services.email_service import email_service
# Login-Lockout-Konfiguration
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
def _get_client_ip(request: "Request | None") -> str | None:
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy)."""
@@ -39,6 +43,32 @@ def _slugify(name: str) -> str:
class AuthService:
async def _check_login_lockout(self, email: str, redis) -> None:
"""Wirft HTTP 429 wenn Account wegen zu vieler Fehlversuche gesperrt ist."""
lockout_key = f"login_lockout:{email.lower()}"
if await redis.exists(lockout_key):
ttl = await redis.ttl(lockout_key)
wait_min = ttl // 60 + 1
raise HTTPException(
status_code=429,
detail=f"Account temporär gesperrt. Bitte {wait_min} Minute(n) warten.",
)
async def _record_login_failure(self, email: str, redis) -> None:
"""Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen."""
fail_key = f"login_fails:{email.lower()}"
lockout_key = f"login_lockout:{email.lower()}"
fails = await redis.incr(fail_key)
await redis.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC)
if fails >= FAILED_LOGIN_MAX:
await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC)
await redis.delete(fail_key)
async def _clear_login_failures(self, email: str, redis) -> None:
"""Löscht Fehlversuche nach erfolgreichem Login."""
await redis.delete(f"login_fails:{email.lower()}")
await redis.delete(f"login_lockout:{email.lower()}")
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email))
if existing:
@@ -74,42 +104,89 @@ class AuthService:
return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
import redis.asyncio as aioredis
from app.models.audit_log import AuditLog
from app.models.user import AuthProvider
from app.services.ldap_service import ldap_service
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
client_ip = _get_client_ip(request)
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
)
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
await self._check_login_lockout(data.email, redis_client)
user = await db.scalar(select(User).where(User.email == data.email))
if not user:
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
await self._record_login_failure(data.email, redis_client)
db.add(AuditLog(
company_id=None,
user_id=None,
action="login_failed",
entity_type="user",
entity_id=None,
new_value={"email": data.email},
ip=client_ip,
))
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else:
if not user.password_hash or not verify_password(data.password, user.password_hash):
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
auth_ok = False
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available",
)
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
else:
auth_ok = bool(user.password_hash and verify_password(data.password, user.password_hash))
if not auth_ok:
await self._record_login_failure(data.email, redis_client)
db.add(AuditLog(
company_id=user.company_id,
user_id=user.id,
action="login_failed",
entity_type="user",
entity_id=user.id,
new_value={"email": data.email},
ip=client_ip,
))
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_login_failures(data.email, redis_client)
finally:
await redis_client.aclose()
# AuditLog: Erfolgreicher Login
db.add(AuditLog(
company_id=user.company_id,
user_id=user.id,
action="login_success",
entity_type="user",
entity_id=user.id,
ip=client_ip,
))
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled:
from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id))
await db.commit()
return TokenResponse(
access_token="",
refresh_token="",
+76 -16
View File
@@ -27,9 +27,52 @@ log = logging.getLogger(__name__)
QR_TOKEN_PREFIX = "kiosk_qr:"
QR_TOKEN_TTL = 5 * 60 # 5 Minuten
# PIN-Brute-Force-Schutz
PIN_MAX_ATTEMPTS = 5
PIN_LOCKOUT_SECONDS = 900 # 15 Minuten
class KioskAuthService:
async def _check_pin_lockout(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Prüft ob PIN-Login für diese Kombination gesperrt ist. Wirft 429 wenn ja."""
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
if await redis.exists(lockout_key):
ttl = await redis.ttl(lockout_key)
wait_min = ttl // 60 + 1
raise HTTPException(
status_code=429,
detail=f"Zu viele Fehlversuche. Bitte {wait_min} Minute(n) warten.",
)
async def _record_pin_failure(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Zählt einen Fehlversuch und sperrt bei Überschreitung von PIN_MAX_ATTEMPTS."""
fail_key = f"pin_fails:{device_id}:{personnel_number}"
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
fails = await redis.incr(fail_key)
await redis.expire(fail_key, PIN_LOCKOUT_SECONDS)
if fails >= PIN_MAX_ATTEMPTS:
await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS)
await redis.delete(fail_key)
log.warning(
"PIN-Lockout ausgelöst: device=%s personnel_number=%s",
device_id,
personnel_number,
)
async def _clear_pin_failures(
self, device_id: uuid.UUID, personnel_number: str, redis
) -> None:
"""Löscht Fehlversuche nach erfolgreichem Login."""
await redis.delete(f"pin_fails:{device_id}:{personnel_number}")
await redis.delete(f"pin_lockout:{device_id}:{personnel_number}")
async def login_pin(
self,
personnel_number: str,
@@ -39,24 +82,41 @@ class KioskAuthService:
db: AsyncSession,
) -> tuple[User, str]:
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
User.is_active == True,
)
)
if user is None:
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
import redis.asyncio as aioredis
from app.core.config import settings
if not user.kiosk_pin_hash:
raise HTTPException(
status_code=401,
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
)
# Redis für Brute-Force-Schutz (async)
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
await self._check_pin_lockout(device_id, personnel_number, redis_client)
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
raise HTTPException(status_code=401, detail="Falscher PIN.")
user = await db.scalar(
select(User).where(
User.company_id == company_id,
User.personnel_number == personnel_number,
User.is_active == True,
)
)
if user is None:
# Fehlversuch zählen auch bei unbekannter Personalnummer
await self._record_pin_failure(device_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
if not user.kiosk_pin_hash:
raise HTTPException(
status_code=401,
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
)
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
await self._record_pin_failure(device_id, personnel_number, redis_client)
raise HTTPException(status_code=401, detail="Falscher PIN.")
# Erfolgreicher Login: Fehlversuche zurücksetzen
await self._clear_pin_failures(device_id, personnel_number, redis_client)
finally:
await redis_client.aclose()
session_token = await kiosk_session_service.create_session(
user_id=user.id,
+42
View File
@@ -268,6 +268,48 @@ class UserService:
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)