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:
+43
@@ -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", "*"]
|
||||||
+40
-5
@@ -9,29 +9,64 @@ gesperrt (siehe Phase 3) - ein mitgeliefertes Standardpasswort wäre
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Einstellungen(BaseSettings):
|
class Einstellungen(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
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
|
# Ablageorte. Im Container zeigen beide auf eingehängte Volumes, damit
|
||||||
# Datenbank und Bilder einen Neustart überleben.
|
# Datenbank und Bilder einen Neustart überleben.
|
||||||
datenbank_pfad: Path = Path("data/kleiderboerse.sqlite")
|
datenbank_pfad: Path = Field(default=Path("data/kleiderboerse.sqlite"),
|
||||||
upload_pfad: Path = Path("uploads")
|
validation_alias="DATABASE_PATH")
|
||||||
|
upload_pfad: Path = Field(default=Path("uploads"), validation_alias="UPLOAD_PATH")
|
||||||
|
|
||||||
# Passwort für den Erfassungsbereich. Leer = gesperrt.
|
# Passwort für den Erfassungsbereich. Leer = gesperrt.
|
||||||
admin_passwort: str = ""
|
admin_passwort: str = Field(default="", validation_alias="ADMIN_PASSWORD")
|
||||||
admin_passwort_hash: str = ""
|
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
|
# Grösse eines einzelnen Bild-Uploads. Greift zusätzlich zur Prüfung in
|
||||||
# images.py; ein Handyfoto liegt heute bei 3-12 MB.
|
# 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
|
@property
|
||||||
def datenbank_url(self) -> str:
|
def datenbank_url(self) -> str:
|
||||||
return f"sqlite:///{self.datenbank_pfad}"
|
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
|
@lru_cache
|
||||||
def einstellungen() -> Einstellungen:
|
def einstellungen() -> Einstellungen:
|
||||||
|
|||||||
+82
-2
@@ -5,15 +5,23 @@ aus - ein Dienst, eine Datenbank, kein getrenntes Frontend (Variante A aus
|
|||||||
Plan.md).
|
Plan.md).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
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.config import einstellungen
|
||||||
from app.database import SessionLocal, engine
|
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
|
from app.seed import stammdaten_anlegen
|
||||||
|
|
||||||
|
log = logging.getLogger("kleiderboerse")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lebenszyklus(_app: FastAPI):
|
async def lebenszyklus(_app: FastAPI):
|
||||||
@@ -24,6 +32,12 @@ async def lebenszyklus(_app: FastAPI):
|
|||||||
cfg.upload_pfad.mkdir(parents=True, exist_ok=True)
|
cfg.upload_pfad.mkdir(parents=True, exist_ok=True)
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
stammdaten_anlegen(db)
|
stammdaten_anlegen(db)
|
||||||
|
|
||||||
|
if not cfg.admin_aktiv:
|
||||||
|
log.warning(
|
||||||
|
"Kein ADMIN_PASSWORD gesetzt - der Erfassungsbereich ist gesperrt. "
|
||||||
|
"Die Galerie funktioniert."
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
@@ -35,11 +49,77 @@ app = FastAPI(
|
|||||||
lifespan=lebenszyklus,
|
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(categories.router)
|
||||||
app.include_router(items.router)
|
app.include_router(items.router)
|
||||||
app.include_router(images.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"])
|
@app.get("/health", tags=["Betrieb"])
|
||||||
def health():
|
def health():
|
||||||
"""Wird vom Docker-Healthcheck und von der CI benutzt."""
|
"""Wird vom Docker-Healthcheck und von der CI benutzt."""
|
||||||
|
|||||||
@@ -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,7 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFil
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import crud, images
|
from app import crud, images, security
|
||||||
from app.config import einstellungen
|
from app.config import einstellungen
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Item, ItemBild
|
from app.models import Item, ItemBild
|
||||||
@@ -63,6 +63,7 @@ def bild_ausliefern(dateiname: str):
|
|||||||
response_model=list[BildAus],
|
response_model=list[BildAus],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
tags=["Bilder"],
|
tags=["Bilder"],
|
||||||
|
dependencies=[security.BetreiberNoetig],
|
||||||
)
|
)
|
||||||
def bilder_hochladen(
|
def bilder_hochladen(
|
||||||
item_id: int,
|
item_id: int,
|
||||||
@@ -86,6 +87,7 @@ def bilder_hochladen(
|
|||||||
response_model=list[ItemAus],
|
response_model=list[ItemAus],
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
tags=["Bilder"],
|
tags=["Bilder"],
|
||||||
|
dependencies=[security.BetreiberNoetig],
|
||||||
)
|
)
|
||||||
def stapel_import(
|
def stapel_import(
|
||||||
dateien: list[UploadFile] = File(...),
|
dateien: list[UploadFile] = File(...),
|
||||||
@@ -119,7 +121,8 @@ def stapel_import(
|
|||||||
|
|
||||||
# ----------------------------------------------------------------- Ändern ---
|
# ----------------------------------------------------------------- Ä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)):
|
def hauptbild_setzen(bild_id: int, db: Session = Depends(get_db)):
|
||||||
bild = db.get(ItemBild, bild_id)
|
bild = db.get(ItemBild, bild_id)
|
||||||
if bild is None:
|
if bild is None:
|
||||||
@@ -131,6 +134,7 @@ def hauptbild_setzen(bild_id: int, db: Session = Depends(get_db)):
|
|||||||
"/api/v1/images/{bild_id}",
|
"/api/v1/images/{bild_id}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
tags=["Bilder"],
|
tags=["Bilder"],
|
||||||
|
dependencies=[security.BetreiberNoetig],
|
||||||
)
|
)
|
||||||
def bild_loeschen(bild_id: int, db: Session = Depends(get_db)):
|
def bild_loeschen(bild_id: int, db: Session = Depends(get_db)):
|
||||||
bild = db.get(ItemBild, bild_id)
|
bild = db.get(ItemBild, bild_id)
|
||||||
|
|||||||
+31
-9
@@ -5,11 +5,11 @@ Berechtigungslogik der Reservierung steckt aber schon hier, weil sie den
|
|||||||
Datenfluss bestimmt.
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import crud, images
|
from app import crud, images, security
|
||||||
from app.config import einstellungen
|
from app.config import einstellungen
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Geschlecht, Item, Saison, Status
|
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))
|
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)):
|
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:
|
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.")
|
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)
|
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)):
|
def item_aendern(item_id: int, daten: ItemAendern, db: Session = Depends(get_db)):
|
||||||
item = _item_oder_404(db, item_id)
|
item = _item_oder_404(db, item_id)
|
||||||
if daten.category_id is not None and crud.kategorie_holen(db, daten.category_id) is None:
|
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)
|
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)):
|
def item_loeschen(item_id: int, db: Session = Depends(get_db)):
|
||||||
item = _item_oder_404(db, item_id)
|
item = _item_oder_404(db, item_id)
|
||||||
# ON DELETE CASCADE räumt nur die Zeilen in item_images weg. Die Dateien
|
# 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 ---
|
# --------------------------------------------------------- Reservierung ---
|
||||||
|
|
||||||
@router.post("/{item_id}/reserve", response_model=ReservierenAus)
|
@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)
|
item = _item_oder_404(db, item_id)
|
||||||
if item.status == Status.draft.value:
|
if item.status == Status.draft.value:
|
||||||
raise HTTPException(status_code=404, detail="Kleidungsstück nicht gefunden.")
|
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)
|
@router.post("/{item_id}/release", response_model=ItemAus)
|
||||||
def item_freigeben(
|
def item_freigeben(
|
||||||
|
request: Request,
|
||||||
item_id: int,
|
item_id: int,
|
||||||
token: str | None = Query(default=None),
|
token: str | None = Query(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
ist_betreiber: bool = False, # wird in Phase 3 durch die Anmeldung ersetzt
|
|
||||||
):
|
):
|
||||||
"""Hebt eine Reservierung auf.
|
"""Hebt eine Reservierung auf.
|
||||||
|
|
||||||
@@ -153,7 +174,7 @@ def item_freigeben(
|
|||||||
item = _item_oder_404(db, item_id)
|
item = _item_oder_404(db, item_id)
|
||||||
if item.status != Status.reserved.value:
|
if item.status != Status.reserved.value:
|
||||||
raise HTTPException(status_code=409, detail="Dieses Kleidungsstück ist nicht reserviert.")
|
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(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Zum Aufheben braucht es den Link aus der Reservierung.",
|
detail="Zum Aufheben braucht es den Link aus der Reservierung.",
|
||||||
@@ -161,7 +182,8 @@ def item_freigeben(
|
|||||||
return _als_antwort(crud.freigeben(db, item))
|
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)):
|
def item_abgegeben(item_id: int, db: Session = Depends(get_db)):
|
||||||
item = _item_oder_404(db, item_id)
|
item = _item_oder_404(db, item_id)
|
||||||
return _als_antwort(crud.als_abgegeben_markieren(db, item))
|
return _als_antwort(crud.als_abgegeben_markieren(db, item))
|
||||||
|
|||||||
+151
@@ -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)
|
||||||
@@ -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:
|
||||||
@@ -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 "$@"
|
||||||
@@ -11,3 +11,9 @@ pillow>=11.0,<12
|
|||||||
# Datei-Feld meist selbst nach JPEG um, aber nicht zuverlässig - und beim
|
# Datei-Feld meist selbst nach JPEG um, aber nicht zuverlässig - und beim
|
||||||
# Stapel-Import fiele ein einzelnes nicht lesbares Bild sonst still weg.
|
# Stapel-Import fiele ein einzelnes nicht lesbares Bild sonst still weg.
|
||||||
pillow-heif>=0.20,<1
|
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
|
||||||
|
|||||||
+62
-15
@@ -5,14 +5,47 @@ Verzeichnis - keine geteilten Zustände zwischen Tests, und die echte
|
|||||||
Datenbank unter data/ wird nie angefasst.
|
Datenbank unter data/ wird nie angefasst.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import os
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from app.config import einstellungen
|
TEST_PASSWORT = "test-passwort-123"
|
||||||
from app.database import Basis, engine_erzeugen, get_db
|
|
||||||
from app.main import app
|
# MUSS vor dem Import von app.main stehen: dort wird die SessionMiddleware
|
||||||
from app.seed import stammdaten_anlegen
|
# 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)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -25,7 +58,7 @@ def upload_ordner(tmp_path, monkeypatch):
|
|||||||
"""
|
"""
|
||||||
ziel = tmp_path / "uploads"
|
ziel = tmp_path / "uploads"
|
||||||
ziel.mkdir()
|
ziel.mkdir()
|
||||||
monkeypatch.setenv("UPLOAD_PFAD", str(ziel))
|
monkeypatch.setenv("UPLOAD_PATH", str(ziel))
|
||||||
einstellungen.cache_clear()
|
einstellungen.cache_clear()
|
||||||
yield ziel
|
yield ziel
|
||||||
einstellungen.cache_clear()
|
einstellungen.cache_clear()
|
||||||
@@ -43,20 +76,34 @@ def db_sitzung(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db_sitzung):
|
def gast(db_sitzung):
|
||||||
"""TestClient, der auf dieselbe Testdatenbank zeigt wie 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():
|
def _db():
|
||||||
yield db_sitzung
|
yield db_sitzung
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = _db
|
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)
|
yield TestClient(app)
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def kategorie_id(client):
|
def client(gast):
|
||||||
return client.get("/api/v1/categories").json()[0]["id"]
|
"""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"]
|
||||||
|
|||||||
@@ -43,21 +43,26 @@ def test_freigeben_mit_token(client, item_id):
|
|||||||
assert antwort.json()["reserved_by"] is None
|
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
|
"""Der Kern der Entscheidung: sonst löscht jeder Besucher fremde
|
||||||
Reservierungen."""
|
Reservierungen.
|
||||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
|
||||||
|
|
||||||
assert client.post(f"/api/v1/items/{item_id}/release").status_code == 403
|
Bewusst als Gast geprüft - der angemeldete Betreiber darf das sehr wohl
|
||||||
assert client.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403
|
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
|
# 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["status"] == "reserved"
|
||||||
assert item["reserved_by"] == "Familie Meier"
|
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
|
"""Sonst könnte ein alter Link später die Reservierung einer anderen
|
||||||
Person aufheben."""
|
Person aufheben."""
|
||||||
token = client.post(
|
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}/release?token={token}")
|
||||||
|
|
||||||
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Zweite"})
|
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):
|
def test_abgegeben_loescht_den_namen(client, item_id):
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user