From 698ed49a9ba757ea885f27d326b89a1eeb4791a1 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 4 Jan 2026 22:49:34 -0300 Subject: [PATCH] onboarding: rotate Keycloak after Vaultwarden --- backend/atlas_portal/provisioning.py | 22 +- .../atlas_portal/routes/access_requests.py | 112 ++++++++-- frontend/src/views/OnboardingView.vue | 193 ++++++++++++------ 3 files changed, 247 insertions(+), 80 deletions(-) diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index 1190748..acb378d 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -202,17 +202,21 @@ def provision_access_request(request_code: str) -> ProvisionResult: existing_email_user = admin_client().find_user_by_email(email) if existing_email_user and (existing_email_user.get("username") or "") != username: raise RuntimeError("email is already associated with an existing Atlas account") - # Always enforce email verification in Keycloak itself (even if the portal - # already verified an external email before approval). - # Do not force MFA enrollment during initial login: some users prefer to - # enable MFA later and some clients are too friction-heavy when MFA is - # mandatory for every service. - required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL"] + # The portal already verified the external contact email before approval, + # so mark it as verified in Keycloak. + # + # Do not force password rotation on first login: the onboarding flow + # intentionally guides users through Vaultwarden first, then triggers a + # Keycloak password change step later. + # + # Do not force MFA enrollment during initial login: users can opt into MFA + # later. + required_actions: list[str] = [] payload = { "username": username, "enabled": True, "email": email, - "emailVerified": False, + "emailVerified": True, "requiredActions": required_actions, "attributes": {MAILU_EMAIL_ATTR: [mailu_email]}, } @@ -256,7 +260,7 @@ def provision_access_request(request_code: str) -> ProvisionResult: if not user_id: return ProvisionResult(ok=False, status="accounts_building") - # Task: set initial temporary password and store it for "show once" onboarding. + # Task: set initial password and store it for "show once" onboarding. try: if not user_id: raise RuntimeError("missing user id") @@ -280,7 +284,7 @@ def provision_access_request(request_code: str) -> ProvisionResult: initial_password = password_value if password_value: - admin_client().reset_password(user_id, password_value, temporary=True) + admin_client().reset_password(user_id, password_value, temporary=False) if isinstance(initial_password, str) and initial_password: _upsert_task(conn, request_code, "keycloak_password", "ok", None) diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 858d9b8..6f440c4 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -57,13 +57,13 @@ def _verify_url(request_code: str, token: str) -> str: ONBOARDING_STEPS: tuple[str, ...] = ( - "keycloak_password_changed", - "keycloak_mfa_optional", "vaultwarden_master_password", - "element_recovery_key", - "element_recovery_key_stored", "vaultwarden_browser_extension", "vaultwarden_mobile_app", + "keycloak_password_rotated", + "keycloak_mfa_optional", + "element_recovery_key", + "element_recovery_key_stored", "elementx_setup", "jellyfin_login", "mail_client_setup", @@ -74,22 +74,19 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple( step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS ) -KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_changed"} +KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_rotated"} _KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state" _KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"} +_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at" def _sequential_prerequisites( steps: tuple[str, ...], - keycloak_managed_steps: set[str], optional_steps: set[str], ) -> dict[str, set[str]]: completed: list[str] = [] prerequisites: dict[str, set[str]] = {} for step in steps: - if step in keycloak_managed_steps: - completed.append(step) - continue prerequisites[step] = set(completed) if step not in optional_steps: completed.append(step) @@ -98,7 +95,6 @@ def _sequential_prerequisites( ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites( ONBOARDING_STEPS, - KEYCLOAK_MANAGED_STEPS, ONBOARDING_OPTIONAL_STEPS, ) @@ -126,11 +122,26 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: return completed -def _auto_completed_keycloak_steps(username: str) -> set[str]: +def _password_rotation_requested(conn, request_code: str) -> bool: + row = conn.execute( + """ + SELECT 1 + FROM access_request_onboarding_artifacts + WHERE request_code = %s AND artifact = %s + LIMIT 1 + """, + (request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), + ).fetchone() + return bool(row) + + +def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: if not username: return set() if not admin_client().ready(): return set() + if not request_code: + return set() completed: set[str] = set() try: @@ -152,8 +163,8 @@ def _auto_completed_keycloak_steps(username: str) -> set[str]: actions_list = [a for a in actions if isinstance(a, str)] required_actions = set(actions_list) - if "UPDATE_PASSWORD" not in required_actions: - completed.add("keycloak_password_changed") + if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions: + completed.add("keycloak_password_rotated") # Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action, # which forces users to enroll MFA at first login. We no longer require that, so @@ -174,7 +185,7 @@ def _auto_completed_keycloak_steps(username: str) -> set[str]: def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]: completed = _fetch_completed_onboarding_steps(conn, request_code) - return completed | _auto_completed_keycloak_steps(username) + return completed | _auto_completed_keycloak_steps(conn, request_code, username) def _automation_ready(conn, request_code: str, username: str) -> bool: @@ -255,10 +266,14 @@ def _fetch_optional_mfa_state(conn, request_code: str) -> str: def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) + password_rotation_requested = _password_rotation_requested(conn, request_code) return { "required_steps": list(ONBOARDING_REQUIRED_STEPS), "optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS), "completed_steps": completed_steps, + "keycloak": { + "password_rotation_requested": password_rotation_requested, + }, "optional": { "keycloak_mfa_optional": { "state": _fetch_optional_mfa_state(conn, request_code), @@ -751,6 +766,75 @@ def register(app) -> None: } ) + @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) + @require_auth + def request_access_onboarding_keycloak_password_rotate() -> Any: + if not configured(): + return jsonify({"error": "server not configured"}), 503 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + if not code: + return jsonify({"error": "request_code is required"}), 400 + + username = getattr(g, "keycloak_username", "") or "" + if not username: + return jsonify({"error": "invalid token"}), 401 + + if not admin_client().ready(): + return jsonify({"error": "keycloak admin unavailable"}), 503 + + try: + with connect() as conn: + row = conn.execute( + "SELECT username, status FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + if (row.get("username") or "") != username: + return jsonify({"error": "forbidden"}), 403 + + status = _normalize_status(row.get("status") or "") + if status not in {"awaiting_onboarding", "ready"}: + return jsonify({"error": "onboarding not available"}), 409 + + prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set()) + if prerequisites: + current_completed = _completed_onboarding_steps(conn, code, username) + missing = sorted(prerequisites - current_completed) + if missing: + return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 + + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return jsonify({"error": "keycloak user not found"}), 409 + + full = admin_client().get_user(user_id) + actions = full.get("requiredActions") + actions_list: list[str] = [] + if isinstance(actions, list): + actions_list = [a for a in actions if isinstance(a, str)] + if "UPDATE_PASSWORD" not in actions_list: + actions_list.append("UPDATE_PASSWORD") + admin_client().update_user(user_id, {"requiredActions": actions_list}) + + conn.execute( + """ + INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) + VALUES (%s, %s, NOW()::text) + ON CONFLICT (request_code, artifact) DO NOTHING + """, + (code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), + ) + + onboarding_payload = _onboarding_payload(conn, code, username) + except Exception: + return jsonify({"error": "failed to request password rotation"}), 502 + + return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload}) + @app.route("/api/access/request/onboarding/mfa", methods=["POST"]) @require_auth def request_access_onboarding_mfa_optional() -> Any: diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 7ce32aa..40d6dcb 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -90,8 +90,8 @@

