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 /