diff --git a/.env.example b/.env.example index 9ae5272..9a04380 100644 --- a/.env.example +++ b/.env.example @@ -55,8 +55,8 @@ SMTP_STARTTLS= # 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 +# 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= diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 96f2703..ce97898 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -85,12 +85,9 @@ limiter = Limiter(key_func=_client_ip) def _admin_totp_required() -> bool: + """Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst.""" raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower() - if raw in ("1", "true", "yes"): - return True - if raw in ("0", "false", "no"): - return False - return (os.getenv("ENVIRONMENT") or "development").strip().lower() in ("production", "prod") + return raw in ("1", "true", "yes") def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]: @@ -394,16 +391,7 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa """, (prof["totp_pending_secret"], pid), ) - cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,)) - plain_codes = generate_recovery_codes() - for code in plain_codes: - cur.execute( - """ - INSERT INTO profile_recovery_codes (profile_id, code_hash) - VALUES (%s, %s) - """, - (pid, hash_recovery_code(code)), - ) + plain_codes = _replace_recovery_codes(cur, pid) prof = dict(prof) prof["totp_enabled"] = True prof["totp_secret"] = None @@ -414,6 +402,38 @@ def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(defa return payload +def _replace_recovery_codes(cur, profile_id: int) -> list[str]: + cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,)) + plain_codes = generate_recovery_codes() + for code in plain_codes: + cur.execute( + """ + INSERT INTO profile_recovery_codes (profile_id, code_hash) + VALUES (%s, %s) + """, + (profile_id, hash_recovery_code(code)), + ) + return plain_codes + + +@router.post("/totp/recovery-codes") +def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)): + """Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht.""" + pid = int(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof.get("totp_enabled"): + raise HTTPException(400, "Zwei-Faktor ist nicht aktiv") + if not verify_pin(req.current_password, prof["pin_hash"]): + raise HTTPException(400, "Aktuelles Passwort ist falsch") + if not verify_totp(prof.get("totp_secret"), req.code): + raise HTTPException(400, "Code aus der App stimmt nicht") + plain_codes = _replace_recovery_codes(cur, pid) + return {"recovery_codes": plain_codes} + + @router.post("/totp/disable") def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)): pid = int(session["profile_id"]) diff --git a/backend/version.py b/backend/version.py index 2f3bb2e..cb172ee 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,12 +1,12 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.239" -BUILD_DATE = "2026-10-12" +APP_VERSION = "0.8.240" +BUILD_DATE = "2026-10-13" DB_SCHEMA_VERSION = "20261012096" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) - "auth": "1.3.0", # Login-Sperre, TOTP für Plattform-Admins, Medien-Grant statt Sitzungstoken in URLs + "auth": "1.3.1", # TOTP freiwillig; Wiederherstellungscodes bleiben bis zur Bestätigung stehen "profiles": "1.8.1", # GET /profiles/me: account_state + club_roles "tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext "capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte @@ -53,6 +53,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.240", + "date": "2026-10-13", + "changes": [ + "Zwei-Faktor ist freiwillig. REQUIRE_ADMIN_TOTP erzwingt es nur noch, wenn die Variable ausdrücklich true ist.", + "Wiederherstellungscodes bleiben sichtbar, bis sie bestätigt sind. Neue Codes lassen sich in den Einstellungen erzeugen.", + ], + }, { "version": "0.8.239", "date": "2026-10-12", diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 6924ca8..b24d4d3 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -39,7 +39,7 @@ services: ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}" ENVIRONMENT: "${ENVIRONMENT:-development}" AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}" - REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}" + REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}" # M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen) CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}" diff --git a/docker-compose.yml b/docker-compose.yml index 2b71360..56fabb0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,7 @@ services: SMTP_STARTTLS: ${SMTP_STARTTLS:-} # Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@… AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}" - REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-true}" + REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}" ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}" # Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" diff --git a/frontend/src/components/TotpSetupCard.jsx b/frontend/src/components/TotpSetupCard.jsx index 97fd203..a8c1c6d 100644 --- a/frontend/src/components/TotpSetupCard.jsx +++ b/frontend/src/components/TotpSetupCard.jsx @@ -37,7 +37,6 @@ export default function TotpSetupCard({ onEnabled }) { setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : []) setSecret('') setCode('') - if (onEnabled) await onEnabled(data) } catch (err) { setError(err.message || 'Code nicht akzeptiert') } finally { @@ -85,15 +84,33 @@ export default function TotpSetupCard({ onEnabled }) { {recoveryCodes.length > 0 ? (
- Diese Wiederherstellungscodes jetzt sichern. Sie werden nur dieses eine Mal angezeigt. + Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst. + Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
-Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt. + Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
- {user?.role === 'admin' || user?.role === 'superadmin' ? ( -- Für Plattform-Admins bleibt das so. -
- ) : ( - - )} + Zwei-Faktor ausschalten + + ) : (