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
+80
View File
@@ -1576,3 +1576,83 @@ Keine Commits in dieser Session.
- frontend/src/pages/CompanySettingsPage.tsx | 161 ++++++++++++++++++++- - 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 +
---
+7 -1
View File
@@ -1,5 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import model_validator from pydantic import Field, model_validator
from functools import lru_cache from functools import lru_cache
@@ -10,6 +10,12 @@ class Settings(BaseSettings):
app_name: str = "TimeMaster" app_name: str = "TimeMaster"
app_env: str = "development" app_env: str = "development"
secret_key: str = "change-me-in-production" 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" frontend_url: str = "http://localhost:5173"
allowed_hosts: list[str] = [] allowed_hosts: list[str] = []
+30 -4
View File
@@ -2,7 +2,9 @@
Zentrale Krypto-Hilfsfunktionen für TimeMaster. Zentrale Krypto-Hilfsfunktionen für TimeMaster.
Verwendet Fernet-Verschlüsselung (AES-128-CBC + HMAC-SHA256). 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: Verwendung:
from app.core.crypto import encrypt_value, decrypt_value from app.core.crypto import encrypt_value, decrypt_value
@@ -14,16 +16,40 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import logging
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings 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: def _fernet() -> Fernet:
"""Erstellt eine Fernet-Instanz aus dem konfigurierten SECRET_KEY.""" """Erstellt eine Fernet-Instanz aus dem konfigurierten Datenschlüssel."""
key = hashlib.sha256(settings.secret_key.encode()).digest() return Fernet(get_fernet_key())
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_value(plain: str) -> str: 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.") 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 ─────────────────────────────────────────────────────── # ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool: 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) # 4. IP-Whitelist prüfen (optional)
if device.ip_whitelist: if device.ip_whitelist:
client_ip = request.client.host if request.client else "" client_ip = _get_client_ip(request)
if not client_ip: if not client_ip or client_ip == "unknown":
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.") raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
if not _check_ip_whitelist(client_ip, device.ip_whitelist): if not _check_ip_whitelist(client_ip, device.ip_whitelist):
raise HTTPException( raise HTTPException(
+8 -1
View File
@@ -21,9 +21,16 @@ from app.routers import hours_payouts
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod) # 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: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 yield
# Shutdown # Shutdown
await engine.dispose() await engine.dispose()
+4
View File
@@ -1,6 +1,7 @@
import uuid import uuid
import enum import enum
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func 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} # business_trip: {"destination": str, "purpose": str}
meta: Mapped[dict | None] = mapped_column(JSONB) 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 # Krankheit: Arbeitsunfähigkeitsbescheinigung
certificate_required_by: Mapped[date | None] = mapped_column(Date) certificate_required_by: Mapped[date | None] = mapped_column(Date)
certificate_received_at: Mapped[date | None] = mapped_column(Date) certificate_received_at: Mapped[date | None] = mapped_column(Date)
+28 -2
View File
@@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@@ -375,20 +375,46 @@ async def mark_certificate_received(
async def update_balance( async def update_balance(
user_id: UUID, user_id: UUID,
data: VacationBalanceUpdate, 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), db: AsyncSession = Depends(get_db),
year: int = Query(...), year: int = Query(...),
): ):
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen.""" """Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
from app.models.vacation_balance import VacationBalance from app.models.vacation_balance import VacationBalance
from app.models.audit_log import AuditLog
target = await db.get(User, user_id) target = await db.get(User, user_id)
if target is None or target.company_id != current_user.company_id: if target is None or target.company_id != current_user.company_id:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(404, "Mitarbeiter nicht gefunden") raise HTTPException(404, "Mitarbeiter nicht gefunden")
balance = await absence_service.get_balance(user_id, year, db) 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(): for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value) 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() await db.commit()
pending = await absence_service.get_pending_days(user_id, year, db) pending = await absence_service.get_pending_days(user_id, year, db)
company = await db.get(Company, current_user.company_id) company = await db.get(Company, current_user.company_id)
+10 -2
View File
@@ -46,12 +46,14 @@ async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends
@router.post("/refresh", response_model=TokenResponse) @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) return await auth_service.refresh(data.refresh_token, db)
@router.post("/logout", response_model=MessageResponse) @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) await auth_service.logout(data.refresh_token, db)
return MessageResponse(message="Logged out successfully") return MessageResponse(message="Logged out successfully")
@@ -132,6 +134,12 @@ def _totp_plain(user) -> str | None:
@router.post("/totp/setup", response_model=TotpSetupResponse) @router.post("/totp/setup", response_model=TotpSetupResponse)
async def totp_setup(current_user: CurrentUser): async def totp_setup(current_user: CurrentUser):
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert.""" """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 import pyotp
secret = pyotp.random_base32() secret = pyotp.random_base32()
issuer = "TimeMaster" issuer = "TimeMaster"
+11 -1
View File
@@ -1,7 +1,8 @@
import uuid import uuid
from datetime import date, datetime 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 import AbsenceStatus
from app.models.absence_type import AbsenceCategory from app.models.absence_type import AbsenceCategory
@@ -67,6 +68,7 @@ class AbsenceOut(BaseModel):
half_day_start: bool half_day_start: bool
half_day_end: bool half_day_end: bool
working_days: float working_days: float
fza_hours: Decimal | None = None
status: AbsenceStatus status: AbsenceStatus
approved_by: uuid.UUID | None approved_by: uuid.UUID | None
substitute_id: uuid.UUID | None substitute_id: uuid.UUID | None
@@ -87,10 +89,18 @@ class AbsenceCreate(BaseModel):
substitute_id: uuid.UUID | None = None substitute_id: uuid.UUID | None = None
note: str | None = None note: str | None = None
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen 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: def model_post_init(self, __context) -> None:
if self.end_date < self.start_date: if self.end_date < self.start_date:
raise ValueError("end_date must be >= 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): class AbsenceUpdate(BaseModel):
+18 -5
View File
@@ -205,6 +205,7 @@ class AbsenceService:
half_day_start=data.half_day_start, half_day_start=data.half_day_start,
half_day_end=data.half_day_end, half_day_end=data.half_day_end,
working_days=working_days, working_days=working_days,
fza_hours=data.fza_hours if hasattr(data, "fza_hours") else None,
status=status, status=status,
approved_by=approved_by, approved_by=approved_by,
substitute_id=data.substitute_id, substitute_id=data.substitute_id,
@@ -314,7 +315,9 @@ class AbsenceService:
# Überstunden zurückbuchen wenn Freizeitausgleich # Überstunden zurückbuchen wenn Freizeitausgleich
absence_type = await db.get(AbsenceType, absence.type_id) absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type and absence_type.affects_overtime_balance: 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: elif absence.status != AbsenceStatus.PENDING:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -380,7 +383,9 @@ class AbsenceService:
# Überstundenkonto abziehen wenn Freizeitausgleich # Überstundenkonto abziehen wenn Freizeitausgleich
fza_warnings: list[str] = [] fza_warnings: list[str] = []
if absence_type and absence_type.affects_overtime_balance: 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) # Audit-Log (DSGVO)
db.add(AuditLog( db.add(AuditLog(
@@ -601,11 +606,15 @@ class AbsenceService:
return daily_hours return daily_hours
async def _deduct_overtime( 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]: ) -> 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.""" Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
user = await db.get(User, user_id) user = await db.get(User, user_id)
if fza_hours is not None:
hours_to_deduct = Decimal(str(fza_hours))
else:
daily_hours = await self._calc_daily_hours(user_id, db) daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_deduct = Decimal(str(working_days)) * daily_hours hours_to_deduct = Decimal(str(working_days)) * daily_hours
@@ -647,9 +656,13 @@ class AbsenceService:
return warnings return warnings
async def _refund_overtime( 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: ) -> None:
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung).""" """Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
if fza_hours is not None:
hours_to_refund = Decimal(str(fza_hours))
else:
daily_hours = await self._calc_daily_hours(user_id, db) daily_hours = await self._calc_daily_hours(user_id, db)
hours_to_refund = Decimal(str(working_days)) * daily_hours hours_to_refund = Decimal(str(working_days)) * daily_hours
+83 -6
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.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.services.email_service import email_service 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: def _get_client_ip(request: "Request | None") -> str | None:
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy).""" """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: 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: async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
existing = await db.scalar(select(User).where(User.email == data.email)) existing = await db.scalar(select(User).where(User.email == data.email))
if existing: if existing:
@@ -74,11 +104,32 @@ class AuthService:
return tokens return tokens
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse: 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.models.user import AuthProvider
from app.services.ldap_service import ldap_service from app.services.ldap_service import ldap_service
client_ip = _get_client_ip(request)
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)) user = await db.scalar(select(User).where(User.email == data.email))
if not user: 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password", detail="Invalid email or password",
@@ -86,6 +137,7 @@ class AuthService:
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated") raise HTTPException(status_code=403, detail="Account is deactivated")
auth_ok = False
if user.auth_provider == AuthProvider.LDAP: if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db) ldap_cfg = await ldap_service.get_config(user.company_id, db)
if not ldap_cfg or not ldap_cfg.enabled: if not ldap_cfg or not ldap_cfg.enabled:
@@ -93,23 +145,48 @@ class AuthService:
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="LDAP authentication not available", detail="LDAP authentication not available",
) )
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password): auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
else: else:
if not user.password_hash or not verify_password(data.password, user.password_hash): 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password", 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 # TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
if user.totp_enabled: if user.totp_enabled:
from app.core.security import create_partial_token from app.core.security import create_partial_token
from app.schemas.auth import TokenResponse from app.schemas.auth import TokenResponse
partial = create_partial_token(str(user.id)) partial = create_partial_token(str(user.id))
await db.commit()
return TokenResponse( return TokenResponse(
access_token="", access_token="",
refresh_token="", refresh_token="",
@@ -27,9 +27,52 @@ log = logging.getLogger(__name__)
QR_TOKEN_PREFIX = "kiosk_qr:" QR_TOKEN_PREFIX = "kiosk_qr:"
QR_TOKEN_TTL = 5 * 60 # 5 Minuten QR_TOKEN_TTL = 5 * 60 # 5 Minuten
# PIN-Brute-Force-Schutz
PIN_MAX_ATTEMPTS = 5
PIN_LOCKOUT_SECONDS = 900 # 15 Minuten
class KioskAuthService: 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( async def login_pin(
self, self,
personnel_number: str, personnel_number: str,
@@ -39,6 +82,15 @@ class KioskAuthService:
db: AsyncSession, db: AsyncSession,
) -> tuple[User, str]: ) -> tuple[User, str]:
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token).""" """Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
import redis.asyncio as aioredis
from app.core.config import settings
# 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)
user = await db.scalar( user = await db.scalar(
select(User).where( select(User).where(
User.company_id == company_id, User.company_id == company_id,
@@ -47,6 +99,8 @@ class KioskAuthService:
) )
) )
if user is None: 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.") raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
if not user.kiosk_pin_hash: if not user.kiosk_pin_hash:
@@ -56,8 +110,14 @@ class KioskAuthService:
) )
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): 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.") 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( session_token = await kiosk_session_service.create_session(
user_id=user.id, user_id=user.id,
company_id=company_id, company_id=company_id,
+42
View File
@@ -268,6 +268,48 @@ class UserService:
detail="Personalnummer kann nicht gelöscht werden (Reservierung).", 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(): for field, value in changes.items():
setattr(user, field, value) setattr(user, field, value)
@@ -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')
+60
View File
@@ -538,3 +538,63 @@ Keine Commits in dieser Session.
- frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++ - 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 ++++++++++++++++++------
---
@@ -254,6 +254,11 @@ interface CreateAbsenceModalProps {
submitting: boolean submitting: boolean
error: string error: string
overtimeBalance: OvertimeBalanceOut | null overtimeBalance: OvertimeBalanceOut | null
fzaMode: 'days' | 'hours'
setFzaMode: React.Dispatch<React.SetStateAction<'days' | 'hours'>>
fzaHours: number
setFzaHours: React.Dispatch<React.SetStateAction<number>>
isFzaType: (typeId: string) => boolean
onCreate: () => void onCreate: () => void
onClose: () => void onClose: () => void
} }
@@ -267,9 +272,17 @@ export function CreateAbsenceModal({
submitting, submitting,
error, error,
overtimeBalance, overtimeBalance,
fzaMode,
setFzaMode,
fzaHours,
setFzaHours,
isFzaType,
onCreate, onCreate,
onClose, onClose,
}: CreateAbsenceModalProps) { }: 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 ( return (
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'> <div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'> <div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
@@ -284,7 +297,7 @@ export function CreateAbsenceModal({
<select <select
value={form.for_user_id} value={form.for_user_id}
onChange={e => setForm(f => ({ ...f, for_user_id: e.target.value }))} onChange={e => setForm(f => ({ ...f, for_user_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}
> >
<option value=''> Für mich selbst </option> <option value=''> Für mich selbst </option>
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)} {colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)}
@@ -296,7 +309,7 @@ export function CreateAbsenceModal({
<select <select
value={form.type_id} value={form.type_id}
onChange={e => setForm(f => ({ ...f, type_id: e.target.value }))} onChange={e => 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}
> >
<option value=''>Bitte wählen</option> <option value=''>Bitte wählen</option>
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)} {types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
@@ -307,13 +320,60 @@ export function CreateAbsenceModal({
</p> </p>
)} )}
</div> </div>
{showFzaToggle && (
<div className='flex rounded-lg border border-gray-200 overflow-hidden'>
<button
type='button'
onClick={() => setFzaMode('days')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${fzaMode === 'days' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}
>
Tage
</button>
<button
type='button'
onClick={() => setFzaMode('hours')}
className={`flex-1 py-2 text-sm font-medium transition-colors ${fzaMode === 'hours' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}
>
Stunden
</button>
</div>
)}
{showFzaToggle && fzaMode === 'hours' ? (
<>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Datum *</label>
<input
type='date'
value={form.start_date}
onChange={e => setForm(f => ({ ...f, start_date: e.target.value, end_date: e.target.value }))}
className={inputClass}
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Stunden *</label>
<input
type='number'
min={0.25}
max={24}
step={0.25}
value={fzaHours}
onChange={e => setFzaHours(Math.max(0.25, Math.min(24, parseFloat(e.target.value) || 0.25)))}
className={inputClass}
/>
<p className='text-xs text-gray-400 mt-1'>{fzaHours} h FZA entsprechende Überstunden werden verrechnet</p>
</div>
</>
) : (
<>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-3'>
<div> <div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label> <label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
<input <input
type='date' value={form.start_date} type='date' value={form.start_date}
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} onChange={e => 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' className={inputClass}
/> />
</div> </div>
<div> <div>
@@ -321,7 +381,7 @@ export function CreateAbsenceModal({
<input <input
type='date' value={form.end_date} min={form.start_date} type='date' value={form.end_date} min={form.start_date}
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} onChange={e => 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' className={inputClass}
/> />
</div> </div>
</div> </div>
@@ -343,6 +403,9 @@ export function CreateAbsenceModal({
Letzter Tag halbtags Letzter Tag halbtags
</label> </label>
</div> </div>
</>
)}
<div> <div>
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label> <label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
<textarea <textarea
+2
View File
@@ -60,6 +60,7 @@ export function useAbsences(year: number, statusFilter: string) {
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string }, form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string },
onSuccess: () => void, onSuccess: () => void,
setSubmitting: (v: boolean) => void, setSubmitting: (v: boolean) => void,
fzaHours?: number,
) => { ) => {
if (!form.type_id || !form.start_date || !form.end_date) { if (!form.type_id || !form.start_date || !form.end_date) {
setError('Bitte alle Pflichtfelder ausfüllen') setError('Bitte alle Pflichtfelder ausfüllen')
@@ -76,6 +77,7 @@ export function useAbsences(year: number, statusFilter: string) {
half_day_end: form.half_day_end, half_day_end: form.half_day_end,
note: form.note || null, note: form.note || null,
for_user_id: form.for_user_id || null, for_user_id: form.for_user_id || null,
...(fzaHours !== undefined ? { fza_hours: fzaHours } : {}),
}) })
onSuccess() onSuccess()
await load() await load()
+27 -2
View File
@@ -43,6 +43,8 @@ export function AbsencesPage() {
half_day_start: false, half_day_end: false, half_day_start: false, half_day_end: false,
note: '', for_user_id: '', note: '', for_user_id: '',
}) })
const [fzaMode, setFzaMode] = useState<'days' | 'hours'>('days')
const [fzaHours, setFzaHours] = useState<number>(4)
const year = new Date().getFullYear() const year = new Date().getFullYear()
@@ -107,6 +109,8 @@ export function AbsencesPage() {
setShowCreate(true) setShowCreate(true)
setError('') setError('')
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
setFzaMode('days')
setFzaHours(4)
if (isManager) await loadColleaguesIfNeeded() if (isManager) await loadColleaguesIfNeeded()
} }
@@ -119,11 +123,21 @@ export function AbsencesPage() {
await saveEdit(editAbsence, editForm, isManager, () => setEditAbsence(null), setSubmitting) await saveEdit(editAbsence, editForm, isManager, () => setEditAbsence(null), setSubmitting)
} }
const isFzaType = (typeId: string) => {
const t = types.find(t => t.id === typeId)
if (!t) return false
const lower = t.name?.toLowerCase() ?? ''
return lower.includes('fza') || lower.includes('freizeitausgleich')
}
const handleCreate = async () => { const handleCreate = async () => {
const useFzaHours = isFzaType(form.type_id) && fzaMode === 'hours'
await createAbsence(form, () => { await createAbsence(form, () => {
setShowCreate(false) setShowCreate(false)
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
}, setSubmitting) setFzaMode('days')
setFzaHours(4)
}, setSubmitting, useFzaHours ? fzaHours : undefined)
} }
const handleSaveBalance = async () => { const handleSaveBalance = async () => {
@@ -777,8 +791,19 @@ export function AbsencesPage() {
submitting={submitting} submitting={submitting}
error={error} error={error}
overtimeBalance={overtimeBalance} overtimeBalance={overtimeBalance}
fzaMode={fzaMode}
setFzaMode={setFzaMode}
fzaHours={fzaHours}
setFzaHours={setFzaHours}
isFzaType={isFzaType}
onCreate={handleCreate} onCreate={handleCreate}
onClose={() => { setShowCreate(false); setError(''); setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) }} onClose={() => {
setShowCreate(false)
setError('')
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
setFzaMode('days')
setFzaHours(4)
}}
/> />
)} )}
+50 -12
View File
@@ -1,17 +1,13 @@
# HTTP-only Konfiguration (SSL/HTTPS noch nicht eingerichtet)
# Sobald ein TLS-Zertifikat vorhanden ist:
# 1. Listen-Block auf 443 ssl http2 erweitern
# 2. ssl_certificate / ssl_certificate_key einkommentieren
# 3. HSTS-Header hinzufügen
# 4. HTTP->HTTPS-Redirect aktivieren
server { server {
listen 80; listen 80;
server_name yourdomain.com www.yourdomain.com; server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20M; client_max_body_size 20M;
@@ -24,28 +20,70 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s; proxy_read_timeout 60s;
# Security Headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
} }
# FastAPI Docs (nur in dev aktiv) # FastAPI Docs (nur in dev aktiv)
location /docs { location /docs {
proxy_pass http://127.0.0.1:8000/docs; proxy_pass http://127.0.0.1:8000/docs;
# Security Headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
} }
location /openapi.json { location /openapi.json {
proxy_pass http://127.0.0.1:8000/openapi.json; proxy_pass http://127.0.0.1:8000/openapi.json;
# Security Headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
} }
# React Frontend (statische Dateien) # React Frontend (statische Dateien)
# HINWEIS: nginx-Regel: add_header in einem location-Block ueberschreibt
# alle add_header-Direktiven des parent server-Blocks. Daher Security-Header
# in jede location wiederholen.
location / { location / {
root /opt/timemaster/frontend/dist; root /opt/timemaster/frontend/dist;
index index.html; index index.html;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
expires 1d; expires 1d;
add_header Cache-Control "public, must-revalidate"; add_header Cache-Control "public, must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
} }
# Uploads / Static Files # Uploads / Static Files
location /static/ { location /static/ {
alias /opt/timemaster/backend/static/; alias /opt/timemaster/backend/static/;
expires 7d; expires 7d;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
} }
} }