Multi-Agent-Review: Race Conditions, DB-Indizes, TLS/Security-Härtung
Konsolidierte Funde aus postgres-/sql-/jwt-/owasp-top10-expert-Review: - Race Conditions gefixt: doppelte aktive Kontrolle (SAVEPOINT + partieller Unique-Index), doppelte Mindermengen-Genehmigung (FOR UPDATE + Unique-Index), Lost-Update bei Nachfüllung (FOR UPDATE auf Fehlbestand/Objektposition). - Migration 0007: partielle Unique-Indizes als DB-Sicherheitsnetz + fehlende FK-Indizes (fehlbestand.material_id, kontrolle(objekt_id,status), zustaendigkeit, benutzer_rolle.rolle, objektposition.ablaufdatum u.a.). - Connection-Pool explizit begrenzt (pool_size=5, max_overflow=5) - ohne das könnte jeder uvicorn-Worker den Postgres max_connections-Wert sprengen. - Timing-Angriff bei Login-Enumeration gefixt (konstante Antwortzeit über Dummy-Hash), JWT-Decode verlangt jetzt exp/sub-Claims. - App-seitiges Rate-Limiting (slowapi, 5/min) auf /auth/login als Verteidigung in der Tiefe zusätzlich zum nginx-Limit. - nginx: TLS mit selbstsigniertem Zertifikat (LAN-Betrieb, keine Domain), HSTS, Content-Security-Policy, Permissions-Policy ergänzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -2799,3 +2799,155 @@ Keine Commits in dieser Session.
|
|||||||
- flutter_app/pubspec.yaml | 3 +
|
- 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 ++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -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 fastapi.security import OAuth2PasswordRequestForm
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
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.db.session import get_db
|
||||||
from app.models.auth import Benutzer
|
from app.models.auth import Benutzer
|
||||||
|
|
||||||
@@ -25,17 +26,20 @@ class MeResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/login", response_model=TokenResponse)
|
@router.post("/auth/login", response_model=TokenResponse)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
async def login(
|
async def login(
|
||||||
|
request: Request,
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
result = await db.execute(select(Benutzer).where(Benutzer.login == form_data.username))
|
result = await db.execute(select(Benutzer).where(Benutzer.login == form_data.username))
|
||||||
benutzer = result.scalar_one_or_none()
|
benutzer = result.scalar_one_or_none()
|
||||||
if (
|
# Passwort-Vergleich läuft IMMER (auch bei unbekanntem Login), damit die
|
||||||
benutzer is None
|
# Antwortzeit nicht verrät, ob ein Login existiert (jwt-expert-Review-Fund).
|
||||||
or not benutzer.aktiv
|
passwort_ok = verify_password_konstante_zeit(
|
||||||
or not verify_password(form_data.password, benutzer.passwort_hash)
|
form_data.password, benutzer.passwort_hash if benutzer else None
|
||||||
):
|
)
|
||||||
|
if benutzer is None or not benutzer.aktiv or not passwort_ok:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Login oder Passwort falsch",
|
detail="Login oder Passwort falsch",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -7,6 +7,11 @@ from app.core.app_settings import settings
|
|||||||
|
|
||||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
_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:
|
def hash_password(password: str) -> str:
|
||||||
return _pwd_context.hash(password)
|
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)
|
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:
|
def create_access_token(*, subject: str) -> str:
|
||||||
# Bewusst KEINE Rollen im Token: get_current_user liest Rollen bei jedem Request
|
# 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).
|
# 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:
|
def decode_access_token(token: str) -> dict:
|
||||||
try:
|
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:
|
except jwt.PyJWTError as exc:
|
||||||
raise InvalidTokenError(str(exc)) from exc
|
raise InvalidTokenError(str(exc)) from exc
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||||||
|
|
||||||
from app.core.app_settings import settings
|
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)
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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
|
import app.models # noqa: F401 (alle ORM-Modelle vollständig an Base.metadata
|
||||||
# registrieren, unabhängig davon, welche Endpunkte tatsächlich verdrahtet sind -
|
# registrieren, unabhängig davon, welche Endpunkte tatsächlich verdrahtet sind -
|
||||||
# sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py)
|
# sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py)
|
||||||
from app.api.v1.api import api_router
|
from app.api.v1.api import api_router
|
||||||
from app.core.app_settings import settings
|
from app.core.app_settings import settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
|
|
||||||
_PLACEHOLDER_JWT_SECRET = "change-me-to-a-long-random-value"
|
_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 = 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")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ async def nachfuellen(
|
|||||||
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
|
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
|
||||||
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
|
"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:
|
if fehlbestand.status == FehlbestandStatus.erledigt:
|
||||||
raise FehlbestandBereitsErledigtError()
|
raise FehlbestandBereitsErledigtError()
|
||||||
|
|
||||||
@@ -46,10 +53,12 @@ async def nachfuellen(
|
|||||||
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
|
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Objektposition).where(
|
select(Objektposition)
|
||||||
|
.where(
|
||||||
Objektposition.objekt_id == fehlbestand.objekt_id,
|
Objektposition.objekt_id == fehlbestand.objekt_id,
|
||||||
Objektposition.material_id == fehlbestand.material_id,
|
Objektposition.material_id == fehlbestand.material_id,
|
||||||
)
|
)
|
||||||
|
.with_for_update()
|
||||||
)
|
)
|
||||||
objektposition = result.scalar_one_or_none()
|
objektposition = result.scalar_one_or_none()
|
||||||
if objektposition is not None:
|
if objektposition is not None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||||
@@ -60,8 +61,22 @@ async def starte_kontrolle(
|
|||||||
status=KontrollStatus.in_bearbeitung,
|
status=KontrollStatus.in_bearbeitung,
|
||||||
gestartet_am=datetime.now(timezone.utc),
|
gestartet_am=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(neue_kontrolle)
|
try:
|
||||||
await db.flush()
|
# 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(
|
await _lasse_mindermengen_ablaufen(
|
||||||
db, objekt_id=objekt_id, neue_kontrolle=neue_kontrolle, zustaendiger_server_id=zustaendiger_server_id
|
db, objekt_id=objekt_id, neue_kontrolle=neue_kontrolle, zustaendiger_server_id=zustaendiger_server_id
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||||
@@ -34,6 +35,10 @@ async def genehmigen(
|
|||||||
# Kontrolle, Sprint 3), aber das Schema erlaubt kontrolle_id=NULL.
|
# Kontrolle, Sprint 3), aber das Schema erlaubt kontrolle_id=NULL.
|
||||||
raise ValueError("Fehlbestand ohne auslösende Kontrolle kann nicht genehmigt werden")
|
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(
|
result = await db.execute(
|
||||||
select(MindermengenGenehmigung).where(
|
select(MindermengenGenehmigung).where(
|
||||||
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
|
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
|
||||||
@@ -52,7 +57,12 @@ async def genehmigen(
|
|||||||
status=MindermengeStatus.aktiv,
|
status=MindermengeStatus.aktiv,
|
||||||
)
|
)
|
||||||
db.add(genehmigung)
|
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(
|
await historie_service.log(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ dependencies = [
|
|||||||
"aiosmtplib>=3.0,<3.1",
|
"aiosmtplib>=3.0,<3.1",
|
||||||
"python-barcode[images]>=0.15,<0.16",
|
"python-barcode[images]>=0.15,<0.16",
|
||||||
"reportlab>=4.2,<4.3",
|
"reportlab>=4.2,<4.3",
|
||||||
|
"slowapi>=0.1.9,<0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -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.)
|
# nicht importierte Tabelle zeigt. Fund im dritten echten CI-Lauf.)
|
||||||
from app.core.app_settings import settings
|
from app.core.app_settings import settings
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.main import app
|
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.auth import Benutzer, BenutzerRolle, RolleTyp
|
||||||
from app.models.objekt import Objekt
|
from app.models.objekt import Objekt
|
||||||
from app.models.stammdaten import Bereich, Material, MaterialTyp, Objekttyp, Standort
|
from app.models.stammdaten import Bereich, Material, MaterialTyp, Objekttyp, Standort
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
import pytest
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_login_success(client, mitarbeiter_user):
|
async def test_login_success(client, mitarbeiter_user):
|
||||||
|
|||||||
@@ -63,7 +63,19 @@ PGCONF
|
|||||||
grep -q "conf.d/\*.conf" "${PG_CONF_DIR}/postgresql.conf" || \
|
grep -q "conf.d/\*.conf" "${PG_CONF_DIR}/postgresql.conf" || \
|
||||||
echo "include_dir = 'conf.d'" >> "${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" \
|
sed -e "s/__APP_DOMAIN__/${APP_DOMAIN}/g" \
|
||||||
"$(dirname "$0")/nginx_mabea.conf.template" > /etc/nginx/sites-available/mabea.conf
|
"$(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
|
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 "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 "Backend selbst ist noch nicht deployt – siehe deploy/mabea-backend.service.template für den späteren systemd-Dienst."
|
||||||
echo
|
echo
|
||||||
echo "Hinweis: nginx läuft aktuell nur über Port 80 (kein TLS). Für echten Betrieb vor Domain-Vergabe"
|
echo "nginx läuft über HTTPS mit selbstsigniertem Zertifikat (Port 80 leitet auf 443 um)."
|
||||||
echo "TLS nachziehen (Let's Encrypt bei Domain, sonst selbstsigniertes Zertifikat fürs LAN)."
|
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)."
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
# limit_req_zone gehört in den http-Kontext (hier: via sites-enabled-Include-Kette
|
# 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).
|
# bereits innerhalb von http{}, aber außerhalb des server{}-Blocks nötig).
|
||||||
# Grober Schutz gegen Brute-Force auf Login, ergänzt (nicht ersetzt) die
|
# 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;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name __APP_DOMAIN__;
|
server_name __APP_DOMAIN__;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# Basis-Hardening-Header (Review-Fund owasp-top10-expert). Sobald TLS aktiv ist,
|
server {
|
||||||
# zusätzlich Strict-Transport-Security ergänzen.
|
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-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options DENY always;
|
add_header X-Frame-Options DENY always;
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin 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 {
|
location /api/v1/auth/login {
|
||||||
limit_req zone=mabea_login burst=5 nodelay;
|
limit_req zone=mabea_login burst=5 nodelay;
|
||||||
|
|||||||
Reference in New Issue
Block a user