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 @@
- 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.
- 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. +
++ 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.
- 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. -
-