feat(compliance): update retention policy and enhance password reset validation
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 26s
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 26s
- Adjusted retention policy to align with compliance requirements: - Changed HIDDEN_TO_PURGE_DAYS from 90 to 30 days. - Enhanced password reset functionality to enforce a minimum password length of 8 characters. - Updated tests to validate new password requirements and retention logic. - Corrected umlaut in copyright error messages for clarity.
This commit is contained in:
parent
be0385922d
commit
fc33bfbdeb
|
|
@ -21,7 +21,8 @@ LC_TRASH_SOFT = "trash_soft"
|
|||
LC_TRASH_HIDDEN = "trash_hidden"
|
||||
|
||||
SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30")))
|
||||
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90")))
|
||||
# P-03b: Default gemaess fachlichem Loeschkonzept (Audit 2026-05-09): 30+30 Tage.
|
||||
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30")))
|
||||
|
||||
|
||||
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class PasswordResetRequest(BaseModel):
|
|||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
"""Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration."""
|
||||
|
|
|
|||
|
|
@ -1167,7 +1167,7 @@ def bulk_media_patch(
|
|||
{
|
||||
"id": asset_id,
|
||||
"detail": (
|
||||
"Fur Vereins- oder offizielle Medien ist eine "
|
||||
"Für Vereins- oder offizielle Medien ist eine "
|
||||
"Urheberrechtsangabe (copyright_notice) Pflicht."
|
||||
),
|
||||
}
|
||||
|
|
@ -1280,7 +1280,7 @@ def patch_media_asset(
|
|||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Fur Vereins- oder offizielle Medien ist eine Urheberrechtsangabe "
|
||||
"Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe "
|
||||
"(copyright_notice) Pflicht. Bitte vor oder zusammen mit der "
|
||||
"Freigabe angeben."
|
||||
),
|
||||
|
|
|
|||
100
backend/tests/test_auth_password_reset_minlength.py
Normal file
100
backend/tests/test_auth_password_reset_minlength.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""
|
||||
P-05b: POST /api/auth/reset-password muss Passwoerter unter 8 Zeichen ablehnen.
|
||||
Prueft Pydantic-Validierung (HTTP 422) BEVOR die DB befragt wird.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _mock_db_valid_token() -> MagicMock:
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.return_value = {"profile_id": 1}
|
||||
mock_conn = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
return mock_cm, mock_cur
|
||||
|
||||
|
||||
# ── Mindestlaenge-Grenzwerte ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("short_pw", ["", "a", "1234567"])
|
||||
def test_reset_password_too_short_rejected(client: TestClient, short_pw: str) -> None:
|
||||
"""Passwort < 8 Zeichen muss mit 422 abgelehnt werden (Pydantic-Validierung)."""
|
||||
r = client.post(
|
||||
"/api/auth/reset-password",
|
||||
json={"token": "sometoken", "new_password": short_pw},
|
||||
)
|
||||
assert r.status_code == 422, (
|
||||
f"Erwartet 422 fuer Passwort {short_pw!r} ({len(short_pw)} Zeichen), "
|
||||
f"erhalten {r.status_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_reset_password_exactly_8_chars_accepted(client: TestClient) -> None:
|
||||
"""Passwort mit genau 8 Zeichen muss die Validierung passieren."""
|
||||
mock_cm, mock_cur = _mock_db_valid_token()
|
||||
with patch("routers.auth.get_db", return_value=mock_cm), patch(
|
||||
"routers.auth.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/auth/reset-password",
|
||||
json={"token": "sometoken", "new_password": "12345678"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("ok") is True
|
||||
|
||||
|
||||
def test_reset_password_long_password_accepted(client: TestClient) -> None:
|
||||
"""Langes Passwort (>= 8 Zeichen) muss akzeptiert werden."""
|
||||
mock_cm, mock_cur = _mock_db_valid_token()
|
||||
with patch("routers.auth.get_db", return_value=mock_cm), patch(
|
||||
"routers.auth.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/auth/reset-password",
|
||||
json={"token": "sometoken", "new_password": "sicheresPasswort123!"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_reset_password_missing_field_rejected(client: TestClient) -> None:
|
||||
"""Fehlendes new_password-Feld muss mit 422 abgelehnt werden."""
|
||||
r = client.post(
|
||||
"/api/auth/reset-password",
|
||||
json={"token": "sometoken"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_reset_password_invalid_token_returns_400(client: TestClient) -> None:
|
||||
"""Gueltiges Passwort aber ungueltiger Token muss 400 liefern."""
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.return_value = None
|
||||
mock_conn = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
with patch("routers.auth.get_db", return_value=mock_cm), patch(
|
||||
"routers.auth.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/auth/reset-password",
|
||||
json={"token": "ungueltig", "new_password": "validesPw123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
|
@ -125,13 +125,11 @@ def test_patch_promote_to_official_without_copyright_returns_400(client: TestCli
|
|||
|
||||
|
||||
def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None:
|
||||
"""Promotion private -> club MIT copyright_notice im Body darf nicht 400 liefern."""
|
||||
"""Promotion private -> club MIT copyright_notice im Body muss 200 liefern."""
|
||||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||||
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
|
||||
mock_cur.fetchone.side_effect = [
|
||||
_PRIVATE_ASSET,
|
||||
{**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"},
|
||||
]
|
||||
updated_asset = {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}
|
||||
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, updated_asset]
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
||||
|
|
@ -143,17 +141,19 @@ def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient
|
|||
headers={"X-Auth-Token": "t"},
|
||||
)
|
||||
|
||||
assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower()
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == 42
|
||||
assert body["visibility"] == "club"
|
||||
assert body["copyright_notice"] == "Verein 2026"
|
||||
|
||||
|
||||
def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None:
|
||||
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright erlaubt."""
|
||||
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright muss 200 liefern."""
|
||||
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
|
||||
promoted_asset = {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}
|
||||
mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT)
|
||||
mock_cur.fetchone.side_effect = [
|
||||
_ASSET_WITH_COPYRIGHT,
|
||||
{**_ASSET_WITH_COPYRIGHT, "visibility": "club"},
|
||||
]
|
||||
mock_cur.fetchone.side_effect = [_ASSET_WITH_COPYRIGHT, promoted_asset]
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
|
||||
|
|
@ -165,7 +165,11 @@ def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) ->
|
|||
headers={"X-Auth-Token": "t"},
|
||||
)
|
||||
|
||||
assert r.status_code != 400 or "copyright" not in r.json().get("detail", "").lower()
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == 42
|
||||
assert body["visibility"] == "club"
|
||||
assert body["copyright_notice"] == "Verein 2026"
|
||||
|
||||
|
||||
def test_patch_filename_only_no_copyright_check(client: TestClient) -> None:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.66"
|
||||
BUILD_DATE = "2026-05-09"
|
||||
APP_VERSION = "0.8.67"
|
||||
BUILD_DATE = "2026-05-10"
|
||||
DB_SCHEMA_VERSION = "20260508049"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.2", # Passwort-Mindestlange PUT /auth/pin: 4 auf 8 Zeichen angehoben
|
||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
|
||||
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
|
||||
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
|
||||
|
|
@ -13,7 +13,7 @@ MODULE_VERSIONS = {
|
|||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"media_assets": "1.12.2", # P-04: Copyright-Pflicht bei Promotion auf club/official in PATCH und bulk-patch
|
||||
"media_assets": "1.12.3", # P-04b: Umlautkorrektur Fehlermeldung; Tests gehaertet
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
|
|
@ -29,6 +29,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.67",
|
||||
"date": "2026-05-10",
|
||||
"changes": [
|
||||
"Sicherheit P-05b: PasswordResetConfirm.new_password min_length=8 (POST /auth/reset-password)",
|
||||
"Sicherheit P-03b: Retention-Default HIDDEN_TO_PURGE 90->30 Tage (gemaess Loeschkonzept 30+30)",
|
||||
"Sicherheit P-04: Positive Promotion-Tests haerten (explizit 200 + Payload); Umlaut in Fehlermeldung",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.66",
|
||||
"date": "2026-05-09",
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ services:
|
|||
DB_USER: shinkan_user
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
|
||||
# Loeschkonzept (Audit P-03b): 30 Tage Soft-Trash, dann 30 Tage Hidden, dann Purge (gesamt 60 Tage).
|
||||
MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS: "${MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS:-30}"
|
||||
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-90}"
|
||||
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-30}"
|
||||
volumes:
|
||||
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Compliance-Implementierung – Umsetzungsbericht
|
||||
|
||||
**Erstellt:** 2026-05-09
|
||||
**Zuletzt aktualisiert:** 2026-05-10
|
||||
**Audit-Basis:** `docs/compliance-audit.md`
|
||||
**App-Version nach Umsetzung:** 0.8.66
|
||||
**App-Version nach Umsetzung:** 0.8.67
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,84 +21,171 @@ Neuer Docker-Service `retention-cron` nutzt dasselbe Backend-Image und führt `s
|
|||
|
||||
**Tests:** Keine automatisierten Tests möglich (Runtime-Verhalten); operativ über Container-Logs (`docker logs shinkan-retention-cron`) überprüfbar.
|
||||
|
||||
**Anmerkungen:**
|
||||
- Retention-Zeiten über Env-Variablen konfigurierbar: `MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS` (Default 30), `MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS` (Default 90)
|
||||
- Das Skript selbst (`scripts/media_retention_job.py`) war bereits korrekt implementiert; nur der Scheduler fehlte
|
||||
---
|
||||
|
||||
### P-03b – Retention-Zeiten mit Löschkonzept abgleichen ✅
|
||||
|
||||
**Status:** Umgesetzt (2026-05-10)
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/media_lifecycle.py:24` – Default `HIDDEN_TO_PURGE_DAYS`
|
||||
- `docker-compose.yml` – explizite Env-Variable im `retention-cron`-Service
|
||||
|
||||
**Befund des Re-Audits:**
|
||||
Das fachliche Löschkonzept (Audit §6.4) sieht 30 + 30 Tage vor:
|
||||
- Stufe 1 → Stufe 2 (Soft → Hidden): 30 Tage ✓ (war bereits korrekt)
|
||||
- Stufe 2 → Purge (Hidden → gelöscht): **weitere 30 Tage** → Code-Default war 90 Tage ❌
|
||||
|
||||
**Technische Änderung:**
|
||||
```python
|
||||
# media_lifecycle.py Zeile 24 (vorher "90", jetzt "30"):
|
||||
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30")))
|
||||
```
|
||||
|
||||
`docker-compose.yml` enthält jetzt explizit `MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${...:-30}"` mit Kommentar, der das 30+30-Konzept dokumentiert. Der Wert kann per Env-Variable in einzelnen Deployments überschrieben werden.
|
||||
|
||||
---
|
||||
|
||||
### P-04 – Copyright-Pflicht bei Archiv-Promotion vereinheitlichen ✅
|
||||
|
||||
**Status:** Umgesetzt
|
||||
**Status:** Umgesetzt; Tests nachgehärtet (2026-05-10)
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/routers/media_assets.py` – `patch_media_asset()` und `bulk_media_patch()`
|
||||
- `backend/tests/test_media_assets_copyright_promotion.py`
|
||||
|
||||
**Technische Änderung:**
|
||||
Beide Endpoints prüfen nun, ob `copyright_notice` vorhanden ist, wenn `visibility` auf `club` oder `official` gewechselt wird. Priorität: Wert aus dem Request-Body > bestehender Wert in der DB. Ist beides leer, wird HTTP 400 zurückgegeben.
|
||||
Beide Endpoints prüfen, ob `copyright_notice` vorhanden ist, wenn `visibility` auf `club` oder `official` gewechselt wird. Priorität: Wert aus dem Request-Body > bestehender Wert in der DB. Ist beides leer, wird HTTP 400 zurückgegeben.
|
||||
|
||||
Single PATCH: `raise HTTPException(status_code=400, ...)`
|
||||
Bulk PATCH: Asset wird in die `failed`-Liste eingetragen und übersprungen, Gesamtoperation läuft weiter.
|
||||
Fehlermeldung: `"Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe (copyright_notice) Pflicht."` (Umlaut korrekt, 2026-05-10 korrigiert)
|
||||
|
||||
**Tests:** `backend/tests/test_media_assets_copyright_promotion.py` (7 Tests, alle grün):
|
||||
- Promotion zu `club` ohne Copyright → 400
|
||||
- Promotion zu `official` ohne Copyright → 400
|
||||
- Promotion zu `club` mit Copyright im Body → nicht 400
|
||||
- Promotion zu `club`, Copyright bereits auf Asset → nicht 400
|
||||
- Kein Visibility-Wechsel → keine Copyright-Prüfung → 200
|
||||
- Bulk: ohne Copyright → in `failed`, `updated_count == 0`
|
||||
- Bulk: mit Copyright → in `updated`, `updated_count == 1`
|
||||
**Tests:** 7 Tests, alle grün:
|
||||
- Promotion zu `club` ohne Copyright → **400** (exakt)
|
||||
- Promotion zu `official` ohne Copyright → **400** (exakt)
|
||||
- Promotion zu `club` mit Copyright im Body → **200** + Payload-Prüfung (gehärtet)
|
||||
- Promotion zu `club`, Copyright bereits auf Asset → **200** + Payload-Prüfung (gehärtet)
|
||||
- Kein Visibility-Wechsel → keine Copyright-Prüfung → **200** (exakt)
|
||||
- Bulk: ohne Copyright → in `failed`, `updated_count == 0` (exakt)
|
||||
- Bulk: mit Copyright → in `updated`, `updated_count == 1` (exakt)
|
||||
|
||||
---
|
||||
|
||||
### P-05 – Passwort-Mindestlänge angleichen ✅
|
||||
### P-05 – Passwort-Mindestlänge angleichen ⚠️ (Teil 1 von 2 – Re-Audit-Auflage offen)
|
||||
|
||||
**Status:** Umgesetzt
|
||||
**Status:** Teilweise umgesetzt
|
||||
|
||||
**Betroffene Dateien (initialer Fix 2026-05-09):**
|
||||
- `backend/routers/auth.py:101` – `PUT /api/auth/pin`: `< 4` → `< 8`
|
||||
- `frontend/src/pages/LoginPage.jsx:175` – `minLength="6"` → `minLength="8"`
|
||||
- `frontend/src/pages/AccountSettingsPage.jsx:403` – `minLength={4}` → `minLength={8}`
|
||||
|
||||
**Verbleibende Lücke identifiziert im Re-Audit 2026-05-09:** `POST /api/auth/reset-password` hatte kein Mindestlängen-Limit → siehe P-05b.
|
||||
|
||||
---
|
||||
|
||||
### P-05b – reset-password Mindestlänge 8 Zeichen ✅
|
||||
|
||||
**Status:** Umgesetzt (2026-05-10)
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/routers/auth.py:101` – `PUT /api/auth/pin`
|
||||
- `frontend/src/pages/LoginPage.jsx:175` – Registrierungs-/Login-Formular
|
||||
- `frontend/src/pages/AccountSettingsPage.jsx:403` – Passwort-Änderungsformular
|
||||
- `backend/models.py:29` – `PasswordResetConfirm.new_password`
|
||||
- `backend/tests/test_auth_password_reset_minlength.py` – 7 neue Tests
|
||||
|
||||
**Technische Änderung:**
|
||||
- Backend: `if len(new_pin) < 4` → `if len(new_pin) < 8` (Fehlermeldung angepasst)
|
||||
- Frontend LoginPage: `minLength="6"` → `minLength="8"`
|
||||
- Frontend AccountSettingsPage: `minLength={4}` → `minLength={8}`
|
||||
```python
|
||||
# models.py (vorher):
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
Alle drei Stellen sind jetzt konsistent mit dem bereits korrekten `POST /api/auth/register` (war schon `< 8`).
|
||||
# models.py (nachher):
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
```
|
||||
|
||||
**Tests:** Keine neuen Tests; Änderung ist trivial und durch bestehende Auth-Tests (Register) indirekt abgedeckt.
|
||||
FastAPI lehnt Requests mit `new_password < 8 Zeichen` nun mit HTTP **422** (Pydantic Validation Error) ab, bevor der Endpoint-Handler ausgeführt wird. Kein DB-Zugriff erfolgt für unvalide Requests.
|
||||
|
||||
**Tests:** 7 Tests, alle grün:
|
||||
- Leer-String → 422
|
||||
- 1-Zeichen-Passwort → 422
|
||||
- 7-Zeichen-Passwort (`"1234567"`) → 422
|
||||
- Exakt 8 Zeichen (`"12345678"`) → **200** ✓
|
||||
- Langes Passwort → **200** ✓
|
||||
- Fehlendes `new_password`-Feld → 422
|
||||
- Gültiges Passwort, ungültiger Token → 400
|
||||
|
||||
**P-05 vollständig geschlossen?** Ja — alle passwortverarbeitenden Backend-Endpoints erzwingen jetzt Mindestlänge 8:
|
||||
|
||||
| Endpoint | Mindestlänge | Mechanismus |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/register` | 8 | `if len(password) < 8` im Handler |
|
||||
| `PUT /api/auth/pin` | 8 | `if len(new_pin) < 8` im Handler |
|
||||
| `POST /api/auth/reset-password` | 8 | Pydantic `Field(min_length=8)` |
|
||||
| Management-Reset (profiles.py) | 8 | Pydantic `Field(min_length=8)` |
|
||||
| Frontend LoginPage | 8 | `minLength="8"` |
|
||||
| Frontend AccountSettingsPage | 8 | `minLength={8}` |
|
||||
|
||||
---
|
||||
|
||||
### P-07 – ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Release-Test ✅
|
||||
### P-07 – ALLOW_PUBLIC_MEDIA_STATIC Release-Test ✅
|
||||
|
||||
**Status:** Umgesetzt
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/tests/test_security_release.py` – 2 neue Tests
|
||||
|
||||
**Technische Änderung:**
|
||||
Zwei neue Tests in der bestehenden Release-Test-Suite:
|
||||
**Tests:** Beide grün.
|
||||
|
||||
1. `test_public_media_static_not_mounted_by_default`: Verifiziert, dass `/media`-Mount ohne `ALLOW_PUBLIC_MEDIA_STATIC` in der App-Route-Liste nicht vorhanden ist (sicherer Standardzustand in Production).
|
||||
---
|
||||
|
||||
2. `test_allow_public_media_static_activates_media_mount`: Dokumentiert den Effekt des Flags – wenn gesetzt, ist der Mount aktiv (für Awareness im CI).
|
||||
### P-12 – sessionStorage bei Logout bereinigen ❌
|
||||
|
||||
**Tests:** Beide Tests grün (16/16 in `test_security_release.py`).
|
||||
**Status:** Nicht umgesetzt — weiterhin offen (MITT-05)
|
||||
|
||||
**Befund:**
|
||||
`logout()` in `frontend/src/context/AuthContext.jsx` löscht nur `localStorage`-Einträge:
|
||||
```javascript
|
||||
const logout = () => {
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
// sessionStorage wird NICHT geleert
|
||||
}
|
||||
```
|
||||
|
||||
`TrainingCoachPage` schreibt folgende Schlüssel in `sessionStorage`:
|
||||
- `storageStepKey(unitId)` – aktueller Trainingsschritt (Zahl)
|
||||
- `storageDeltasKey(unitId)` – Trainingsdeltas (JSON)
|
||||
- `storageDebriefKey(unitId)` – Debrief-Status (Boolean)
|
||||
|
||||
Nach einem Logout bleiben diese Daten im `sessionStorage` des Tabs erhalten. Bei einem Nutzerwechsel im selben Tab (geteilter Rechner) kann der neue Nutzer Trainingsfortschritt des Vorgängers sehen, bis der Tab geschlossen wird.
|
||||
|
||||
**Risikoeinstufung:** MITT-05 (mittleres Risiko; sessionStorage ist tab-lokal und wird beim Tab-Schließen gelöscht; erfordert physischen Zugang zum selben offenen Tab)
|
||||
|
||||
**Fix (nicht durchgeführt, ca. 15 Minuten Aufwand):**
|
||||
```javascript
|
||||
// AuthContext.jsx logout():
|
||||
const logout = () => {
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
sessionStorage.clear() // oder gezielt nach 'shinkan_coach_' prefix
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P-23 – LoginPage: minLength + Versionsstring ✅
|
||||
|
||||
**Status:** Umgesetzt (zusammen mit P-05)
|
||||
**Status:** Umgesetzt
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `frontend/src/pages/LoginPage.jsx`
|
||||
|
||||
**Technische Änderung:**
|
||||
- `minLength="6"` → `minLength="8"` (deckungsgleich mit P-05)
|
||||
- Hardcodierter Versionsstring `v0.1.0 • Development` aus dem Footer der LoginPage entfernt. Kein Ersatz: Die Versionsinformation ist nur im eingeloggten Bereich unter Einstellungen sichtbar.
|
||||
- `minLength="6"` → `minLength="8"`
|
||||
- Hardcodierter Versionsstring `v0.1.0 • Development` entfernt
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -106,49 +194,54 @@ Zwei neue Tests in der bestehenden Release-Test-Suite:
|
|||
**Status:** Umgesetzt
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `backend/main.py:85-86` – CORSMiddleware-Konfiguration
|
||||
- `backend/main.py:85-86`
|
||||
|
||||
**Technische Änderung:**
|
||||
```python
|
||||
# Vorher:
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
# Nachher:
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
|
||||
```
|
||||
|
||||
Die drei erlaubten Header entsprechen den tatsächlich genutzten Headers (Auth-Token, Tenant-Header, Content-Type). Alle API-Funktionen bleiben unverändert erreichbar.
|
||||
---
|
||||
|
||||
## Test-Zusammenfassung (Stand 0.8.67)
|
||||
|
||||
```
|
||||
tests/test_auth_password_reset_minlength.py 7 passed (neu, P-05b)
|
||||
tests/test_media_assets_copyright_promotion.py 7 passed (gehärtet, P-04)
|
||||
tests/test_security_release.py 9 passed (inkl. 2 P-07-Tests)
|
||||
Weitere bestehende Tests: 81 passed, 6 skipped
|
||||
Gesamt: 104 passed, 6 skipped, 1 failed
|
||||
|
||||
Fehlgeschlagener Test: test_list_media_assets_invalid_lifecycle_400
|
||||
→ Pre-existing: benötigt laufenden PostgreSQL-Container (Hostname "postgres")
|
||||
→ Bestand bereits vor allen Compliance-Änderungen (verifiziert per git stash)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test-Zusammenfassung
|
||||
## Nicht umgesetzte Pakete
|
||||
|
||||
```
|
||||
tests/test_security_release.py 9 passed (inkl. 2 neue P-07 Tests)
|
||||
tests/test_media_assets_copyright_promotion.py 7 passed (neue P-04 Tests)
|
||||
Gesamt neue Tests: 11
|
||||
Gesamt bestehende Tests: 104 → 103 passed, 1 failed (pre-existing, DB nicht erreichbar)
|
||||
```
|
||||
|
||||
Der vorhandene Fehler (`test_list_media_assets_invalid_lifecycle_400`) war bereits vor dieser Implementierung vorhanden (verifiziert per `git stash`). Er tritt nur lokal auf, wenn kein PostgreSQL-Container mit Hostname `postgres` läuft, und besteht in der CI/Docker-Umgebung nicht.
|
||||
|
||||
---
|
||||
|
||||
## Nicht umgesetzte Pakete (laut Freigabe ausgeschlossen)
|
||||
|
||||
P-01, P-02, P-06, P-09, P-10, P-11, P-13–P-16 und alle weiteren Findings bleiben offen.
|
||||
Vollständige Liste und Begründungen: `docs/compliance-audit.md`.
|
||||
| Paket | Status | Begründung |
|
||||
|-------|--------|------------|
|
||||
| P-01 | offen | Rechtstexte — Scope ausgeschlossen |
|
||||
| P-02 | offen | Self-Service-Löschworkflow — Scope ausgeschlossen |
|
||||
| P-06 | offen | HSTS-Header — Scope ausgeschlossen |
|
||||
| P-09 | offen | Kein Einwilligungsdialog Recht am eigenen Bild — Scope ausgeschlossen |
|
||||
| P-10 | offen | DSA-Meldeverfahren — Scope ausgeschlossen |
|
||||
| P-11 | offen | HttpOnly-Cookie-Migration — Scope ausgeschlossen |
|
||||
| P-12 | offen | sessionStorage bei Logout — nicht freigegeben, Aufwand ca. 15 min |
|
||||
| P-13–P-16 | offen | Scope ausgeschlossen |
|
||||
|
||||
---
|
||||
|
||||
## Re-Audit-Empfehlung
|
||||
|
||||
Nach Deployment der Version 0.8.66 sollten folgende Punkte operativ verifiziert werden:
|
||||
Operativ nach Deployment 0.8.67 prüfen:
|
||||
|
||||
1. **P-03**: `docker logs shinkan-retention-cron` prüfen — Job läuft einmalig beim Start und danach täglich um 03:00 Uhr
|
||||
2. **P-04**: Manuelle Stichprobe: PATCH eines privaten Mediums auf `official` ohne `copyright_notice` → muss 400 zurückgeben
|
||||
3. **P-24**: Browser DevTools → Network → Preflight-Request auf `/api/exercises` → `Access-Control-Allow-Headers` darf nur `content-type, x-auth-token, x-active-club-id` enthalten
|
||||
1. **P-03/P-03b**: `docker logs shinkan-retention-cron` — Job läuft täglich 03:00 Uhr; Retention-Zeiten: 30 → 30 Tage
|
||||
2. **P-04**: Manuell: PATCH privates Medium auf `official` ohne `copyright_notice` → muss 400 liefern
|
||||
3. **P-05b**: Manuell: Reset-Link mit 7-Zeichen-Passwort → muss mit Fehler abgewiesen werden
|
||||
4. **P-24**: Browser DevTools Preflight → `Access-Control-Allow-Headers: content-type, x-auth-token, x-active-club-id`
|
||||
|
||||
Nächster vollständiger Re-Audit empfohlen nach Umsetzung der kritischen Findings (P-01: Impressum/Datenschutz, P-02: Löschkonzept/DSGVO-Request-Workflow).
|
||||
Nächster vollständiger Re-Audit nach Umsetzung der kritischen Findings (P-01: Impressum/Datenschutz, P-02: DSGVO-Löschanfragen).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.8.66"
|
||||
export const BUILD_DATE = "2026-05-09"
|
||||
export const APP_VERSION = "0.8.67"
|
||||
export const BUILD_DATE = "2026-05-10"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
LoginPage: "1.0.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user