portal: auto-verify keycloak onboarding

This commit is contained in:
Brad Stein 2026-01-03 00:57:14 -03:00
parent b0ed2374e3
commit 2c2f0b04d9
4 changed files with 120 additions and 7 deletions

View File

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

View File

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

View File

@ -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.",
},
],
},
{

View File

@ -51,7 +51,7 @@
</div>
<p class="muted">
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.
</p>
<div v-if="initialPassword" class="initial-password">
@ -85,13 +85,39 @@
<input
type="checkbox"
:checked="isStepDone('keycloak_password_changed')"
:disabled="!auth.authenticated || loading"
@change="toggleStep('keycloak_password_changed', $event)"
disabled
/>
<span>Change your Keycloak password</span>
<span
class="pill mono auto-pill"
:class="isStepDone('keycloak_password_changed') ? 'pill-ok' : 'pill-warn'"
>
{{ isStepDone("keycloak_password_changed") ? "verified" : "pending" }}
</span>
</label>
<p class="muted">
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 <a href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">account console</a>.
</p>
</li>
<li class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone('keycloak_mfa_configured')"
disabled
/>
<span>Enable Keycloak MFA</span>
<span
class="pill mono auto-pill"
:class="isStepDone('keycloak_mfa_configured') ? 'pill-ok' : 'pill-warn'"
>
{{ isStepDone("keycloak_mfa_configured") ? "verified" : "pending" }}
</span>
</label>
<p class="muted">
Add a TOTP authenticator (2FA) in Keycloak:
<a href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">account console</a>.
</p>
</li>
@ -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;