Phase 3: Anmeldung, Rate-Limits und Sicherheits-Header

Lesen darf jeder, schreiben nur der Betreiber - mit der bewussten Ausnahme
des Reservierens. Dazu Dockerfile und Compose-Datei, damit sich das lokal
ausprobieren lässt.

Beim Bauen sind zwei Fehler aufgefallen, die ohne Test nicht aufgefallen
wären:

1. slowapi zählt pro URL-Pfad. Weil jedes Kleidungsstück eine eigene URL
   hat, bekam jedes seinen eigenen Zähler - ein Skript hätte also den
   gesamten Bestand reservieren können, ohne je an ein Limit zu stossen.
   Genau der Missbrauch, gegen den das Limit gedacht ist. Behoben mit
   shared_limit und festem scope.
2. Der erste Anlauf des Tests machte fünf Anfragen gegen ein Limit von
   fünf und konnte damit gar nichts zeigen. Geprüft wird jetzt der
   tatsächlich ausgelieferte Standardwert, mit mehr Anfragen als erlaubt.

Weiter umgesetzt:

- Passwort als bcrypt-Hash aus der Umgebung, einmal beim Start gebildet
  und gemerkt. Bei jeder Anfrage neu gehasht liesse sich die Anwendung
  sonst ohne Anmeldung lahmlegen - bcrypt ist absichtlich langsam.
- Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt (503)
  statt offen zu stehen. Kein mitgeliefertes Standardpasswort.
- Sitzung als signiertes Cookie, HttpOnly, SameSite=Lax (blockt
  seitenfremde POSTs), Secure abschaltbar nur fürs lokale Testen,
  Abmeldung nach zwei Stunden Ruhe.
- CSP mit script-src 'self', nosniff, frame-ancestors none, dazu noindex
  und robots.txt: die Galerie ist frei zugänglich, soll aber nicht
  dauerhaft im Suchindex stehen.
- Der Betreiber darf Reservierungen ohne Token aufheben, damit sich eine
  Missbrauchswelle aufräumen lässt.
- uvicorn mit --proxy-headers: hinter einem Reverse-Proxy zählte sonst
  alles auf dessen IP, und ein einzelner Besucher sperrte alle aus.
- Container läuft nicht als root; Code gehört root, nur Daten und Bilder
  dem Dienstbenutzer.

Umgebungsvariablen heissen jetzt ausdrücklich englisch (ADMIN_PASSWORD,
SECRET_KEY, ...), passend zur Anleitung und zur Kantone-App.

