security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)
CRITICAL: - C-1: LDAP tls_verify Default False → True (MITM-Schutz) - C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext) - core/crypto.py: encrypt_value() / decrypt_value() helper - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true - _totp_plain() helper mit Legacy-Fallback für bestehende Werte HIGH: - H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben) - H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import) - H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *) - H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt MEDIUM: - M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy - M-4: Audit-Log für password_changed, totp_enabled, totp_disabled - M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl) 152/152 Tests grün Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -954,3 +954,66 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/services/kiosk_auth_service.py | 195 +++++++++++++++++++++++++++++
|
- backend/app/services/kiosk_auth_service.py | 195 +++++++++++++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-24 12:52 – 12:53 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 7e19311 feat: CALDAV_ALLOWED_CIDRS Whitelist für interne CalDAV/Nextcloud-Server
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 14 ++++++++++++++
|
||||||
|
- backend/app/core/config.py | 6 ++++++
|
||||||
|
- backend/app/services/caldav_service.py | 29 +++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 12:55 – 12:56 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 14 ++++++++++++++
|
||||||
|
- backend/app/core/config.py | 6 ++++++
|
||||||
|
- backend/app/services/caldav_service.py | 29 +++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 12:57 – 12:58 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 14 ++++++++++++++
|
||||||
|
- backend/app/core/config.py | 6 ++++++
|
||||||
|
- backend/app/services/caldav_service.py | 29 +++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 13:01 – 13:01 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 14 ++++++++++++++
|
||||||
|
- backend/app/core/config.py | 6 ++++++
|
||||||
|
- backend/app/services/caldav_service.py | 29 +++++++++++++++++++++++++++--
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 13:03 – 13:04 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- a639de1 docs: CalDAV-Konfiguration und CALDAV_ALLOWED_CIDRS in deployment.md
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- docs/deployment.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
from app.core.crypto import encrypt_value, decrypt_value
|
||||||
|
|
||||||
|
stored = encrypt_value("geheimes-passwort")
|
||||||
|
plain = decrypt_value(stored)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(plain: str) -> str:
|
||||||
|
"""Verschlüsselt einen Klartext-String per Fernet. Gibt den chiffrierten String zurück."""
|
||||||
|
return _fernet().encrypt(plain.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(encrypted: str) -> str:
|
||||||
|
"""
|
||||||
|
Entschlüsselt einen Fernet-verschlüsselten String.
|
||||||
|
Wirft ValueError bei ungültigem Token oder falschem Schlüssel.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(encrypted.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise ValueError("Entschlüsselung fehlgeschlagen – ungültiger Token oder falscher Schlüssel.") from exc
|
||||||
@@ -10,6 +10,7 @@ Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
|
|||||||
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
|
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@@ -32,14 +33,23 @@ from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Nonce-Cache (Redis wenn verfügbar, sonst In-Memory-Fallback) ─────────────
|
# ── Nonce-Cache (Redis primär, In-Memory-Fallback mit asyncio.Lock) ──────────
|
||||||
|
|
||||||
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
|
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
|
||||||
|
_nonce_lock: asyncio.Lock | None = None # lazy init (Loop-abhängig)
|
||||||
_NONCE_TTL = 60 # Sekunden
|
_NONCE_TTL = 60 # Sekunden
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nonce_lock() -> asyncio.Lock:
|
||||||
|
"""Lazy-initialized asyncio.Lock (thread-safe, event-loop-abhängig)."""
|
||||||
|
global _nonce_lock
|
||||||
|
if _nonce_lock is None:
|
||||||
|
_nonce_lock = asyncio.Lock()
|
||||||
|
return _nonce_lock
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_nonce_cache() -> None:
|
def _cleanup_nonce_cache() -> None:
|
||||||
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen."""
|
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen (muss unter Lock aufgerufen werden)."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired = [k for k, v in _nonce_cache.items() if v <= now]
|
expired = [k for k, v in _nonce_cache.items() if v <= now]
|
||||||
for k in expired:
|
for k in expired:
|
||||||
@@ -51,6 +61,11 @@ async def _check_and_set_nonce(nonce: str) -> bool:
|
|||||||
Prüft ob die Nonce schon gesehen wurde und speichert sie.
|
Prüft ob die Nonce schon gesehen wurde und speichert sie.
|
||||||
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
|
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
|
||||||
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
|
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
|
||||||
|
|
||||||
|
Verwendet Redis als primären Nonce-Store. Falls Redis nicht erreichbar,
|
||||||
|
wird ein asyncio.Lock-geschützter In-Memory-Fallback verwendet.
|
||||||
|
Kritisch: Redis-Ausfall bei laufenden Kiosk-Requests ermöglicht theoretisch
|
||||||
|
Replay im Fallback-Fenster → Redis sollte in Production HA sein.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
@@ -61,8 +76,10 @@ async def _check_and_set_nonce(nonce: str) -> bool:
|
|||||||
await r.aclose()
|
await r.aclose()
|
||||||
return result is not None # None = bereits vorhanden
|
return result is not None # None = bereits vorhanden
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache: %s", e)
|
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e)
|
||||||
# Fallback: In-Memory
|
# Fallback: In-Memory mit asyncio.Lock gegen Race Conditions
|
||||||
|
lock = _get_nonce_lock()
|
||||||
|
async with lock:
|
||||||
_cleanup_nonce_cache()
|
_cleanup_nonce_cache()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if nonce in _nonce_cache:
|
if nonce in _nonce_cache:
|
||||||
|
|||||||
+12
-6
@@ -44,14 +44,20 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[settings.frontend_url],
|
allow_origins=[settings.frontend_url],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=[
|
||||||
|
"Content-Type",
|
||||||
|
"Authorization",
|
||||||
|
"X-Kiosk-Key-Id",
|
||||||
|
"X-Kiosk-Timestamp",
|
||||||
|
"X-Kiosk-Nonce",
|
||||||
|
"X-Kiosk-Signature",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO (M-07): TrustedHostMiddleware – set ALLOWED_HOSTS env variable (comma-separated) in production.
|
# TrustedHostMiddleware: aktiv sobald ALLOWED_HOSTS gesetzt (Development: leer = deaktiviert)
|
||||||
# Example: ALLOWED_HOSTS=timemaster.example.com,www.timemaster.example.com
|
# Production: ALLOWED_HOSTS=timemaster.example.com in .env setzen
|
||||||
# The placeholder "yourdomain.com" has been replaced with a config-driven approach.
|
if settings.allowed_hosts:
|
||||||
if settings.is_production and settings.allowed_hosts:
|
|
||||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
|
||||||
|
|
||||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class LdapConfig(Base):
|
|||||||
port: Mapped[int] = mapped_column(Integer, default=389)
|
port: Mapped[int] = mapped_column(Integer, default=389)
|
||||||
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
|
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
|
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
tls_verify: Mapped[bool] = mapped_column(Boolean, default=False)
|
tls_verify: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
# Bind credentials
|
# Bind credentials
|
||||||
bind_dn: Mapped[str] = mapped_column(Text, nullable=False)
|
bind_dn: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class User(Base):
|
|||||||
kiosk_nfc_uid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
kiosk_nfc_uid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
|
||||||
# TOTP / 2FA
|
# TOTP / 2FA
|
||||||
totp_secret: Mapped[str | None] = mapped_column(String(64))
|
totp_secret: Mapped[str | None] = mapped_column(String(500)) # Fernet-verschlüsselt
|
||||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.crypto import decrypt_value, encrypt_value
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.dependencies import CurrentUser
|
from app.core.dependencies import CurrentUser
|
||||||
from app.core.limiter import limiter
|
from app.core.limiter import limiter
|
||||||
from app.core.security import hash_password, verify_password
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
@@ -103,12 +105,30 @@ async def change_password(
|
|||||||
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
|
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
|
||||||
)
|
)
|
||||||
current_user.password_hash = hash_password(data.new_password)
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="password_changed",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return MessageResponse(message="Passwort erfolgreich geändert")
|
return MessageResponse(message="Passwort erfolgreich geändert")
|
||||||
|
|
||||||
|
|
||||||
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
|
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _totp_plain(user) -> str | None:
|
||||||
|
"""Gibt das entschlüsselte TOTP-Secret zurück, oder None."""
|
||||||
|
if not user.totp_secret:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return decrypt_value(user.totp_secret)
|
||||||
|
except ValueError:
|
||||||
|
# Fallback: Secret war noch im Klartext (Legacy-Daten vor 0026-Migration)
|
||||||
|
return user.totp_secret
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
||||||
@@ -117,8 +137,8 @@ async def totp_setup(current_user: CurrentUser):
|
|||||||
issuer = "TimeMaster"
|
issuer = "TimeMaster"
|
||||||
label = current_user.email
|
label = current_user.email
|
||||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
|
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
|
||||||
# Secret temporär im User speichern (noch nicht totp_enabled)
|
# Secret Fernet-verschlüsselt speichern (noch nicht totp_enabled)
|
||||||
current_user.totp_secret = secret
|
current_user.totp_secret = encrypt_value(secret)
|
||||||
# Hinweis: DB-Commit passiert NICHT hier – erst nach verify in /totp/confirm
|
# Hinweis: DB-Commit passiert NICHT hier – erst nach verify in /totp/confirm
|
||||||
# Damit das Secret nicht verloren geht, sofort speichern
|
# Damit das Secret nicht verloren geht, sofort speichern
|
||||||
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
|
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
|
||||||
@@ -133,7 +153,7 @@ async def totp_setup_save(
|
|||||||
import pyotp
|
import pyotp
|
||||||
if not current_user.totp_secret:
|
if not current_user.totp_secret:
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
current_user.totp_secret = secret
|
current_user.totp_secret = encrypt_value(secret)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return MessageResponse(message="Secret gespeichert")
|
return MessageResponse(message="Secret gespeichert")
|
||||||
|
|
||||||
@@ -146,12 +166,20 @@ async def totp_confirm(
|
|||||||
):
|
):
|
||||||
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
|
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
|
||||||
import pyotp
|
import pyotp
|
||||||
if not current_user.totp_secret:
|
plain_secret = _totp_plain(current_user)
|
||||||
|
if not plain_secret:
|
||||||
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
|
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
|
||||||
totp = pyotp.TOTP(current_user.totp_secret)
|
totp = pyotp.TOTP(plain_secret)
|
||||||
if not totp.verify(data.code, valid_window=1):
|
if not totp.verify(data.code, valid_window=1):
|
||||||
raise HTTPException(400, "Ungültiger Code")
|
raise HTTPException(400, "Ungültiger Code")
|
||||||
current_user.totp_enabled = True
|
current_user.totp_enabled = True
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="totp_enabled",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
|
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
|
||||||
|
|
||||||
@@ -168,11 +196,19 @@ async def totp_disable(
|
|||||||
raise HTTPException(400, "Passwort falsch")
|
raise HTTPException(400, "Passwort falsch")
|
||||||
if not current_user.totp_enabled or not current_user.totp_secret:
|
if not current_user.totp_enabled or not current_user.totp_secret:
|
||||||
raise HTTPException(400, "2FA ist nicht aktiv")
|
raise HTTPException(400, "2FA ist nicht aktiv")
|
||||||
totp = pyotp.TOTP(current_user.totp_secret)
|
plain_secret = _totp_plain(current_user)
|
||||||
|
totp = pyotp.TOTP(plain_secret or "")
|
||||||
if not totp.verify(data.code, valid_window=1):
|
if not totp.verify(data.code, valid_window=1):
|
||||||
raise HTTPException(400, "Ungültiger TOTP-Code")
|
raise HTTPException(400, "Ungültiger TOTP-Code")
|
||||||
current_user.totp_enabled = False
|
current_user.totp_enabled = False
|
||||||
current_user.totp_secret = None
|
current_user.totp_secret = None
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="totp_disabled",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
|
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
|
||||||
|
|
||||||
@@ -202,7 +238,8 @@ async def totp_login(
|
|||||||
if not user.totp_enabled or not user.totp_secret:
|
if not user.totp_enabled or not user.totp_secret:
|
||||||
raise HTTPException(400, "2FA nicht aktiv")
|
raise HTTPException(400, "2FA nicht aktiv")
|
||||||
|
|
||||||
totp = pyotp.TOTP(user.totp_secret)
|
plain_secret = _totp_plain(user)
|
||||||
|
totp = pyotp.TOTP(plain_secret or "")
|
||||||
if not totp.verify(data.code, valid_window=1):
|
if not totp.verify(data.code, valid_window=1):
|
||||||
raise HTTPException(400, "Ungültiger Code")
|
raise HTTPException(400, "Ungültiger Code")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ router = APIRouter(prefix="/import", tags=["import"])
|
|||||||
|
|
||||||
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
|
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
|
||||||
|
|
||||||
|
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_upload(file: UploadFile) -> bytes:
|
||||||
|
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
|
||||||
|
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(content) > _MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
class ImportPreviewResponse(BaseModel):
|
class ImportPreviewResponse(BaseModel):
|
||||||
preview: list[ImportPreviewEntry]
|
preview: list[ImportPreviewEntry]
|
||||||
@@ -48,7 +61,7 @@ async def kimai_preview(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
||||||
|
|
||||||
content = await file.read()
|
content = await _read_upload(file)
|
||||||
result: ImportResult = await preview_kimai_import(content, target_id, db)
|
result: ImportResult = await preview_kimai_import(content, target_id, db)
|
||||||
|
|
||||||
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
|
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
|
||||||
@@ -76,7 +89,7 @@ async def kimai_run(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
||||||
|
|
||||||
content = await file.read()
|
content = await _read_upload(file)
|
||||||
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
|
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
|
||||||
|
|
||||||
return ImportRunResponse(
|
return ImportRunResponse(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -90,13 +90,27 @@ async def import_template(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_upload(file: UploadFile) -> bytes:
|
||||||
|
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
|
||||||
|
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(content) > _MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/preview", response_model=UserImportResult)
|
@router.post("/import/preview", response_model=UserImportResult)
|
||||||
async def user_import_preview(
|
async def user_import_preview(
|
||||||
file: Annotated[UploadFile, File()],
|
file: Annotated[UploadFile, File()],
|
||||||
current_user: User = require_role(*_admin_roles),
|
current_user: User = require_role(*_admin_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
content = await file.read()
|
content = await _read_upload(file)
|
||||||
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
|
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
|
||||||
return _to_import_result_schema(result)
|
return _to_import_result_schema(result)
|
||||||
|
|
||||||
@@ -107,7 +121,7 @@ async def user_import_apply(
|
|||||||
current_user: User = require_role(*_admin_roles),
|
current_user: User = require_role(*_admin_roles),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
content = await file.read()
|
content = await _read_upload(file)
|
||||||
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
|
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
|
||||||
return _to_import_result_schema(result)
|
return _to_import_result_schema(result)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
|||||||
from app.services.email_service import email_service
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request: "Request | None") -> str | None:
|
||||||
|
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy)."""
|
||||||
|
if not request:
|
||||||
|
return None
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
# Erstes Element = Original-Client-IP (nginx setzt X-Forwarded-For)
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else None
|
||||||
|
|
||||||
|
|
||||||
def _slugify(name: str) -> str:
|
def _slugify(name: str) -> str:
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
return slug[:80]
|
return slug[:80]
|
||||||
@@ -198,7 +209,7 @@ class AuthService:
|
|||||||
refresh_token_hash=hashed_refresh,
|
refresh_token_hash=hashed_refresh,
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
|
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
|
||||||
device=request.headers.get("User-Agent", "")[:255] if request else None,
|
device=request.headers.get("User-Agent", "")[:255] if request else None,
|
||||||
ip=request.client.host if request and request.client else None,
|
ip=_get_client_ip(request),
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
|
|||||||
@@ -209,12 +209,27 @@ def _event_url(calendar_url: str, uid: str) -> str:
|
|||||||
return calendar_url.rstrip("/") + f"/{uid}.ics"
|
return calendar_url.rstrip("/") + f"/{uid}.ics"
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_verify_ssl(verify_ssl: bool) -> bool:
|
||||||
|
"""
|
||||||
|
Gibt den tatsächlich zu verwendenden verify_ssl-Wert zurück.
|
||||||
|
In Production ist SSL-Verifikation immer aktiviert – verify_ssl=False wird ignoriert.
|
||||||
|
"""
|
||||||
|
if settings.is_production and not verify_ssl:
|
||||||
|
log.warning(
|
||||||
|
"CalDAV: verify_ssl=False in Production ignoriert – SSL-Verifikation wird erzwungen. "
|
||||||
|
"Für selbstsignierte Zertifikate das CA-Bundle unter REQUESTS_CA_BUNDLE konfigurieren."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return verify_ssl
|
||||||
|
|
||||||
|
|
||||||
async def _http_put(
|
async def _http_put(
|
||||||
calendar_url: str, username: str, password: str, uid: str,
|
calendar_url: str, username: str, password: str, uid: str,
|
||||||
ical: bytes, verify_ssl: bool,
|
ical: bytes, verify_ssl: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
|
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
|
||||||
_validate_caldav_url(calendar_url)
|
_validate_caldav_url(calendar_url)
|
||||||
|
verify_ssl = _effective_verify_ssl(verify_ssl)
|
||||||
url = _event_url(calendar_url, uid)
|
url = _event_url(calendar_url, uid)
|
||||||
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
@@ -230,6 +245,7 @@ async def _http_delete(
|
|||||||
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
|
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
_validate_caldav_url(calendar_url)
|
_validate_caldav_url(calendar_url)
|
||||||
|
verify_ssl = _effective_verify_ssl(verify_ssl)
|
||||||
url = _event_url(calendar_url, uid)
|
url = _event_url(calendar_url, uid)
|
||||||
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
||||||
resp = await client.delete(url, auth=(username, password))
|
resp = await client.delete(url, auth=(username, password))
|
||||||
@@ -242,6 +258,7 @@ async def _http_propfind(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
|
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
|
||||||
_validate_caldav_url(calendar_url)
|
_validate_caldav_url(calendar_url)
|
||||||
|
verify_ssl = _effective_verify_ssl(verify_ssl)
|
||||||
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
|
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
|
||||||
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
|
||||||
resp = await client.request(
|
resp = await client.request(
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Security-Fixes: LDAP tls_verify default=True, totp_secret column length 500
|
||||||
|
|
||||||
|
Revision ID: 0026
|
||||||
|
Revises: 0025
|
||||||
|
Create Date: 2026-05-24
|
||||||
|
|
||||||
|
Änderungen:
|
||||||
|
- ldap_configs.tls_verify: DEFAULT False → DEFAULT True
|
||||||
|
Neue Konfigurationen werden mit TLS-Verifikation angelegt.
|
||||||
|
BESTEHENDE Einträge mit tls_verify=False werden NICHT automatisch geändert,
|
||||||
|
da dies laufende LDAP-Verbindungen unterbrechen könnte.
|
||||||
|
Admins müssen ihre LDAP-Konfiguration manuell prüfen.
|
||||||
|
|
||||||
|
- users.totp_secret: VARCHAR(64) → VARCHAR(500)
|
||||||
|
Fernet-verschlüsselte Secrets sind länger als 64 Zeichen (~180 Zeichen).
|
||||||
|
Bestehende Plaintext-Secrets (falls vorhanden) müssen manuell verschlüsselt
|
||||||
|
werden; nach dieser Migration werden neue Secrets automatisch verschlüsselt.
|
||||||
|
WICHTIG: Nach dem Deployment müssen bestehende TOTP-Nutzer ihr 2FA neu einrichten
|
||||||
|
(altes Plaintext-Secret nicht mehr kompatibel mit neuem Decrypt-Flow).
|
||||||
|
Alternativ: vor dem Deployment `migrate_totp_secrets.py` ausführen (TODO).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0026"
|
||||||
|
down_revision = "0025"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── C-1: LDAP tls_verify Default auf True ────────────────────────────────
|
||||||
|
op.alter_column(
|
||||||
|
"ldap_configs",
|
||||||
|
"tls_verify",
|
||||||
|
server_default=sa.text("true"),
|
||||||
|
existing_type=sa.Boolean(),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── C-2: totp_secret Column-Länge für Fernet-Token ───────────────────────
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"totp_secret",
|
||||||
|
existing_type=sa.String(64),
|
||||||
|
type_=sa.String(500),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# totp_secret zurück auf 64 (ggf. Datenverlust bei Fernet-verschlüsselten Werten)
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"totp_secret",
|
||||||
|
existing_type=sa.String(500),
|
||||||
|
type_=sa.String(64),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# LDAP tls_verify Default zurück auf False
|
||||||
|
op.alter_column(
|
||||||
|
"ldap_configs",
|
||||||
|
"tls_verify",
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
existing_type=sa.Boolean(),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user