diff --git a/.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md b/.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md new file mode 100644 index 0000000..0aae184 --- /dev/null +++ b/.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md @@ -0,0 +1,9 @@ +# Designprinzipien — verschoben + +Die **Jinkendo-Produktfamilie Designprinzipien** (Index + Shinkan-Serie) liegen nicht mehr hier. + +**Neuer Standort (Shinkan-Serie):** [docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md](../../../docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md) + +**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md) + +**Abgleich:** [docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md](../../../docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md) diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 5f75854..b46e857 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -7,6 +7,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | | profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin | | profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft | +| profiles | `POST /api/profiles/{id}/totp-reset` | Plattform | `get_tenant_context` | nur `superadmin`, nicht das eigene Konto | Schaltet TOTP ab, löscht Wiederherstellungscodes, beendet Sitzungen | | clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | | club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | | diff --git a/.env.example b/.env.example index ad3344b..9a04380 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 +# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig. +REQUIRE_ADMIN_TOTP=false +# 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 /