Files
timemaster/backend/app/core/kiosk_security.py
T
patrick 06bb1c1664 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>
2026-05-26 11:13:42 +02:00

267 lines
9.9 KiB
Python

"""
Ed25519-basierte Request-Verifizierung für Kiosk-Geräte.
Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
X-Kiosk-Key-Id : UUID des Geräts (entspricht KioskDevice.id)
X-Kiosk-Timestamp : Unix-Zeit in Sekunden (max 30s Drift zum Server)
X-Kiosk-Nonce : Einmalige UUID (Replay-Schutz, 60s-Fenster via Redis)
X-Kiosk-Signature : Base64(Ed25519-Sig über "METHOD PATH TIMESTAMP NONCE sha256(BODY)")
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
"""
import asyncio
import base64
import hashlib
import ipaddress
import logging
import time
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_ssh_public_key
from fastapi import Depends, Header, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
logger = logging.getLogger(__name__)
# ── Nonce-Cache (Redis primär, In-Memory-Fallback mit asyncio.Lock) ──────────
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
_nonce_lock: asyncio.Lock | None = None # lazy init (Loop-abhängig)
_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:
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen (muss unter Lock aufgerufen werden)."""
now = time.time()
expired = [k for k, v in _nonce_cache.items() if v <= now]
for k in expired:
del _nonce_cache[k]
async def _check_and_set_nonce(nonce: str) -> bool:
"""
Prüft ob die Nonce schon gesehen wurde und speichert sie.
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
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:
import redis.asyncio as aioredis
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True)
key = f"kiosk:nonce:{nonce}"
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
await r.aclose()
return result is not None # None = bereits vorhanden
except Exception as e:
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e)
# Fallback: In-Memory mit asyncio.Lock gegen Race Conditions
lock = _get_nonce_lock()
async with lock:
_cleanup_nonce_cache()
now = time.time()
if nonce in _nonce_cache:
return False
_nonce_cache[nonce] = now + _NONCE_TTL
return True
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────
def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
"""
Lädt einen Ed25519-Public-Key aus PEM- oder OpenSSH-Format.
Wirft ValueError bei ungültigem Format.
"""
key_bytes = public_key_str.strip().encode()
# OpenSSH-Format (beginnt mit "ssh-ed25519 ...")
if key_bytes.startswith(b"ssh-ed25519"):
key = load_ssh_public_key(key_bytes)
if not isinstance(key, Ed25519PublicKey):
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
return key
# PEM-Format (beginnt mit "-----BEGIN ...")
if key_bytes.startswith(b"-----"):
key = load_pem_public_key(key_bytes)
if not isinstance(key, Ed25519PublicKey):
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
return key
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:
"""
Prüft ob client_ip in einer kommaseparierten CIDR-Liste enthalten ist.
Gibt True zurück wenn erlaubt, False wenn nicht.
"""
try:
client_addr = ipaddress.ip_address(client_ip)
except ValueError:
logger.warning("Ungültige Client-IP: %s", client_ip)
return False
for cidr in ip_whitelist.split(","):
cidr = cidr.strip()
if not cidr:
continue
try:
network = ipaddress.ip_network(cidr, strict=False)
if client_addr in network:
return True
except ValueError:
logger.warning("Ungültiger CIDR-Eintrag in ip_whitelist: %s", cidr)
continue
return False
# ── FastAPI Dependency ────────────────────────────────────────────────────────
async def verify_kiosk_request(
request: Request,
x_kiosk_key_id: str = Header(..., alias="X-Kiosk-Key-Id"),
x_kiosk_timestamp: str = Header(..., alias="X-Kiosk-Timestamp"),
x_kiosk_nonce: str = Header(..., alias="X-Kiosk-Nonce"),
x_kiosk_signature: str = Header(..., alias="X-Kiosk-Signature"),
db: AsyncSession = Depends(get_db),
) -> KioskDevice:
"""
FastAPI-Dependency: Verifiziert einen signierten Kiosk-Request.
Schritte:
1. Timestamp-Check (max 30s Drift)
2. Nonce-Check (Replay-Schutz)
3. Gerät laden + Status prüfen
4. IP-Whitelist (falls konfiguriert)
5. Ed25519-Signatur verifizieren
6. last_heartbeat_at aktualisieren
Gibt das verifizierte KioskDevice zurück.
"""
# 1. Timestamp-Check
try:
request_time = float(x_kiosk_timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="Ungültiger Timestamp.")
drift = abs(time.time() - request_time)
if drift > 30:
raise HTTPException(
status_code=401,
detail=f"Timestamp liegt zu weit in der Vergangenheit oder Zukunft ({drift:.1f}s Abweichung, max 30s).",
)
# 2. Nonce-Check
# UUID-Format validieren (grobe Prüfung)
try:
UUID(x_kiosk_nonce)
except ValueError:
raise HTTPException(status_code=401, detail="Nonce muss eine gültige UUID sein.")
nonce_ok = await _check_and_set_nonce(x_kiosk_nonce)
if not nonce_ok:
raise HTTPException(status_code=401, detail="Replay-Angriff erkannt: Nonce bereits verwendet.")
# 3. Gerät laden
try:
device_uuid = UUID(x_kiosk_key_id)
except ValueError:
raise HTTPException(status_code=401, detail="X-Kiosk-Key-Id muss eine gültige UUID sein.")
device = await db.scalar(
select(KioskDevice).where(KioskDevice.id == device_uuid)
)
if device is None:
raise HTTPException(status_code=401, detail="Kiosk-Gerät nicht gefunden.")
if device.status != KioskDeviceStatus.APPROVED:
raise HTTPException(
status_code=401,
detail=f"Kiosk-Gerät ist nicht freigegeben (Status: {device.status.value}).",
)
if not device.public_key:
raise HTTPException(status_code=401, detail="Kein Public Key für dieses Gerät registriert.")
# 4. IP-Whitelist prüfen (optional)
if device.ip_whitelist:
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(
status_code=403,
detail=f"Client-IP {client_ip} ist nicht in der erlaubten IP-Whitelist.",
)
# 5. Signatur verifizieren
body = await request.body()
body_hash = hashlib.sha256(body).hexdigest()
message = f"{request.method} {request.url.path} {x_kiosk_timestamp} {x_kiosk_nonce} {body_hash}"
try:
signature_bytes = base64.b64decode(x_kiosk_signature)
except Exception:
raise HTTPException(status_code=401, detail="Signatur ist kein gültiges Base64.")
try:
pub_key = _load_ed25519_public_key(device.public_key)
except ValueError as e:
logger.error("Fehler beim Laden des Public Keys für Gerät %s: %s", device.id, e)
raise HTTPException(status_code=401, detail="Public Key des Geräts ist ungültig.")
try:
pub_key.verify(signature_bytes, message.encode())
except InvalidSignature:
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
# 6. Heartbeat-Zeitstempel aktualisieren
device.last_heartbeat_at = datetime.now(timezone.utc)
await db.flush()
return device