portal: auto-verify keycloak onboarding
This commit is contained in:
parent
b0ed2374e3
commit
2c2f0b04d9
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user