From 06bb1c1664b3b9efe56b9e96cc23d659fd9f6521 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 26 May 2026 11:13:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20FZA=20Einzelstunden=20+=20Security-Fixe?= =?UTF-8?q?s=20(K-1=E2=80=93K-5,=20H-2=E2=80=93H-4,=20M-1/M-3/M-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DEVLOG.md | 80 ++++++++++ backend/app/core/config.py | 8 +- backend/app/core/crypto.py | 34 ++++- backend/app/core/kiosk_security.py | 20 ++- backend/app/main.py | 13 +- backend/app/models/absence.py | 4 + backend/app/routers/absences.py | 30 +++- backend/app/routers/auth.py | 12 +- backend/app/schemas/absence.py | 12 +- backend/app/services/absence_service.py | 31 ++-- backend/app/services/auth_service.py | 113 ++++++++++++--- backend/app/services/kiosk_auth_service.py | 92 ++++++++++-- backend/app/services/user_service.py | 42 ++++++ backend/migrations/versions/0032_fza_hours.py | 21 +++ frontend/DEVLOG.md | 60 ++++++++ .../src/components/absences/AbsenceModals.tsx | 137 +++++++++++++----- frontend/src/hooks/useAbsences.ts | 2 + frontend/src/pages/AbsencesPage.tsx | 29 +++- nginx.conf | 62 ++++++-- 19 files changed, 693 insertions(+), 109 deletions(-) create mode 100644 backend/migrations/versions/0032_fza_hours.py diff --git a/DEVLOG.md b/DEVLOG.md index 6cefdaa..bc3c741 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1576,3 +1576,83 @@ Keine Commits in dieser Session. - frontend/src/pages/CompanySettingsPage.tsx | 161 ++++++++++++++++++++- --- +## 2026-05-26 10:39 – 10:39 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- +## 2026-05-26 10:40 – 10:41 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- c8578f6 chore: CLAUDE.md aus Git-Tracking entfernen + +### Geänderte Dateien +- .gitignore | 1 + +- CLAUDE.md | 347 ------------------------------------------------------------- + +--- +## 2026-05-26 10:41 – 10:42 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- c9cb6d7 chore: .claude/ aus Git-Tracking entfernen + +### Geänderte Dateien +- .claude/agents/code-optimizer.md | 90 ------------------------------------ +- .claude/agents/frontend.md | 94 -------------------------------------- +- .claude/agents/security-auditor.md | 92 ------------------------------------- +- .gitignore | 1 + + +--- +## 2026-05-26 10:42 – 10:43 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .claude/agents/code-optimizer.md | 90 ------------------------------------ +- .claude/agents/frontend.md | 94 -------------------------------------- +- .claude/agents/security-auditor.md | 92 ------------------------------------- +- .gitignore | 1 + + +--- +## 2026-05-26 10:51 – 10:54 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .claude/agents/code-optimizer.md | 90 ------------------------------------ +- .claude/agents/frontend.md | 94 -------------------------------------- +- .claude/agents/security-auditor.md | 92 ------------------------------------- +- .gitignore | 1 + + +--- +## 2026-05-26 10:56 – 11:07 (11m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- .claude/agents/code-optimizer.md | 90 ------------------------------------ +- .claude/agents/frontend.md | 94 -------------------------------------- +- .claude/agents/security-auditor.md | 92 ------------------------------------- +- .gitignore | 1 + + +--- diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b67da7f..1ecc2a6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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= 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] = [] diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py index 800a488..02dc1a3 100644 --- a/backend/app/core/crypto.py +++ b/backend/app/core/crypto.py @@ -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: diff --git a/backend/app/core/kiosk_security.py b/backend/app/core/kiosk_security.py index a9feec9..d4c0b9c 100644 --- a/backend/app/core/kiosk_security.py +++ b/backend/app/core/kiosk_security.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index c901837..5692215 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,9 +21,16 @@ from app.routers import hours_payouts @asynccontextmanager async def lifespan(app: FastAPI): - # Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + # Startup: Tabellen anlegen nur in Development/Test. + # In Production verwaltet Alembic das Schema – create_all würde mit Migrationen kollidieren. + import logging + _log = logging.getLogger(__name__) + if not settings.is_production: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + _log.info("Development-Modus: create_all ausgeführt.") + else: + _log.info("Production-Modus: create_all übersprungen — Alembic verwaltet das Schema.") yield # Shutdown await engine.dispose() diff --git a/backend/app/models/absence.py b/backend/app/models/absence.py index d34ab09..2527ba7 100644 --- a/backend/app/models/absence.py +++ b/backend/app/models/absence.py @@ -1,6 +1,7 @@ import uuid import enum from datetime import date, datetime +from decimal import Decimal from typing import TYPE_CHECKING from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func @@ -56,6 +57,9 @@ class Absence(Base): # business_trip: {"destination": str, "purpose": str} meta: Mapped[dict | None] = mapped_column(JSONB) + # FZA in Stunden statt Tagen (bei Stunden-FZA ist start_date == end_date) + fza_hours: Mapped[Decimal | None] = mapped_column(Numeric(5, 2)) + # Krankheit: Arbeitsunfähigkeitsbescheinigung certificate_required_by: Mapped[date | None] = mapped_column(Date) certificate_received_at: Mapped[date | None] = mapped_column(Date) diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 411135e..49d766e 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -1,6 +1,6 @@ from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -375,20 +375,46 @@ async def mark_certificate_received( async def update_balance( user_id: UUID, data: VacationBalanceUpdate, - current_user: User = require_role(*_manager_roles), + request: Request, + current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN), db: AsyncSession = Depends(get_db), year: int = Query(...), ): """Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen.""" from app.models.vacation_balance import VacationBalance + from app.models.audit_log import AuditLog target = await db.get(User, user_id) if target is None or target.company_id != current_user.company_id: from fastapi import HTTPException raise HTTPException(404, "Mitarbeiter nicht gefunden") balance = await absence_service.get_balance(user_id, year, db) + + # Alte Werte für AuditLog sichern + old_base = balance.base_days + old_special = balance.special_days + old_carried = balance.carried_over_days + for field, value in data.model_dump(exclude_unset=True).items(): setattr(balance, field, value) + + # AuditLog schreiben + db.add(AuditLog( + user_id=current_user.id, + action="update_vacation_balance", + entity_type="vacation_balance", + entity_id=balance.id, + old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried}, + new_value={ + "base_days": balance.base_days, + "special_days": balance.special_days, + "carried_over_days": balance.carried_over_days, + "target_user_id": str(user_id), + "year": year, + }, + ip_address=request.client.host if request.client else None, + )) + await db.commit() pending = await absence_service.get_pending_days(user_id, year, db) company = await db.get(Company, current_user.company_id) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 26e084c..40cae94 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -46,12 +46,14 @@ async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends @router.post("/refresh", response_model=TokenResponse) -async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)): +@limiter.limit("30/minute") +async def refresh(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)): return await auth_service.refresh(data.refresh_token, db) @router.post("/logout", response_model=MessageResponse) -async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)): +@limiter.limit("60/minute") +async def logout(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)): await auth_service.logout(data.refresh_token, db) return MessageResponse(message="Logged out successfully") @@ -132,6 +134,12 @@ def _totp_plain(user) -> str | None: @router.post("/totp/setup", response_model=TotpSetupResponse) async def totp_setup(current_user: CurrentUser): """Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert.""" + # Fix K-4: Verhindere Überschreiben eines aktiven TOTP-Secrets mit gestohlenem Access-Token + if current_user.totp_enabled: + raise HTTPException( + status_code=400, + detail="TOTP ist bereits aktiv. Bitte zuerst deaktivieren (POST /auth/totp/disable).", + ) import pyotp secret = pyotp.random_base32() issuer = "TimeMaster" diff --git a/backend/app/schemas/absence.py b/backend/app/schemas/absence.py index aa0fbb3..6c63504 100644 --- a/backend/app/schemas/absence.py +++ b/backend/app/schemas/absence.py @@ -1,7 +1,8 @@ import uuid from datetime import date, datetime +from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from app.models.absence import AbsenceStatus from app.models.absence_type import AbsenceCategory @@ -67,6 +68,7 @@ class AbsenceOut(BaseModel): half_day_start: bool half_day_end: bool working_days: float + fza_hours: Decimal | None = None status: AbsenceStatus approved_by: uuid.UUID | None substitute_id: uuid.UUID | None @@ -87,10 +89,18 @@ class AbsenceCreate(BaseModel): substitute_id: uuid.UUID | None = None note: str | None = None for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen + fza_hours: Decimal | None = Field( + None, + ge=Decimal("0.25"), + le=Decimal("24"), + description="FZA in Stunden (statt Tagen); nur bei eintägigem Zeitraum erlaubt.", + ) def model_post_init(self, __context) -> None: if self.end_date < self.start_date: raise ValueError("end_date must be >= start_date") + if self.fza_hours is not None and self.start_date != self.end_date: + raise ValueError("fza_hours ist nur erlaubt wenn start_date == end_date (eintägiger FZA).") class AbsenceUpdate(BaseModel): diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index c34c4e8..0eeb02f 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -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: diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 77e42eb..81c11c9 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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="", diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py index 453c053..4255805 100644 --- a/backend/app/services/kiosk_auth_service.py +++ b/backend/app/services/kiosk_auth_service.py @@ -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, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 75ab75e..44dac9e 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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) diff --git a/backend/migrations/versions/0032_fza_hours.py b/backend/migrations/versions/0032_fza_hours.py new file mode 100644 index 0000000..bbf3706 --- /dev/null +++ b/backend/migrations/versions/0032_fza_hours.py @@ -0,0 +1,21 @@ +"""add fza_hours to absences + +Revision ID: 0032 +Revises: 0031 +Create Date: 2026-05-25 +""" +from alembic import op +import sqlalchemy as sa + +revision = '0032' +down_revision = '0031' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('absences', sa.Column('fza_hours', sa.Numeric(5, 2), nullable=True)) + + +def downgrade(): + op.drop_column('absences', 'fza_hours') diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index e7d85f8..09450d1 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -538,3 +538,63 @@ Keine Commits in dieser Session. - frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++ --- +## 2026-05-25 22:53 – 22:56 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- d0fdaef feat: Monatsansicht im /mobile Heute-Screen + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- +## 2026-05-25 22:59 – 22:59 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- +## 2026-05-25 23:00 – 23:11 (11m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- +## 2026-05-25 23:14 – 23:15 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- +## 2026-05-25 23:17 – 23:21 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 18 ++ +- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ + +--- diff --git a/frontend/src/components/absences/AbsenceModals.tsx b/frontend/src/components/absences/AbsenceModals.tsx index 28fe880..50bc5ad 100644 --- a/frontend/src/components/absences/AbsenceModals.tsx +++ b/frontend/src/components/absences/AbsenceModals.tsx @@ -254,6 +254,11 @@ interface CreateAbsenceModalProps { submitting: boolean error: string overtimeBalance: OvertimeBalanceOut | null + fzaMode: 'days' | 'hours' + setFzaMode: React.Dispatch> + fzaHours: number + setFzaHours: React.Dispatch> + isFzaType: (typeId: string) => boolean onCreate: () => void onClose: () => void } @@ -267,9 +272,17 @@ export function CreateAbsenceModal({ submitting, error, overtimeBalance, + fzaMode, + setFzaMode, + fzaHours, + setFzaHours, + isFzaType, onCreate, onClose, }: CreateAbsenceModalProps) { + const showFzaToggle = form.type_id ? isFzaType(form.type_id) : false + const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' + return (
@@ -284,7 +297,7 @@ export function CreateAbsenceModal({ setForm(f => ({ ...f, type_id: e.target.value }))} - className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' + className={inputClass} > {types.map(t => )} @@ -307,42 +320,92 @@ export function CreateAbsenceModal({

)}
-
-
- - setForm(f => ({ ...f, start_date: e.target.value }))} - className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' - /> + + {showFzaToggle && ( +
+ +
-
- - setForm(f => ({ ...f, end_date: e.target.value }))} - className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' - /> -
-
-
- - -
+ )} + + {showFzaToggle && fzaMode === 'hours' ? ( + <> +
+ + setForm(f => ({ ...f, start_date: e.target.value, end_date: e.target.value }))} + className={inputClass} + /> +
+
+ + setFzaHours(Math.max(0.25, Math.min(24, parseFloat(e.target.value) || 0.25)))} + className={inputClass} + /> +

{fzaHours} h FZA – entsprechende Überstunden werden verrechnet

+
+ + ) : ( + <> +
+
+ + setForm(f => ({ ...f, start_date: e.target.value }))} + className={inputClass} + /> +
+
+ + setForm(f => ({ ...f, end_date: e.target.value }))} + className={inputClass} + /> +
+
+
+ + +
+ + )} +