From 32b28a93f379dd39bc99c89f6d463724e2c28b61 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 13 Oct 2026 07:45:53 +0200 Subject: [PATCH] Harden login, sessions, and media access before the dev deploy. Limit brute-force attempts, require admin TOTP, and keep session tokens out of media URLs. Co-authored-by: Cursor --- .env.example | 7 +- backend/account_onboarding_gate.py | 2 + backend/auth.py | 74 ++- backend/main.py | 2 +- backend/media_grant.py | 49 ++ .../migrations/096_login_lockout_and_totp.sql | 29 ++ backend/models.py | 23 +- backend/password_reset_mail.py | 4 +- backend/routers/auth.py | 431 ++++++++++++++++-- backend/routers/content_reports.py | 1 + backend/routers/exercises.py | 2 +- backend/routers/media_assets.py | 2 +- backend/routers/profiles.py | 33 +- backend/tests/test_auth_hardening.py | 51 +++ backend/totp_auth.py | 63 +++ backend/version.py | 18 +- docker-compose.dev-env.yml | 6 +- docker-compose.yml | 5 +- frontend/src/App.jsx | 28 ++ .../ExerciseAttachmentMediaStrip.jsx | 5 +- .../ExerciseInlineFileMediaModal.jsx | 5 +- .../src/components/ExerciseMediaEmbed.jsx | 5 +- .../src/components/ExerciseMediaThumbTile.jsx | 5 +- frontend/src/components/TotpSetupCard.jsx | 101 ++++ .../exercises/ExerciseFormPageRoot.jsx | 7 +- frontend/src/context/AuthContext.jsx | 18 + frontend/src/pages/AccountSettingsPage.jsx | 77 +++- frontend/src/pages/LoginPage.jsx | 76 ++- frontend/src/pages/MediaLibraryPage.jsx | 8 +- frontend/src/utils/api.js | 46 +- frontend/src/utils/exerciseMediaUrl.js | 64 ++- 31 files changed, 1146 insertions(+), 101 deletions(-) create mode 100644 backend/media_grant.py create mode 100644 backend/migrations/096_login_lockout_and_totp.sql create mode 100644 backend/tests/test_auth_hardening.py create mode 100644 backend/totp_auth.py create mode 100644 frontend/src/components/TotpSetupCard.jsx diff --git a/.env.example b/.env.example index ad3344b..9ae5272 100644 --- a/.env.example +++ b/.env.example @@ -53,7 +53,12 @@ SMTP_FROM=noreply@jinkendo.de SMTP_SSL= SMTP_STARTTLS= -AUTO_ADMIN_FIRST_USER=true +# Produktion: false. true nur für eine bewusst leere Erstinstallation. +AUTO_ADMIN_FIRST_USER=false +# Plattform-Admins müssen TOTP einrichten. 0 schaltet die Pflicht ab. +REQUIRE_ADMIN_TOTP=true +# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet. +# MEDIA_TOKEN_SECRET= ADMIN_BOOTSTRAP_EMAILS= APP_URL=https://shinkan.jinkendo.de diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py index b643b10..c962fd9 100644 --- a/backend/account_onboarding_gate.py +++ b/backend/account_onboarding_gate.py @@ -33,6 +33,8 @@ AUTH_INFRA_PREFIXES = ( "/api/auth/status", "/api/auth/pin", "/api/auth/resend-verification", + "/api/auth/totp", + "/api/auth/media-grant", "/api/profiles/me", "/api/me/entitlements", ) diff --git a/backend/auth.py b/backend/auth.py index 7e3b18e..b7ec6b4 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -12,6 +12,11 @@ from fastapi import Header, Query, HTTPException import bcrypt from db import get_db, get_cursor +from media_grant import profile_id_from_media_grant + +# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer. +_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i" +MAX_SESSION_DAYS = 30 def hash_pin(pin: str) -> str: @@ -33,6 +38,34 @@ def verify_pin(pin: str, stored_hash: str) -> bool: return stored_hash == hashlib.sha256(pin.encode()).hexdigest() +def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool: + """Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel).""" + if stored_hash and str(stored_hash).startswith("$2"): + return verify_pin(pin, stored_hash) + verify_pin(pin or "x", _LOGIN_TIMING_HASH) + if not stored_hash: + return False + return verify_pin(pin, stored_hash) + + +def session_lifetime_days(profile: Optional[dict]) -> int: + raw = profile.get("session_days") if profile else None + try: + days = int(raw) if raw is not None else MAX_SESSION_DAYS + except (TypeError, ValueError): + days = MAX_SESSION_DAYS + if days < 1: + days = 1 + if days > MAX_SESSION_DAYS: + days = MAX_SESSION_DAYS + return days + + +def revoke_profile_sessions(cur, profile_id: int) -> None: + """Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens.""" + cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,)) + + def make_token() -> str: """Generate a secure random token for sessions.""" return secrets.token_urlsafe(32) @@ -51,12 +84,39 @@ def get_session(token: str): cur.execute( "SELECT s.*, p.role, p.name " "FROM sessions s JOIN profiles p ON s.profile_id=p.id " - "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP", + "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP " + "AND COALESCE(s.purpose, 'full') = 'full'", (token,) ) return cur.fetchone() +def get_limited_session(token: str, purpose: str): + """Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff.""" + if not token or purpose not in ("totp_enroll", "totp_challenge"): + return None + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT s.*, p.role, p.name, p.email " + "FROM sessions s JOIN profiles p ON s.profile_id=p.id " + "WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP " + "AND s.purpose = %s", + (token, purpose), + ) + return cur.fetchone() + + +def _profile_session_from_id(profile_id: int) -> Optional[dict]: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT id AS profile_id, role, name FROM profiles WHERE id = %s", + (profile_id,), + ) + return cur.fetchone() + + def require_auth(x_auth_token: Optional[str] = Header(default=None)): """ FastAPI dependency - requires valid authentication. @@ -80,8 +140,8 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss """ FastAPI dependency - auth via header OR query parameter. - Used for endpoints accessed by tags and SSE connections that can't send headers. - Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters. + Dateiabruf für /