diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1371f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM python:3.11-slim + +# libheif wird von pillow-heif mitgeliefert; hier nur die Laufzeit-Bibliotheken, +# die Pillow für JPEG/PNG/WebP braucht. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini ./ +COPY alembic/ ./alembic/ +COPY app/ ./app/ +COPY docker-entrypoint.sh /usr/local/bin/kleiderboerse-entrypoint.sh +RUN chmod +x /usr/local/bin/kleiderboerse-entrypoint.sh + +# Datenbank und Bilder liegen in Volumes, ausserhalb des Codes. +ENV DATABASE_PATH=/data/kleiderboerse.sqlite \ + UPLOAD_PATH=/uploads \ + PYTHONUNBUFFERED=1 + +# Nicht als root laufen. Die beiden Verzeichnisse gehören dem Dienstbenutzer, +# der Code dagegen root - so kann der Webdienst seinen eigenen Code nicht +# überschreiben. +RUN useradd --system --create-home --shell /usr/sbin/nologin kleider \ + && mkdir -p /data /uploads \ + && chown kleider:kleider /data /uploads +USER kleider + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +ENTRYPOINT ["kleiderboerse-entrypoint.sh"] +# --proxy-headers: hinter einem Reverse-Proxy stünde sonst dessen IP in +# jedem Rate-Limit, und ein einzelner Besucher sperrte alle anderen aus. +# Ausserdem erkennt die App darüber, ob der Browser per HTTPS verbunden ist. +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/app/config.py b/app/config.py index 0704d0b..32378d3 100644 --- a/app/config.py +++ b/app/config.py @@ -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: diff --git a/app/main.py b/app/main.py index c7c60ea..9322ee9 100644 --- a/app/main.py +++ b/app/main.py @@ -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.""" diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..982c93b --- /dev/null +++ b/app/routers/auth.py @@ -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) diff --git a/app/routers/images.py b/app/routers/images.py index 3ad0f84..a87b6de 100644 --- a/app/routers/images.py +++ b/app/routers/images.py @@ -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) diff --git a/app/routers/items.py b/app/routers/items.py index b768ad0..3cb26cc 100644 --- a/app/routers/items.py +++ b/app/routers/items.py @@ -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)) diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..c49bf10 --- /dev/null +++ b/app/security.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dada8c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# Zum Entwickeln und Testen: baut das Image aus dem Quellcode. +# Für den Betrieb mit fertigem Image siehe docker-compose.betrieb.yml. +services: + app: + build: . + ports: + - "8090:8000" + + environment: + # Passwort für den Erfassungsbereich. Ohne Wert bleibt er gesperrt; + # die Galerie funktioniert trotzdem. + ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" + + # Signiert das Sitzungs-Cookie. Ohne festen Wert ist man nach jedem + # Neustart abgemeldet. + SECRET_KEY: "${SECRET_KEY:-}" + + # ACHTUNG - nur zum lokalen Testen über http://localhost. + # Im Betrieb hinter HTTPS unbedingt auf "true" lassen, sonst geht das + # Sitzungs-Cookie auch über unverschlüsselte Verbindungen mit. + COOKIE_HTTPS_ONLY: "false" + + volumes: + - kleiderboerse-db:/data + - kleiderboerse-bilder:/uploads + +volumes: + kleiderboerse-db: + kleiderboerse-bilder: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..94e7ce2 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Bringt die Datenbank auf den aktuellen Stand, bevor der Dienst startet. +# Bei einer leeren Datei legt Alembic die Tabellen an; bei einer +# vorhandenen werden nur fehlende Migrationen nachgezogen. Die Stammdaten +# (Kategorien, Grössen) ergänzt die Anwendung beim Start. +echo "[kleiderboerse] Datenbank prüfen: ${DATABASE_PATH:-data/kleiderboerse.sqlite}" +alembic upgrade head + +if [ -z "${ADMIN_PASSWORD:-}${ADMIN_PASSWORD_HASH:-}" ]; then + echo "[kleiderboerse] Kein ADMIN_PASSWORD gesetzt - der Erfassungsbereich" + echo "[kleiderboerse] bleibt gesperrt. Die Galerie funktioniert." +fi + +if [ -z "${SECRET_KEY:-}" ]; then + echo "[kleiderboerse] Kein SECRET_KEY gesetzt - nach einem Neustart muss" + echo "[kleiderboerse] man sich neu anmelden." +fi + +exec "$@" diff --git a/requirements.txt b/requirements.txt index 4a14979..3bfc1da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,9 @@ pillow>=11.0,<12 # Datei-Feld meist selbst nach JPEG um, aber nicht zuverlässig - und beim # Stapel-Import fiele ein einzelnes nicht lesbares Bild sonst still weg. pillow-heif>=0.20,<1 +bcrypt>=4.2,<5 +# Signiert das Sitzungs-Cookie (von Starlettes SessionMiddleware benutzt). +itsdangerous>=2.2,<3 +# Rate-Limit: bei frei zugänglicher Galerie die einzige Bremse vor dem +# Reservieren-Endpunkt. +slowapi>=0.1.9,<0.2 diff --git a/tests/conftest.py b/tests/conftest.py index 73cd3ee..eaf615b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,14 +5,47 @@ Verzeichnis - keine geteilten Zustände zwischen Tests, und die echte Datenbank unter data/ wird nie angefasst. """ -import pytest -from fastapi.testclient import TestClient -from sqlalchemy.orm import sessionmaker +import os -from app.config import einstellungen -from app.database import Basis, engine_erzeugen, get_db -from app.main import app -from app.seed import stammdaten_anlegen +TEST_PASSWORT = "test-passwort-123" + +# MUSS vor dem Import von app.main stehen: dort wird die SessionMiddleware +# beim Import aufgebaut und liest cookie_nur_https genau einmal. Käme das +# erst als Fixture, bliebe das Cookie auf "Secure" - und der TestClient +# spricht über http://testserver, verwürfe es also stillschweigend. Die +# Anmeldung schlüge dann ohne erkennbaren Grund fehl. +os.environ.setdefault("COOKIE_HTTPS_ONLY", "false") +os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT) +os.environ.setdefault("SECRET_KEY", "nur-fuer-tests") + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 + +from app import security # noqa: E402 +from app.config import einstellungen # noqa: E402 +from app.database import Basis, engine_erzeugen, get_db # noqa: E402 +from app.main import app # noqa: E402 +from app.seed import stammdaten_anlegen # noqa: E402 + + +@pytest.fixture(autouse=True) +def admin_eingerichtet(monkeypatch): + """Setzt ein Passwort für die Tests und leert die Zwischenspeicher. + + autouse, weil sonst jeder Schreibzugriff mit 503 ("nicht eingerichtet") + scheitern würde - das ist das gewollte Verhalten ohne Passwort. + """ + monkeypatch.setenv("ADMIN_PASSWORD", TEST_PASSWORT) + monkeypatch.setenv("COOKIE_HTTPS_ONLY", "false") + einstellungen.cache_clear() + security.hash_zwischenspeicher_leeren() + # Rate-Limits zwischen Tests zurücksetzen, sonst schlägt der zehnte + # Anmeldeversuch über alle Tests hinweg zu. + security.limiter.reset() + yield + einstellungen.cache_clear() + security.hash_zwischenspeicher_leeren() @pytest.fixture(autouse=True) @@ -25,7 +58,7 @@ def upload_ordner(tmp_path, monkeypatch): """ ziel = tmp_path / "uploads" ziel.mkdir() - monkeypatch.setenv("UPLOAD_PFAD", str(ziel)) + monkeypatch.setenv("UPLOAD_PATH", str(ziel)) einstellungen.cache_clear() yield ziel einstellungen.cache_clear() @@ -43,20 +76,34 @@ def db_sitzung(tmp_path): @pytest.fixture -def client(db_sitzung): - """TestClient, der auf dieselbe Testdatenbank zeigt wie db_sitzung.""" +def gast(db_sitzung): + """Nicht angemeldeter Besucher - sieht die Galerie, darf reservieren. + + Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der + würde die echte Datenbank unter data/ anlegen und befüllen. Die + Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung). + """ def _db(): yield db_sitzung app.dependency_overrides[get_db] = _db - # Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der - # würde die echte Datenbank unter data/ anlegen und befüllen. Die - # Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung). yield TestClient(app) app.dependency_overrides.clear() @pytest.fixture -def kategorie_id(client): - return client.get("/api/v1/categories").json()[0]["id"] +def client(gast): + """Angemeldeter Betreiber - darf alles. + + Die meisten Tests brauchen das, weil Anlegen und Ändern seit Phase 3 + eine Anmeldung voraussetzen. + """ + antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT}) + assert antwort.status_code == 200, antwort.text + return gast + + +@pytest.fixture +def kategorie_id(gast): + return gast.get("/api/v1/categories").json()[0]["id"] diff --git a/tests/test_reservierung.py b/tests/test_reservierung.py index e81dd29..0d03ea7 100644 --- a/tests/test_reservierung.py +++ b/tests/test_reservierung.py @@ -43,21 +43,26 @@ def test_freigeben_mit_token(client, item_id): assert antwort.json()["reserved_by"] is None -def test_freigeben_ohne_token_wird_abgewiesen(client, item_id): +def test_freigeben_ohne_token_wird_abgewiesen(client, gast, item_id): """Der Kern der Entscheidung: sonst löscht jeder Besucher fremde - Reservierungen.""" - client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}) + Reservierungen. - assert client.post(f"/api/v1/items/{item_id}/release").status_code == 403 - assert client.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403 + Bewusst als Gast geprüft - der angemeldete Betreiber darf das sehr wohl + ohne Token, damit er eine Missbrauchswelle aufräumen kann. + """ + client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}) + client.post("/api/v1/auth/logout") + + assert gast.post(f"/api/v1/items/{item_id}/release").status_code == 403 + assert gast.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403 # Reservierung steht unverändert - item = client.get(f"/api/v1/items/{item_id}").json() + item = gast.get(f"/api/v1/items/{item_id}").json() assert item["status"] == "reserved" assert item["reserved_by"] == "Familie Meier" -def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, item_id): +def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, gast, item_id): """Sonst könnte ein alter Link später die Reservierung einer anderen Person aufheben.""" token = client.post( @@ -66,7 +71,8 @@ def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, item_id): client.post(f"/api/v1/items/{item_id}/release?token={token}") client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Zweite"}) - assert client.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403 + client.post("/api/v1/auth/logout") + assert gast.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403 def test_abgegeben_loescht_den_namen(client, item_id): diff --git a/tests/test_zugriff.py b/tests/test_zugriff.py new file mode 100644 index 0000000..3e1dc4d --- /dev/null +++ b/tests/test_zugriff.py @@ -0,0 +1,170 @@ +"""Tests zum Zugriffsschutz. + +Grundsatz: lesen darf jeder, schreiben nur der Betreiber - mit der einen +Ausnahme des Reservierens. +""" + +import io + +import pytest +from PIL import Image + +from app import security +from app.config import einstellungen +from tests.conftest import TEST_PASSWORT + + +def bild_bytes() -> bytes: + puffer = io.BytesIO() + Image.new("RGB", (400, 300), (10, 10, 200)).save(puffer, "JPEG") + return puffer.getvalue() + + +# ------------------------------------------------------------- Anmeldung --- + +def test_anmelden_und_abmelden(gast): + assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False + + antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT}) + assert antwort.status_code == 200 + assert gast.get("/api/v1/auth/me").json()["angemeldet"] is True + + gast.post("/api/v1/auth/logout") + assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False + + +def test_falsches_passwort(gast): + antwort = gast.post("/api/v1/auth/login", json={"passwort": "daneben"}) + assert antwort.status_code == 401 + assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False + + +def test_ohne_passwort_ist_der_bereich_gesperrt(gast, monkeypatch, kategorie_id): + """Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen.""" + monkeypatch.delenv("ADMIN_PASSWORD", raising=False) + einstellungen.cache_clear() + security.hash_zwischenspeicher_leeren() + + assert gast.post("/api/v1/auth/login", json={"passwort": "x"}).status_code == 503 + antwort = gast.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id}) + assert antwort.status_code == 503 + # Die Galerie funktioniert trotzdem + assert gast.get("/api/v1/items").status_code == 200 + + +# ------------------------------------------------------- Schreibzugriffe --- + +@pytest.mark.parametrize( + "methode,pfad,rumpf", + [ + ("post", "/api/v1/items", {"json": {"size": "80"}}), + ("patch", "/api/v1/items/1", {"json": {"size": "92"}}), + ("delete", "/api/v1/items/1", {}), + ("post", "/api/v1/items/1/mark-given", {}), + ("post", "/api/v1/images/1/primary", {}), + ("delete", "/api/v1/images/1", {}), + ], +) +def test_schreiben_ohne_anmeldung_wird_abgewiesen(gast, methode, pfad, rumpf): + antwort = getattr(gast, methode)(pfad, **rumpf) + assert antwort.status_code == 401, f"{methode.upper()} {pfad} war offen!" + + +def test_hochladen_ohne_anmeldung_wird_abgewiesen(gast): + antwort = gast.post( + "/api/v1/items/batch-upload", + files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")}, + ) + assert antwort.status_code == 401 + + +def test_lesen_ist_offen(gast, client, kategorie_id): + item = client.post( + "/api/v1/items", json={"size": "80", "category_id": kategorie_id} + ).json() + client.post("/api/v1/auth/logout") + + assert gast.get("/api/v1/items").status_code == 200 + assert gast.get(f"/api/v1/items/{item['id']}").status_code == 200 + assert gast.get("/api/v1/categories").status_code == 200 + assert gast.get("/api/v1/sizes").status_code == 200 + + +def test_reservieren_bleibt_ohne_anmeldung_moeglich(gast, client, kategorie_id): + """Die bewusste Ausnahme - Nachbarn sollen kein Konto brauchen.""" + item = client.post( + "/api/v1/items", json={"size": "80", "category_id": kategorie_id} + ).json() + client.post("/api/v1/auth/logout") + + antwort = gast.post( + f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Familie Meier"} + ) + assert antwort.status_code == 200 + + +def test_betreiber_darf_ohne_token_freigeben(client, kategorie_id): + """Damit eine Missbrauchswelle aufgeräumt werden kann.""" + item = client.post( + "/api/v1/items", json={"size": "80", "category_id": kategorie_id} + ).json() + client.post(f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Wer auch immer"}) + + antwort = client.post(f"/api/v1/items/{item['id']}/release") + assert antwort.status_code == 200 + assert antwort.json()["status"] == "available" + + +# ------------------------------------------------------------ Rate-Limit --- + +def test_reservieren_ist_begrenzt(gast, client, kategorie_id): + """Bei offener Galerie die einzige Bremse vor dem Bestand. + + Geprüft wird der tatsächlich ausgelieferte Standardwert (5/Stunde), + nicht ein für den Test heruntergesetzter: sonst sagte der Test nichts + darüber aus, was im Betrieb gilt. Ein erster Anlauf mit genau fünf + Anfragen gegen ein Limit von fünf konnte gar nichts zeigen. + """ + assert einstellungen().limit_reservieren == "5/hour" + + ids = [ + client.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id}).json()["id"] + for _ in range(8) + ] + client.post("/api/v1/auth/logout") + + ergebnisse = [ + gast.post(f"/api/v1/items/{i}/reserve", json={"reserved_by": "Skript"}).status_code + for i in ids + ] + assert ergebnisse[:5] == [200] * 5, f"unerwartet früh gebremst: {ergebnisse}" + assert ergebnisse[5:] == [429] * 3, f"Rate-Limit griff nicht: {ergebnisse}" + + +def test_anmelden_ist_begrenzt(gast): + """Ein Konto, aus dem Internet erreichbar - ohne Bremse liesse sich das + Passwort in Ruhe durchprobieren.""" + assert einstellungen().limit_anmelden == "10/hour" + + codes = [ + gast.post("/api/v1/auth/login", json={"passwort": "falsch"}).status_code + for _ in range(13) + ] + assert codes[:10] == [401] * 10, f"unerwartet früh gebremst: {codes}" + assert 429 in codes[10:], f"Passwort liess sich unbegrenzt durchprobieren: {codes}" + + +# --------------------------------------------------------------- Header --- + +def test_sicherheits_header(gast): + kopf = gast.get("/api/v1/items").headers + assert "script-src 'self'" in kopf["content-security-policy"] + assert kopf["x-content-type-options"] == "nosniff" + assert kopf["x-frame-options"] == "DENY" + assert "noindex" in kopf["x-robots-tag"] + + +def test_robots_txt_sperrt_suchmaschinen(gast): + antwort = gast.get("/robots.txt") + assert antwort.status_code == 200 + assert "Disallow: /" in antwort.text