Files
StefanandClaude Opus 5 e0212cfa2c
Docker-Image bauen / build (push) Successful in 1m7s
Tests von 55 auf 12 Sekunden, Abhängigkeiten nur noch einmal installieren
Der Testlauf war zu langsam. Gemessen statt geraten: 0,68 s Vorbereitung
pro Test, bei 81 Tests praktisch die ganze Laufzeit.

Ursache war bcrypt. Pro Test wird ein Hash gebildet und geprüft, und das
ist absichtlich langsam - genau das soll es im Betrieb sein. Der Aufwand
ist jetzt über BCRYPT_ROUNDS einstellbar (Standard bleibt 12) und in den
Tests auf 4 gesetzt: dort geht es um die Ablauflogik, nicht um die Stärke
des Hashes.

Der Dekompressionsbomben-Test erzeugte ein Bild mit 400 Megapixeln, allein
dafür 4,8 s. Jetzt wird stattdessen die Grenze heruntergesetzt und ein
kleines Bild verwendet - dieselbe Codestelle, ohne die Wartezeit.

Ausserdem installierten Test- und Anwendungs-Image dieselben
Abhängigkeiten zweimal. Dockerfile.test setzt nun auf dem gebauten
Anwendungs-Image auf, der Testschritt kommt entsprechend danach.

Dabei zwei Fallen, die beide auffielen, weil die Tests plötzlich wieder
langsam waren:
- Das Basis-Image bringt seinen eigenen Stand von app/ mit. Ohne erneutes
  Kopieren prüfen die Tests den Code des Basis-Images - ist es veraltet,
  läuft alles gegen alten Code und meldet Erfolg.
- Der ENTRYPOINT des Anwendungs-Images startet eine Datenbank-Migration.
  Für Tests weder nötig noch erwünscht, darum geleert.

Ganze Kette lokal durchgespielt: bauen, testen, Image prüfen - 22 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-30 14:45:24 +02:00

153 lines
4.8 KiB
Python

"""Anmeldung, Sitzung, CSRF und Rate-Limits.
Grundsatz: **lesen darf jeder, schreiben nur der Betreiber** - mit der einen
Ausnahme des Reservierens, das bewusst ohne Anmeldung möglich ist.
"""
from __future__ import annotations
import secrets
import bcrypt
from fastapi import Depends, HTTPException, Request, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.config import einstellungen
# Der Schlüssel für die Rate-Limits ist die IP. Hinter einem Reverse-Proxy
# liefert get_remote_address nur dann die echte Adresse, wenn uvicorn mit
# --proxy-headers läuft (siehe Dockerfile) - sonst zählt alles auf die IP
# des Proxys, und ein einzelner Besucher sperrt alle anderen aus.
limiter = Limiter(key_func=get_remote_address)
# ------------------------------------------------------------- Passwort ---
def passwort_hash(klartext: str) -> str:
runden = einstellungen().bcrypt_runden
return bcrypt.hashpw(klartext.encode(), bcrypt.gensalt(runden)).decode()
def passwort_stimmt(klartext: str, hash_wert: str) -> bool:
"""Prüft das Passwort. bcrypt vergleicht in konstanter Zeit."""
try:
# bcrypt schneidet nach 72 Byte ab und wirft bei längeren Eingaben
# je nach Version einen Fehler - abfangen statt 500 liefern.
return bcrypt.checkpw(klartext.encode()[:72], hash_wert.encode())
except (ValueError, TypeError):
return False
def _hinterlegter_hash() -> str | None:
"""Hash aus der Umgebung. Ergebnis wird gemerkt.
Wichtig: der Hash wird **einmal** gebildet und nicht bei jeder Anfrage.
bcrypt ist absichtlich langsam; würde man ihn pro Aufruf berechnen,
liesse sich die Anwendung mit ein paar gleichzeitigen Aufrufen der
Anmeldeseite lahmlegen - noch dazu ohne Anmeldung.
"""
cfg = einstellungen()
if cfg.admin_passwort_hash:
return cfg.admin_passwort_hash
if cfg.admin_passwort:
return passwort_hash(cfg.admin_passwort)
return None
_hash_zwischenspeicher: dict[str, str | None] = {}
def hinterlegter_hash() -> str | None:
if "wert" not in _hash_zwischenspeicher:
_hash_zwischenspeicher["wert"] = _hinterlegter_hash()
return _hash_zwischenspeicher["wert"]
def hash_zwischenspeicher_leeren() -> None:
"""Nur für Tests - im Betrieb ändert sich das Passwort nicht zur Laufzeit."""
_hash_zwischenspeicher.clear()
# -------------------------------------------------------------- Sitzung ---
SITZUNG_ANGEMELDET = "betreiber"
SITZUNG_LETZTE_AKTIVITAET = "letzte_aktivitaet"
SITZUNG_CSRF = "csrf"
def anmelden(request: Request) -> None:
import time
# Sitzung neu aufbauen statt ergänzen: verhindert, dass eine vorher
# untergeschobene Sitzungskennung nach der Anmeldung weitergilt.
request.session.clear()
request.session[SITZUNG_ANGEMELDET] = True
request.session[SITZUNG_LETZTE_AKTIVITAET] = int(time.time())
def abmelden(request: Request) -> None:
request.session.clear()
def ist_betreiber(request: Request) -> bool:
import time
if not request.session.get(SITZUNG_ANGEMELDET):
return False
cfg = einstellungen()
letzte = request.session.get(SITZUNG_LETZTE_AKTIVITAET, 0)
if letzte and (time.time() - letzte) > cfg.sitzung_max_ruhe_sekunden:
request.session.clear()
return False
request.session[SITZUNG_LETZTE_AKTIVITAET] = int(time.time())
return True
# ------------------------------------------------------------------ CSRF ---
def csrf_token(request: Request) -> str:
if not request.session.get(SITZUNG_CSRF):
request.session[SITZUNG_CSRF] = secrets.token_urlsafe(32)
return request.session[SITZUNG_CSRF]
def csrf_pruefen(request: Request, gesendet: str | None) -> bool:
hinterlegt = request.session.get(SITZUNG_CSRF)
if not hinterlegt or not gesendet:
return False
return secrets.compare_digest(hinterlegt, gesendet)
# ----------------------------------------------------------- Abhängigkeit ---
def betreiber_noetig(request: Request) -> None:
"""Schützt alles, was den Bestand verändert.
Nicht darunter: Lesen und Reservieren - beides ist bewusst offen.
"""
if not einstellungen().admin_aktiv:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Der Erfassungsbereich ist nicht eingerichtet: "
"ADMIN_PASSWORD oder ADMIN_PASSWORD_HASH setzen."
),
)
if not ist_betreiber(request):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bitte zuerst anmelden.",
)
BetreiberNoetig = Depends(betreiber_noetig)
def betreiber_optional(request: Request) -> bool:
"""Für Stellen, die je nach Anmeldung unterschiedlich reagieren -
etwa das Aufheben einer Reservierung (Token oder Betreiber)."""
return ist_betreiber(request)