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
+7 -1
View File
@@ -1,5 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import model_validator
from pydantic import Field, model_validator
from functools import lru_cache
@@ -10,6 +10,12 @@ class Settings(BaseSettings):
app_name: str = "TimeMaster"
app_env: str = "development"
secret_key: str = "change-me-in-production"
# Separater Schlüssel für Fernet-Datenverschlüsselung (CalDAV/LDAP/SMTP-Passwörter, TOTP-Secrets).
# Empfohlen: in .env als SECRET_KEY_DATA=<zufälliger-string-32+-zeichen> setzen.
# Wenn nicht gesetzt, wird SECRET_KEY als Fallback verwendet (Warnung beim Start).
# WICHTIG: Nach erstem Setzen NICHT mehr ändern alle verschlüsselten DB-Werte werden unlesbar!
secret_key_data: str | None = Field(None, validation_alias="SECRET_KEY_DATA")
frontend_url: str = "http://localhost:5173"
allowed_hosts: list[str] = []
+30 -4
View File
@@ -2,7 +2,9 @@
Zentrale Krypto-Hilfsfunktionen für TimeMaster.
Verwendet Fernet-Verschlüsselung (AES-128-CBC + HMAC-SHA256).
Der Schlüssel wird aus SECRET_KEY per SHA-256 abgeleitet.
Der Schlüssel wird per SHA-256 abgeleitet aus:
- SECRET_KEY_DATA (empfohlen, separater Key für Datenverschlüsselung)
- SECRET_KEY (Fallback wenn SECRET_KEY_DATA nicht gesetzt Warnung beim Start)
Verwendung:
from app.core.crypto import encrypt_value, decrypt_value
@@ -14,16 +16,40 @@ from __future__ import annotations
import base64
import hashlib
import logging
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
logger = logging.getLogger(__name__)
def get_fernet_key() -> bytes:
"""Gibt den Fernet-Key zurück.
Bevorzugt SECRET_KEY_DATA (separater Datenschlüssel).
Fällt auf SECRET_KEY zurück wenn SECRET_KEY_DATA nicht gesetzt ist,
und gibt dabei eine Warnung aus (JWT- und Datenschlüssel identisch).
Der Key wird per SHA-256 auf 32 Bytes normiert und dann base64url-kodiert.
"""
if settings.secret_key_data:
key_material = settings.secret_key_data
else:
logger.warning(
"SECRET_KEY_DATA nicht gesetzt — JWT-Key wird auch für Datenverschlüsselung "
"verwendet. Bitte SECRET_KEY_DATA in .env setzen für verbesserte Sicherheit."
)
key_material = settings.secret_key
key_bytes = hashlib.sha256(key_material.encode()).digest()
return base64.urlsafe_b64encode(key_bytes)
def _fernet() -> Fernet:
"""Erstellt eine Fernet-Instanz aus dem konfigurierten SECRET_KEY."""
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
"""Erstellt eine Fernet-Instanz aus dem konfigurierten Datenschlüssel."""
return Fernet(get_fernet_key())
def encrypt_value(plain: str) -> str:
+18 -2
View File
@@ -114,6 +114,22 @@ def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
# ── Client-IP ermitteln (nginx-Proxy-sicher) ─────────────────────────────────
def _get_client_ip(request: Request) -> str:
"""Liest die echte Client-IP auch hinter nginx-Proxy.
nginx setzt X-Real-IP auf die echte Client-IP.
Ohne diesen Header würde request.client.host hinter nginx immer
127.0.0.1 zurückgeben, womit IP-Whitelisting wirkungslos wäre.
"""
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip.strip()
# Fallback: direkte Verbindungs-IP (lokal/dev)
return request.client.host if request.client else "unknown"
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
@@ -213,8 +229,8 @@ async def verify_kiosk_request(
# 4. IP-Whitelist prüfen (optional)
if device.ip_whitelist:
client_ip = request.client.host if request.client else ""
if not client_ip:
client_ip = _get_client_ip(request)
if not client_ip or client_ip == "unknown":
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
raise HTTPException(