diff --git a/DEVLOG.md b/DEVLOG.md index a9d9149..df6493f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2799,3 +2799,155 @@ Keine Commits in dieser Session. - flutter_app/pubspec.yaml | 3 + --- +## 2026-09-04 18:38 – 18:38 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** flutter_app + +### Commits +- 0909dac Flutter-Tests: ApiClient, Offline-Queue, Login-/Objektliste-Widgets + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:38 – 18:38 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** flutter_app + +### Commits +- 0909dac Flutter-Tests: ApiClient, Offline-Queue, Login-/Objektliste-Widgets + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:39 – 18:39 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:39 – 18:39 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:39 – 18:39 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:40 – 18:40 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:40 – 18:40 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- +## 2026-09-04 18:40 – 18:40 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 ++++ +- flutter_app/DEVLOG.md | 16 ++++ +- flutter_app/README.md | 14 +++- +- flutter_app/lib/api/api_client.dart | 15 ++-- +- flutter_app/pubspec.yaml | 2 + +- flutter_app/test/api_client_test.dart | 90 ++++++++++++++++++++ +- flutter_app/test/offline_queue_test.dart | 96 ++++++++++++++++++++++ +- flutter_app/test/widget/login_screen_test.dart | 57 +++++++++++++ +- .../test/widget/objekt_list_screen_test.dart | 51 ++++++++++++ + +--- diff --git a/backend/alembic/versions/0007_indizes_und_unique_constraints.py b/backend/alembic/versions/0007_indizes_und_unique_constraints.py new file mode 100644 index 0000000..2d2672c --- /dev/null +++ b/backend/alembic/versions/0007_indizes_und_unique_constraints.py @@ -0,0 +1,63 @@ +"""Review-Funde (postgres-/sql-expert): fehlende FK-Indizes + partielle Unique- +Constraints gegen Race Conditions (doppelte aktive Kontrolle/Mindermenge) + +Revision ID: 0007_indizes_und_unique_constraints +Revises: 0006_add_eskalation_felder +Create Date: 2026-09-04 + +Zwei unabhängige Fixes aus dem Agenten-Review dieser Session: +1. Partielle Unique-Indizes als DB-Sicherheitsnetz gegen die TOCTOU-Race + zwischen Check und Insert in starte_kontrolle()/genehmigen() - Anwendungscode + fängt die resultierende IntegrityError bereits ab (lifecycle.py, mindermenge.py). +2. Fehlende Indizes auf häufig gefilterten Fremdschlüsseln (Eskalations-Cron, + Kontroll-Sperre-Check, Dashboard-Ablaufdatum-Abfrage u.a.). +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0007_indizes_und_unique_constraints" +down_revision: Union[str, None] = "0006_add_eskalation_felder" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE UNIQUE INDEX idx_kontrolle_eine_aktive_je_objekt + ON kontrolle (objekt_id) WHERE status = 'in_bearbeitung'; + CREATE UNIQUE INDEX idx_mindermenge_eine_aktive_je_fehlbestand + ON mindermengen_genehmigung (fehlbestand_id) WHERE status = 'aktiv'; + + CREATE INDEX idx_fehlbestand_material ON fehlbestand (material_id); + CREATE INDEX idx_fehlbestand_status_entstanden ON fehlbestand (status, entstanden_am); + CREATE INDEX idx_kontrolle_objekt_status ON kontrolle (objekt_id, status); + CREATE INDEX idx_kontrollposition_kontrolle ON kontrollposition (kontrolle_id); + CREATE INDEX idx_zustaendigkeit_objekt ON zustaendigkeit (objekt_id); + CREATE INDEX idx_zustaendigkeit_standort ON zustaendigkeit (standort_id); + CREATE INDEX idx_benutzer_rolle_rolle ON benutzer_rolle (rolle); + CREATE INDEX idx_objektposition_material ON objektposition (material_id); + CREATE INDEX idx_nachfuellung_fehlbestand ON nachfuellung (fehlbestand_id); + CREATE INDEX idx_objektposition_ablauf ON objektposition (ablaufdatum) WHERE ablaufdatum IS NOT NULL; + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DROP INDEX idx_objektposition_ablauf; + DROP INDEX idx_nachfuellung_fehlbestand; + DROP INDEX idx_objektposition_material; + DROP INDEX idx_benutzer_rolle_rolle; + DROP INDEX idx_zustaendigkeit_standort; + DROP INDEX idx_zustaendigkeit_objekt; + DROP INDEX idx_kontrollposition_kontrolle; + DROP INDEX idx_kontrolle_objekt_status; + DROP INDEX idx_fehlbestand_status_entstanden; + DROP INDEX idx_fehlbestand_material; + DROP INDEX idx_mindermenge_eine_aktive_je_fehlbestand; + DROP INDEX idx_kontrolle_eine_aktive_je_objekt; + """ + ) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index f8e7373..3392005 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -1,11 +1,12 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user -from app.core.security import create_access_token, verify_password +from app.core.rate_limit import limiter +from app.core.security import create_access_token, verify_password_konstante_zeit from app.db.session import get_db from app.models.auth import Benutzer @@ -25,17 +26,20 @@ class MeResponse(BaseModel): @router.post("/auth/login", response_model=TokenResponse) +@limiter.limit("5/minute") async def login( + request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db), ) -> TokenResponse: result = await db.execute(select(Benutzer).where(Benutzer.login == form_data.username)) benutzer = result.scalar_one_or_none() - if ( - benutzer is None - or not benutzer.aktiv - or not verify_password(form_data.password, benutzer.passwort_hash) - ): + # Passwort-Vergleich läuft IMMER (auch bei unbekanntem Login), damit die + # Antwortzeit nicht verrät, ob ein Login existiert (jwt-expert-Review-Fund). + passwort_ok = verify_password_konstante_zeit( + form_data.password, benutzer.passwort_hash if benutzer else None + ) + if benutzer is None or not benutzer.aktiv or not passwort_ok: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Login oder Passwort falsch", diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..b892d68 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,8 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +# App-seitiges Rate-Limiting als Verteidigung in der Tiefe (owasp-Review-Fund): +# nginx begrenzt /auth/login bereits auf 5r/m (deploy/nginx_mabea.conf.template), +# aber das schützt nicht, wenn der Backend-Port direkt erreichbar ist (z.B. +# Fehlkonfiguration, Firewall-Lücke) oder nginx umgangen wird. +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index eb525b7..e2cf445 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -7,6 +7,11 @@ from app.core.app_settings import settings _pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# Konstanter Dummy-Hash für Timing-Angriff-Schutz (jwt-expert-Review-Fund): +# ohne existierenden Benutzer kurzschließt `benutzer is None or verify_password(...)` +# den bcrypt-Vergleich, was per Antwortzeit verrät, ob ein Login existiert. +_DUMMY_HASH = _pwd_context.hash("kein-echtes-passwort-nur-fuer-konstante-antwortzeit") + def hash_password(password: str) -> str: return _pwd_context.hash(password) @@ -16,6 +21,12 @@ def verify_password(plain_password: str, password_hash: str) -> bool: return _pwd_context.verify(plain_password, password_hash) +def verify_password_konstante_zeit(plain_password: str, password_hash: str | None) -> bool: + """Wie verify_password, aber prüft immer gegen einen Hash (Dummy, falls der + Benutzer nicht existiert) - verhindert Login-Enumeration per Antwortzeit.""" + return _pwd_context.verify(plain_password, password_hash or _DUMMY_HASH) + + def create_access_token(*, subject: str) -> str: # Bewusst KEINE Rollen im Token: get_current_user liest Rollen bei jedem Request # frisch aus der DB (Rollenänderung wirkt sofort, kein Token-Refresh nötig). @@ -30,6 +41,11 @@ class InvalidTokenError(Exception): def decode_access_token(token: str) -> dict: try: - return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + return jwt.decode( + token, + settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + options={"require": ["exp", "sub"]}, + ) except jwt.PyJWTError as exc: raise InvalidTokenError(str(exc)) from exc diff --git a/backend/app/db/session.py b/backend/app/db/session.py index b233c7d..4d1e8f5 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -4,7 +4,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn from app.core.app_settings import settings -engine = create_async_engine(settings.database_url, pool_pre_ping=True) +# Explizite Pool-Grenzen (postgres-expert-Review-Fund): ohne das erzeugt jeder +# uvicorn-Worker einen eigenen Default-Pool (5 + 10 Overflow), was bei mehreren +# Workern auf dem 4GB-VPS PostgreSQL max_connections (per Tuning-Config auf 50 +# begrenzt, siehe deploy/install_server.sh) sprengen kann. pool_recycle gegen +# von der DB nach Idle-Timeout gekappte Verbindungen. +engine = create_async_engine( + settings.database_url, + pool_pre_ping=True, + pool_size=5, + max_overflow=5, + pool_recycle=1800, +) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/backend/app/main.py b/backend/app/main.py index fe83f19..9a97f65 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,15 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded import app.models # noqa: F401 (alle ORM-Modelle vollständig an Base.metadata # registrieren, unabhängig davon, welche Endpunkte tatsächlich verdrahtet sind - # sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py) from app.api.v1.api import api_router from app.core.app_settings import settings +from app.core.rate_limit import limiter from app.db.session import engine _PLACEHOLDER_JWT_SECRET = "change-me-to-a-long-random-value" @@ -24,4 +27,6 @@ async def lifespan(app: FastAPI): app = FastAPI(title="MABEA", version="0.1.0", lifespan=lifespan) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.include_router(api_router, prefix="/api/v1") diff --git a/backend/app/services/fehlbestand.py b/backend/app/services/fehlbestand.py index 144f555..64b9ba3 100644 --- a/backend/app/services/fehlbestand.py +++ b/backend/app/services/fehlbestand.py @@ -38,6 +38,13 @@ async def nachfuellen( Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt - "Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen. """ + # Row-Lock gegen Lost-Update bei gleichzeitiger Nachfüllung derselben Position + # (z. B. zwei Stationen erfassen parallel, sql-expert-Review-Fund) - blockiert + # bis Transaktionsende (get_db committet/rollbacked nach dem Request), danach + # sieht der zweite Aufruf bereits die aktualisierte istmenge/status. + result = await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update()) + fehlbestand = result.scalar_one() + if fehlbestand.status == FehlbestandStatus.erledigt: raise FehlbestandBereitsErledigtError() @@ -46,10 +53,12 @@ async def nachfuellen( neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge) result = await db.execute( - select(Objektposition).where( + select(Objektposition) + .where( Objektposition.objekt_id == fehlbestand.objekt_id, Objektposition.material_id == fehlbestand.material_id, ) + .with_for_update() ) objektposition = result.scalar_one_or_none() if objektposition is not None: diff --git a/backend/app/services/kontrolle/lifecycle.py b/backend/app/services/kontrolle/lifecycle.py index 1476e9c..4315ec8 100644 --- a/backend/app/services/kontrolle/lifecycle.py +++ b/backend/app/services/kontrolle/lifecycle.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.models.fehlbestand import Fehlbestand, FehlbestandStatus @@ -60,8 +61,22 @@ async def starte_kontrolle( status=KontrollStatus.in_bearbeitung, gestartet_am=datetime.now(timezone.utc), ) - db.add(neue_kontrolle) - await db.flush() + try: + # SAVEPOINT statt vollem Rollback: ein Fehlschlag hier darf die bereits + # oben (Übernahme-Zweig) protokollierte Historie/Abbruch der alten + # Kontrolle nicht mit verwerfen. + async with db.begin_nested(): + db.add(neue_kontrolle) + await db.flush() + except IntegrityError as exc: + # Sicherheitsnetz gegen den partiellen Unique-Index (Migration 0007): + # zwei parallele Requests haben beide die aktive_kontrolle()-Prüfung + # oben passiert (TOCTOU, sql-expert-Review-Fund) - hier verliert der + # zweite Request kontrolliert statt mit rohem DB-Fehler. + laufende_jetzt = await aktive_kontrolle(db, objekt_id) + if laufende_jetzt is not None: + raise ObjektGesperrtError(laufende_jetzt) from exc + raise await _lasse_mindermengen_ablaufen( db, objekt_id=objekt_id, neue_kontrolle=neue_kontrolle, zustaendiger_server_id=zustaendiger_server_id diff --git a/backend/app/services/mindermenge.py b/backend/app/services/mindermenge.py index 6c7322e..0096198 100644 --- a/backend/app/services/mindermenge.py +++ b/backend/app/services/mindermenge.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.models.fehlbestand import Fehlbestand, FehlbestandStatus @@ -34,6 +35,10 @@ async def genehmigen( # Kontrolle, Sprint 3), aber das Schema erlaubt kontrolle_id=NULL. raise ValueError("Fehlbestand ohne auslösende Kontrolle kann nicht genehmigt werden") + # Row-Lock auf den Fehlbestand serialisiert parallele Genehmigungsversuche + # (sql-expert-Review-Fund: TOCTOU zwischen Check und Insert ohne Lock). + await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update()) + result = await db.execute( select(MindermengenGenehmigung).where( MindermengenGenehmigung.fehlbestand_id == fehlbestand.id, @@ -52,7 +57,12 @@ async def genehmigen( status=MindermengeStatus.aktiv, ) db.add(genehmigung) - await db.flush() + try: + await db.flush() + except IntegrityError as exc: + # Sicherheitsnetz gegen den partiellen Unique-Index (Migration 0007), + # falls der Lock oben je umgangen würde (z. B. anderer Isolation-Level). + raise BereitsGenehmigtError() from exc await historie_service.log( db, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a039032..383f97b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "aiosmtplib>=3.0,<3.1", "python-barcode[images]>=0.15,<0.16", "reportlab>=4.2,<4.3", + "slowapi>=0.1.9,<0.2", ] [project.optional-dependencies] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index eea199c..e055a9f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,8 +9,14 @@ import app.models # noqa: F401 (registriert ALLE ORM-Modelle an Base.metadata, # nicht importierte Tabelle zeigt. Fund im dritten echten CI-Lauf.) from app.core.app_settings import settings from app.core.security import hash_password +from app.core.rate_limit import limiter from app.db.session import get_db from app.main import app + +# Login-Rate-Limit (5/Minute, Karte owasp-Review) würde bei vielen Tests, die +# login() mehrfach aufrufen, den Testlauf selbst blockieren - hier bewusst +# global für die Testsuite deaktiviert, Produktivverhalten bleibt unberührt. +limiter.enabled = False from app.models.auth import Benutzer, BenutzerRolle, RolleTyp from app.models.objekt import Objekt from app.models.stammdaten import Bereich, Material, MaterialTyp, Objekttyp, Standort diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index a2b98d4..40e897f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,5 +1,42 @@ import pytest +from app.core.rate_limit import limiter + + +@pytest.mark.asyncio +async def test_login_unbekannter_benutzer_ist_401(client): + """jwt-expert-Review-Fund: konstante Antwortzeit auch ohne existierenden + Login (verify_password_konstante_zeit) - hier nur der Statuscode, die + Timing-Eigenschaft selbst ist nicht sinnvoll per Unit-Test prüfbar.""" + response = await client.post( + "/api/v1/auth/login", + data={"username": "gibt-es-nicht", "password": "irgendwas"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_rate_limit(client, mitarbeiter_user): + """owasp-Review-Fund: App-seitiges Rate-Limit als Verteidigung in der Tiefe, + unabhängig vom nginx-Limit. In conftest.py global deaktiviert (sonst würden + andere Tests, die login() mehrfach aufrufen, selbst geblockt) - hier gezielt + für diesen einen Test wieder aktiviert.""" + limiter.enabled = True + try: + for _ in range(5): + response = await client.post( + "/api/v1/auth/login", + data={"username": "mitarbeiter1", "password": "falsch"}, + ) + assert response.status_code == 401 + blockiert = await client.post( + "/api/v1/auth/login", + data={"username": "mitarbeiter1", "password": "falsch"}, + ) + assert blockiert.status_code == 429 + finally: + limiter.enabled = False + @pytest.mark.asyncio async def test_login_success(client, mitarbeiter_user): diff --git a/deploy/install_server.sh b/deploy/install_server.sh index 7f969c4..07b4175 100755 --- a/deploy/install_server.sh +++ b/deploy/install_server.sh @@ -63,7 +63,19 @@ PGCONF grep -q "conf.d/\*.conf" "${PG_CONF_DIR}/postgresql.conf" || \ echo "include_dir = 'conf.d'" >> "${PG_CONF_DIR}/postgresql.conf" -echo "== nginx Reverse-Proxy Config (inkl. Security-Header) ==" +echo "== TLS: selbstsigniertes Zertifikat (LAN-Betrieb ohne Domain, Karte owasp-Review) ==" +mkdir -p /etc/nginx/ssl +if [ ! -f /etc/nginx/ssl/mabea.crt ]; then + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/nginx/ssl/mabea.key -out /etc/nginx/ssl/mabea.crt \ + -subj "/CN=${APP_DOMAIN}" + chmod 600 /etc/nginx/ssl/mabea.key + echo "Selbstsigniertes Zertifikat erzeugt (10 Jahre gültig) - Browser warnen beim ersten Zugriff, bis manuell akzeptiert/importiert." +else + echo "Zertifikat existiert bereits, überspringe." +fi + +echo "== nginx Reverse-Proxy Config (inkl. Security-Header, TLS) ==" sed -e "s/__APP_DOMAIN__/${APP_DOMAIN}/g" \ "$(dirname "$0")/nginx_mabea.conf.template" > /etc/nginx/sites-available/mabea.conf ln -sf /etc/nginx/sites-available/mabea.conf /etc/nginx/sites-enabled/mabea.conf @@ -94,5 +106,7 @@ echo "Zugangsdaten liegen (chmod 600) in ${CRED_FILE} – NICHT auf stdout ausge echo "DATABASE_URL von dort in backend/.env auf dem Server übernehmen (siehe backend/example.env)." echo "Backend selbst ist noch nicht deployt – siehe deploy/mabea-backend.service.template für den späteren systemd-Dienst." echo -echo "Hinweis: nginx läuft aktuell nur über Port 80 (kein TLS). Für echten Betrieb vor Domain-Vergabe" -echo "TLS nachziehen (Let's Encrypt bei Domain, sonst selbstsigniertes Zertifikat fürs LAN)." +echo "nginx läuft über HTTPS mit selbstsigniertem Zertifikat (Port 80 leitet auf 443 um)." +echo "Browser warnen beim ersten Zugriff - Zertifikat manuell akzeptieren/importieren." +echo "Sobald eine echte Domain feststeht: ssl_certificate/-key in /etc/nginx/sites-available/mabea.conf" +echo "auf ein Let's-Encrypt-Zertifikat umstellen (certbot)." diff --git a/deploy/nginx_mabea.conf.template b/deploy/nginx_mabea.conf.template index 9880e84..fd05c77 100644 --- a/deploy/nginx_mabea.conf.template +++ b/deploy/nginx_mabea.conf.template @@ -1,18 +1,39 @@ # limit_req_zone gehört in den http-Kontext (hier: via sites-enabled-Include-Kette # bereits innerhalb von http{}, aber außerhalb des server{}-Blocks nötig). # Grober Schutz gegen Brute-Force auf Login, ergänzt (nicht ersetzt) die -# Rate-Begrenzung/Lockout auf Anwendungsebene, die noch aussteht (Review-Fund). +# App-seitige Rate-Begrenzung (slowapi, siehe backend/app/core/rate_limit.py). limit_req_zone $binary_remote_addr zone=mabea_login:10m rate=5r/m; +# HTTP -> HTTPS Redirect. Selbstsigniertes Zertifikat (Karte owasp-Review: LAN- +# Betrieb ohne öffentliche Domain, kein Let's Encrypt möglich) - Browser zeigen +# beim ersten Zugriff eine Warnung, bis das Zertifikat manuell akzeptiert wird. server { listen 80; server_name __APP_DOMAIN__; + return 301 https://$host$request_uri; +} - # Basis-Hardening-Header (Review-Fund owasp-top10-expert). Sobald TLS aktiv ist, - # zusätzlich Strict-Transport-Security ergänzen. +server { + listen 443 ssl; + server_name __APP_DOMAIN__; + + ssl_certificate /etc/nginx/ssl/mabea.crt; + ssl_certificate_key /etc/nginx/ssl/mabea.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # Basis-Hardening-Header (Review-Fund owasp-top10-expert). + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; + # CSP erlaubt nur same-origin (PWA lädt keine externen Skripte/Fonts) plus + # Kamera-Zugriff für den Barcode-Scanner (Karte 10). connect-src 'self' + # deckt auch API-Aufrufe ab, da Frontend/API dieselbe Origin teilen (siehe + # location-Blöcke unten). Owasp-Review-Fund: CSP fehlte komplett. + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'" always; + add_header Permissions-Policy "camera=(self)" always; location /api/v1/auth/login { limit_req zone=mabea_login burst=5 nodelay;