Temporary password

- Use this password to log in for the first time. Keycloak will prompt you to change it. This password is shown - once — copy it now. + Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate + it later after Vaultwarden is set up. This password is shown once — copy it now.

Password @@ -118,20 +118,88 @@

- Keycloak marks this complete once it no longer requires you to update your password. - Use the account console. + Open Passwords and set a strong master + password you won't forget. If you can't sign in yet, check your Atlas mailbox in + Nextcloud Mail for the invite link. +

+ + +
  • + +

    + Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted). + Bitwarden downloads. +

    +
  • + +
  • + +

    + Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock. + Bitwarden downloads. +

    +
  • + +
  • + +
    + + Open Keycloak +
    +

    + After Vaultwarden is set up, rotate your Keycloak password to a strong one and store it in Vaultwarden. + Atlas verifies this once Keycloak no longer requires you to update your password.

  • @@ -183,26 +251,6 @@ -
  • - -

    - Open Passwords and set a strong master - password you won't forget. If you can't sign in yet, check your Atlas mailbox in - Nextcloud Mail for the invite link. -

    -
  • -
  • @@ -332,22 +381,8 @@ const aegisQr = ref(""); const freeOtpQr = ref(""); const mfaQrError = ref(""); const mfaQrReady = ref(false); +const keycloakPasswordRotationRequested = ref(false); const extraSteps = [ - { - id: "vaultwarden_browser_extension", - title: "Install the Vaultwarden browser extension", - description: - "Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted).", - primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" }, - secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" }, - }, - { - id: "vaultwarden_mobile_app", - title: "Install Bitwarden on your phone", - description: "Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.", - primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" }, - secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" }, - }, { id: "elementx_setup", title: "Install Element X and sign in", @@ -364,7 +399,7 @@ const extraSteps = [ id: "mail_client_setup", title: "Set up mail on a device", description: - "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (Thunderbird, Apple Mail, Outlook, etc).", + "Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail, Thunderbird, Apple Mail, Outlook, etc).", primaryLink: { href: "/account", text: "Account" }, }, ]; @@ -408,7 +443,7 @@ function isMfaDecided() { } function isMfaBlocked() { - return !isStepDone("keycloak_password_changed"); + return !isStepDone("keycloak_password_rotated"); } function mfaPillLabel() { @@ -427,6 +462,20 @@ function mfaPillClass() { return "pill-warn"; } +function keycloakRotationPillLabel() { + if (isStepDone("keycloak_password_rotated")) return "done"; + if (isStepBlocked("keycloak_password_rotated")) return "blocked"; + if (keycloakPasswordRotationRequested.value) return "rotate now"; + return "ready"; +} + +function keycloakRotationPillClass() { + if (isStepDone("keycloak_password_rotated")) return "pill-ok"; + if (isStepBlocked("keycloak_password_rotated")) return "pill-wait"; + if (keycloakPasswordRotationRequested.value) return "pill-warn"; + return "pill-info"; +} + async function maybeGenerateMfaQrs(event) { if (mfaQrReady.value) return; const details = event?.target; @@ -446,12 +495,12 @@ function isStepBlocked(step) { Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length ? onboarding.value.required_steps : [ - "keycloak_password_changed", "vaultwarden_master_password", - "element_recovery_key", - "element_recovery_key_stored", "vaultwarden_browser_extension", "vaultwarden_mobile_app", + "keycloak_password_rotated", + "element_recovery_key", + "element_recovery_key_stored", "elementx_setup", "jellyfin_login", "mail_client_setup", @@ -499,15 +548,15 @@ async function check() { status.value = data.status || "unknown"; requestUsername.value = data.username || ""; onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [], optional: {} }; + keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested); tasks.value = Array.isArray(data.tasks) ? data.tasks : []; blocked.value = Boolean(data.blocked); - if (data.initial_password) { - initialPassword.value = data.initial_password; - } + initialPassword.value = data.initial_password || ""; } catch (err) { error.value = err.message || "Failed to check status"; tasks.value = []; blocked.value = false; + keycloakPasswordRotationRequested.value = false; } finally { loading.value = false; } @@ -575,7 +624,7 @@ async function setMfaOptional(state) { return; } if (isMfaBlocked()) { - error.value = "Change your Keycloak password first."; + error.value = "Rotate your Keycloak password first."; return; } if (state !== "done" && state !== "skipped") return; @@ -598,13 +647,43 @@ async function setMfaOptional(state) { } } +async function requestKeycloakPasswordRotation() { + if (!auth.authenticated) { + error.value = "Log in to request password rotation."; + return; + } + if (isStepBlocked("keycloak_password_rotated")) { + error.value = "Complete earlier onboarding steps first."; + return; + } + if (keycloakPasswordRotationRequested.value) return; + error.value = ""; + loading.value = true; + try { + const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_code: requestCode.value.trim() }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); + status.value = data.status || status.value; + onboarding.value = data.onboarding || onboarding.value; + keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested); + } catch (err) { + error.value = err.message || "Failed to request password rotation"; + } finally { + loading.value = false; + } +} + async function toggleStep(step, event) { const checked = Boolean(event?.target?.checked); if (!auth.authenticated) { event?.preventDefault?.(); return; } - if (step === "keycloak_password_changed") { + if (step === "keycloak_password_rotated") { event?.preventDefault?.(); return; }