diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index 970f009..31a8015 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -204,6 +204,19 @@ class KeycloakAdminClient: ) resp.raise_for_status() + def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]: + url = ( + f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" + f"/users/{quote(user_id, safe='')}/credentials" + ) + with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, headers=self._headers()) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict)] + _OIDC: KeycloakOIDC | None = None _ADMIN: KeycloakAdminClient | None = None diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 92ab734..144e1aa 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -41,11 +41,15 @@ def _client_ip() -> str: ONBOARDING_STEPS: tuple[str, ...] = ( "keycloak_password_changed", + "keycloak_mfa_configured", "vaultwarden_master_password", "element_recovery_key", "element_recovery_key_stored", ) +KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_changed", "keycloak_mfa_configured"} +KEYCLOAK_OTP_CRED_TYPES: set[str] = {"otp", "totp"} + def _normalize_status(status: str) -> str: cleaned = (status or "").strip().lower() @@ -67,6 +71,57 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: return completed +def _auto_completed_keycloak_steps(username: str) -> set[str]: + if not username: + return set() + if not admin_client().ready(): + return set() + + completed: set[str] = set() + try: + 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 set() + + full = {} + try: + full = admin_client().get_user(user_id) + except Exception: + full = user if isinstance(user, dict) else {} + + actions = full.get("requiredActions") + required_actions: set[str] = set() + if isinstance(actions, list): + required_actions = {a for a in actions if isinstance(a, str)} + + if "UPDATE_PASSWORD" not in required_actions: + completed.add("keycloak_password_changed") + + otp_present = False + try: + creds = admin_client().get_user_credentials(user_id) + for cred in creds: + ctype = cred.get("type") if isinstance(cred, dict) else None + if isinstance(ctype, str) and ctype.lower() in KEYCLOAK_OTP_CRED_TYPES: + otp_present = True + break + except Exception: + otp_present = False + + if otp_present or "CONFIGURE_TOTP" not in required_actions: + completed.add("keycloak_mfa_configured") + except Exception: + return set() + + return completed + + +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) + + def _automation_ready(conn, request_code: str, username: str) -> bool: if not username: return False @@ -112,7 +167,7 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str: return "awaiting_onboarding" if status == "awaiting_onboarding": - completed = _fetch_completed_onboarding_steps(conn, request_code) + completed = _completed_onboarding_steps(conn, request_code, username) if set(ONBOARDING_STEPS).issubset(completed): conn.execute( "UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'", @@ -260,7 +315,7 @@ def register(app) -> None: if status in {"awaiting_onboarding", "ready"}: response["onboarding_url"] = f"/onboarding?code={code}" if status in {"awaiting_onboarding", "ready"}: - completed = sorted(_fetch_completed_onboarding_steps(conn, code)) + completed = sorted(_completed_onboarding_steps(conn, code, row.get("username") or "")) response["onboarding"] = { "required_steps": list(ONBOARDING_STEPS), "completed_steps": completed, @@ -284,6 +339,8 @@ def register(app) -> None: return jsonify({"error": "request_code is required"}), 400 if step not in ONBOARDING_STEPS: return jsonify({"error": "invalid step"}), 400 + if step in KEYCLOAK_MANAGED_STEPS: + return jsonify({"error": "step is managed by keycloak"}), 400 username = getattr(g, "keycloak_username", "") or "" if not username: @@ -325,7 +382,7 @@ def register(app) -> None: # Re-evaluate completion to update request status to ready if applicable. status = _advance_status(conn, code, username, status) - completed_steps = sorted(_fetch_completed_onboarding_steps(conn, code)) + completed_steps = sorted(_completed_onboarding_steps(conn, code, username)) except Exception: return jsonify({"error": "failed to update onboarding"}), 502 diff --git a/frontend/src/views/AppsView.vue b/frontend/src/views/AppsView.vue index 7986a66..fd2d32e 100644 --- a/frontend/src/views/AppsView.vue +++ b/frontend/src/views/AppsView.vue @@ -72,6 +72,12 @@ const sections = [ target: "_blank", description: "Password manager (Bitwarden-compatible).", }, + { + name: "Keycloak", + url: "https://sso.bstein.dev/realms/atlas/account", + target: "_blank", + description: "Account security + MFA (2FA) settings.", + }, ], }, { diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index bf2ce14..3d691c8 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -51,7 +51,7 @@

- Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done. + Some steps are verified automatically from Keycloak (password + MFA). Others can't be verified yet — mark them complete once you're done.

@@ -85,13 +85,39 @@ Change your Keycloak password + + {{ isStepDone("keycloak_password_changed") ? "verified" : "pending" }} +

- Set a strong account password in Keycloak. Use the + Keycloak marks this complete once it no longer requires you to update your password. + Use the account console. +

+ + +
  • + +

    + Add a TOTP authenticator (2FA) in Keycloak: account console.

  • @@ -263,6 +289,10 @@ async function toggleStep(step, event) { event?.preventDefault?.(); return; } + if (step === "keycloak_password_changed" || step === "keycloak_mfa_configured") { + event?.preventDefault?.(); + return; + } error.value = ""; loading.value = true; try { @@ -432,6 +462,13 @@ button.primary { color: var(--text-strong); } +.auto-pill { + margin-left: auto; + font-size: 12px; + padding: 3px 10px; + border-radius: 999px; +} + .check-item input[type="checkbox"] { width: 18px; height: 18px;