59 Tests, alle grün. Zusätzlich gegen den laufenden Container geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-08-29 23:00:23 +02:00
co-authored by Claude Opus 5
parent 1d122b33f9
commit 99ba74330c
13 changed files with 713 additions and 41 deletions
+40 -5
View File
@@ -9,29 +9,64 @@ gesperrt (siehe Phase 3) - ein mitgeliefertes Standardpasswort wäre
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Einstellungen(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Die Namen der Umgebungsvariablen sind ausdrücklich angegeben und
# englisch - sie stehen in docker-compose.yml und in der Anleitung, und
# dort wären deutsche Namen neben ADMIN_PASSWORD & Co. verwirrend.
# Ablageorte. Im Container zeigen beide auf eingehängte Volumes, damit
# Datenbank und Bilder einen Neustart überleben.
datenbank_pfad: Path = Path("data/kleiderboerse.sqlite")
upload_pfad: Path = Path("uploads")
datenbank_pfad: Path = Field(default=Path("data/kleiderboerse.sqlite"),
validation_alias="DATABASE_PATH")
upload_pfad: Path = Field(default=Path("uploads"), validation_alias="UPLOAD_PATH")
# Passwort für den Erfassungsbereich. Leer = gesperrt.
admin_passwort: str = ""
admin_passwort_hash: str = ""
admin_passwort: str = Field(default="", validation_alias="ADMIN_PASSWORD")
admin_passwort_hash: str = Field(default="", validation_alias="ADMIN_PASSWORD_HASH")
# Schlüssel, mit dem das Sitzungs-Cookie signiert wird. Ohne festen Wert
# wird beim Start einer erzeugt - dann ist man nach jedem Neustart
# abgemeldet. Im Betrieb also setzen.
secret_key: str = Field(default="", validation_alias="SECRET_KEY")
# Secure-Kennzeichen am Sitzungs-Cookie. Standard an; nur zum Testen
# über http://localhost abschalten, sonst schickt der Browser das
# Cookie gar nicht erst mit.
cookie_nur_https: bool = Field(default=True, validation_alias="COOKIE_HTTPS_ONLY")
# Grösse eines einzelnen Bild-Uploads. Greift zusätzlich zur Prüfung in
# images.py; ein Handyfoto liegt heute bei 3-12 MB.
max_upload_bytes: int = 10 * 1024 * 1024
max_upload_bytes: int = Field(default=10 * 1024 * 1024,
validation_alias="MAX_UPLOAD_BYTES")
# Rate-Limits (pro IP). Reservieren ist ohne Anmeldung möglich, also
# steht hier nichts anderes zwischen einem Skript und dem Bestand.
limit_reservieren: str = Field(default="5/hour", validation_alias="RATE_LIMIT_RESERVE")
limit_anmelden: str = Field(default="10/hour", validation_alias="RATE_LIMIT_LOGIN")
# Nach dieser Zeit ohne Aktivität wird abgemeldet.
sitzung_max_ruhe_sekunden: int = Field(default=7200,
validation_alias="SESSION_IDLE_SECONDS")
@property
def datenbank_url(self) -> str:
return f"sqlite:///{self.datenbank_pfad}"
@property
def admin_aktiv(self) -> bool:
"""Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt.
Bewusst kein mitgeliefertes Standardpasswort: das stünde in der
Anleitung und wäre damit allgemein bekannt.
"""
return bool(self.admin_passwort_hash or self.admin_passwort)
@lru_cache
def einstellungen() -> Einstellungen:
+82 -2
View File
@@ -5,15 +5,23 @@ aus - ein Dienst, eine Datenbank, kein getrenntes Frontend (Variante A aus
Plan.md).
"""
import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from starlette.middleware.sessions import SessionMiddleware
from app.config import einstellungen
from app.database import SessionLocal, engine
from app.routers import categories, images, items
from app.routers import auth, categories, images, items
from app.security import limiter
from app.seed import stammdaten_anlegen
log = logging.getLogger("kleiderboerse")
@asynccontextmanager
async def lebenszyklus(_app: FastAPI):
@@ -24,6 +32,12 @@ async def lebenszyklus(_app: FastAPI):
cfg.upload_pfad.mkdir(parents=True, exist_ok=True)
with SessionLocal() as db:
stammdaten_anlegen(db)
if not cfg.admin_aktiv:
log.warning(
"Kein ADMIN_PASSWORD gesetzt - der Erfassungsbereich ist gesperrt. "
"Die Galerie funktioniert."
)
yield
engine.dispose()
@@ -35,11 +49,77 @@ app = FastAPI(
lifespan=lebenszyklus,
)
_cfg = einstellungen()
# Ohne festen Schlüssel wird bei jedem Start ein neuer erzeugt - dann ist
# man nach einem Neustart abgemeldet. Für den Betrieb SECRET_KEY setzen.
_schluessel = _cfg.secret_key or secrets.token_urlsafe(48)
if not _cfg.secret_key:
log.warning("Kein SECRET_KEY gesetzt - Anmeldungen überleben keinen Neustart.")
app.add_middleware(
SessionMiddleware,
secret_key=_schluessel,
session_cookie="kleiderboerse",
same_site="lax", # blockt Cookies bei seitenfremden POSTs (CSRF-Schutz)
https_only=_cfg.cookie_nur_https,
max_age=_cfg.sitzung_max_ruhe_sekunden,
)
app.state.limiter = limiter
@app.exception_handler(RateLimitExceeded)
async def rate_limit_ueberschritten(_request: Request, _exc: RateLimitExceeded):
return JSONResponse(
status_code=429,
content={"detail": "Zu viele Versuche. Bitte später nochmal probieren."},
)
@app.middleware("http")
async def sicherheits_header(request: Request, call_next):
"""Schutz-Header für jede Antwort.
Wichtigster Punkt ist die CSP: sollte trotz Maskierung einmal
Fremd-Markup in eine Seite geraten, verhindert script-src 'self', dass
daraus ausführbarer Code wird.
'unsafe-inline' bei style-src ist für Tailwind-Utility-Klassen nicht
nötig, wohl aber für einzelne style-Attribute im Markup; script-src
bleibt bewusst ohne.
"""
antwort = await call_next(request)
antwort.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
"object-src 'none'; base-uri 'none'; form-action 'self'; "
"frame-ancestors 'none'",
)
antwort.headers.setdefault("X-Content-Type-Options", "nosniff")
antwort.headers.setdefault("Referrer-Policy", "same-origin")
antwort.headers.setdefault("X-Frame-Options", "DENY")
# Die Galerie ist frei zugänglich, soll aber nicht im Suchindex landen:
# sonst sind Fotos und Texte noch auffindbar, wenn die Börse längst
# abgeräumt ist.
antwort.headers.setdefault("X-Robots-Tag", "noindex, nofollow")
return antwort
app.include_router(auth.router)
app.include_router(categories.router)
app.include_router(items.router)
app.include_router(images.router)
@app.get("/robots.txt", include_in_schema=False)
def robots():
from fastapi.responses import PlainTextResponse
return PlainTextResponse("User-agent: *\nDisallow: /\n")
@app.get("/health", tags=["Betrieb"])
def health():
"""Wird vom Docker-Healthcheck und von der CI benutzt."""
+58
View File
@@ -0,0 +1,58 @@
"""Anmeldung des Betreibers."""
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from app import security
from app.config import einstellungen
router = APIRouter(prefix="/api/v1/auth", tags=["Anmeldung"])
class AnmeldenRein(BaseModel):
passwort: str = Field(min_length=1)
class ZustandAus(BaseModel):
angemeldet: bool
admin_eingerichtet: bool
@router.get("/me", response_model=ZustandAus)
def zustand(request: Request):
return ZustandAus(
angemeldet=security.ist_betreiber(request),
admin_eingerichtet=einstellungen().admin_aktiv,
)
@router.post("/login", response_model=ZustandAus)
@security.limiter.limit(lambda: einstellungen().limit_anmelden)
def login(request: Request, daten: AnmeldenRein):
"""Meldet den Betreiber an.
Das Rate-Limit ist hier wichtiger als es aussieht: die Anwendung ist aus
dem Internet erreichbar, es gibt genau ein Konto, und ohne Bremse liesse
sich das Passwort in Ruhe durchprobieren.
"""
hash_wert = security.hinterlegter_hash()
if hash_wert is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"Der Erfassungsbereich ist nicht eingerichtet: "
"ADMIN_PASSWORD oder ADMIN_PASSWORD_HASH setzen."
),
)
if not security.passwort_stimmt(daten.passwort, hash_wert):
raise HTTPException(status_code=401, detail="Passwort stimmt nicht.")
security.anmelden(request)
return ZustandAus(angemeldet=True, admin_eingerichtet=True)
@router.post("/logout", response_model=ZustandAus)
def logout(request: Request):
security.abmelden(request)
return ZustandAus(angemeldet=False, admin_eingerichtet=einstellungen().admin_aktiv)
+6 -2
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFil
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app import crud, images
from app import crud, images, security
from app.config import einstellungen
from app.database import get_db
from app.models import Item, ItemBild
@@ -63,6 +63,7 @@ def bild_ausliefern(dateiname: str):
response_model=list[BildAus],
status_code=status.HTTP_201_CREATED,
tags=["Bilder"],
dependencies=[security.BetreiberNoetig],
)
def bilder_hochladen(
item_id: int,
@@ -86,6 +87,7 @@ def bilder_hochladen(
response_model=list[ItemAus],
status_code=status.HTTP_201_CREATED,
tags=["Bilder"],
dependencies=[security.BetreiberNoetig],
)
def stapel_import(
dateien: list[UploadFile] = File(...),
@@ -119,7 +121,8 @@ def stapel_import(
# ----------------------------------------------------------------- Ändern ---
@router.post("/api/v1/images/{bild_id}/primary", response_model=BildAus, tags=["Bilder"])
@router.post("/api/v1/images/{bild_id}/primary", response_model=BildAus,
tags=["Bilder"], dependencies=[security.BetreiberNoetig])
def hauptbild_setzen(bild_id: int, db: Session = Depends(get_db)):
bild = db.get(ItemBild, bild_id)
if bild is None:
@@ -131,6 +134,7 @@ def hauptbild_setzen(bild_id: int, db: Session = Depends(get_db)):
"/api/v1/images/{bild_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["Bilder"],
dependencies=[security.BetreiberNoetig],
)
def bild_loeschen(bild_id: int, db: Session = Depends(get_db)):
bild = db.get(ItemBild, bild_id)
+31 -9
View File
@@ -5,11 +5,11 @@ Berechtigungslogik der Reservierung steckt aber schon hier, weil sie den
Datenfluss bestimmt.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import crud, images
from app import crud, images, security
from app.config import einstellungen
from app.database import get_db
from app.models import Geschlecht, Item, Saison, Status
@@ -77,7 +77,8 @@ def item_anzeigen(item_id: int, db: Session = Depends(get_db)):
return _als_antwort(_item_oder_404(db, item_id))
@router.post("", response_model=ItemAus, status_code=status.HTTP_201_CREATED)
@router.post("", response_model=ItemAus, status_code=status.HTTP_201_CREATED,
dependencies=[security.BetreiberNoetig])
def item_anlegen(daten: ItemAnlegen, db: Session = Depends(get_db)):
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
raise HTTPException(status_code=422, detail="Kategorie gibt es nicht.")
@@ -94,7 +95,8 @@ def item_anlegen(daten: ItemAnlegen, db: Session = Depends(get_db)):
return _als_antwort(item)
@router.patch("/{item_id}", response_model=ItemAus)
@router.patch("/{item_id}", response_model=ItemAus,
dependencies=[security.BetreiberNoetig])
def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
@@ -110,7 +112,8 @@ def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)
return _als_antwort(geaendert)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT,
dependencies=[security.BetreiberNoetig])
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
# ON DELETE CASCADE räumt nur die Zeilen in item_images weg. Die Dateien
@@ -124,7 +127,25 @@ def item_loeschen(item_id: int, db: Session = Depends(get_db)):
# --------------------------------------------------------- Reservierung ---
@router.post("/{item_id}/reserve", response_model=ReservierenAus)
def item_reservieren(item_id: int, daten: ReservierenRein, db: Session = Depends(get_db)):
# shared_limit statt limit: slowapi zählt sonst pro URL-Pfad, und weil jedes
# Kleidungsstück eine eigene URL hat, bekäme jedes seinen eigenen Zähler -
# ein Skript könnte also den gesamten Bestand reservieren, ohne je an ein
# Limit zu stossen. Der feste scope fasst alle Reservierungen zusammen.
@security.limiter.shared_limit(
lambda: einstellungen().limit_reservieren, scope="reservieren"
)
def item_reservieren(
request: Request,
item_id: int,
daten: ReservierenRein,
db: Session = Depends(get_db),
):
"""Reserviert ein Kleidungsstück - bewusst ohne Anmeldung.
Die Galerie ist frei zugänglich, es gibt also keine vorgelagerte Hürde.
Das Rate-Limit ist damit das Einzige, was zwischen einem Skript und dem
gesamten Bestand steht.
"""
item = _item_oder_404(db, item_id)
if item.status == Status.draft.value:
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
@@ -138,10 +159,10 @@ def item_reservieren(item_id: int, daten: ReservierenRein, db: Session = Depends
@router.post("/{item_id}/release", response_model=ItemAus)
def item_freigeben(
request: Request,
item_id: int,
token: str | None = Query(default=None),
db: Session = Depends(get_db),
ist_betreiber: bool = False, # wird in Phase 3 durch die Anmeldung ersetzt
):
"""Hebt eine Reservierung auf.
@@ -153,7 +174,7 @@ def item_freigeben(
item = _item_oder_404(db, item_id)
if item.status != Status.reserved.value:
raise HTTPException(status_code=409, detail="Dieses Kleidungsstück ist nicht reserviert.")
if not ist_betreiber and not crud.token_gueltig(item, token):
if not security.betreiber_optional(request) and not crud.token_gueltig(item, token):
raise HTTPException(
status_code=403,
detail="Zum Aufheben braucht es den Link aus der Reservierung.",
@@ -161,7 +182,8 @@ def item_freigeben(
return _als_antwort(crud.freigeben(db, item))
@router.post("/{item_id}/mark-given", response_model=ItemAus)
@router.post("/{item_id}/mark-given", response_model=ItemAus,
dependencies=[security.BetreiberNoetig])
def item_abgegeben(item_id: int, db: Session = Depends(get_db)):
item = _item_oder_404(db, item_id)
return _als_antwort(crud.als_abgegeben_markieren(db, item))
+151
View File
@@ -0,0 +1,151 @@
"""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:
return bcrypt.hashpw(klartext.encode(), bcrypt.gensalt()).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)