portal: auto-verify keycloak onboarding
This commit is contained in:
parent
b0ed2374e3
commit
2c2f0b04d9
@ -204,6 +204,19 @@ class KeycloakAdminClient:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
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
|
_OIDC: KeycloakOIDC | None = None
|
||||||
_ADMIN: KeycloakAdminClient | None = None
|
_ADMIN: KeycloakAdminClient | None = None
|
||||||
|
|||||||
@ -41,11 +41,15 @@ def _client_ip() -> str:
|
|||||||
|
|
||||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||||
"keycloak_password_changed",
|
"keycloak_password_changed",
|
||||||
|
"keycloak_mfa_configured",
|
||||||
"vaultwarden_master_password",
|
"vaultwarden_master_password",
|
||||||
"element_recovery_key",
|
"element_recovery_key",
|
||||||
"element_recovery_key_stored",
|
"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:
|
def _normalize_status(status: str) -> str:
|
||||||
cleaned = (status or "").strip().lower()
|
cleaned = (status or "").strip().lower()
|
||||||
@ -67,6 +71,57 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
|||||||
return completed
|
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:
|
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||||
if not username:
|
if not username:
|
||||||
return False
|
return False
|
||||||
@ -112,7 +167,7 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
|||||||
return "awaiting_onboarding"
|
return "awaiting_onboarding"
|
||||||
|
|
||||||
if status == "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):
|
if set(ONBOARDING_STEPS).issubset(completed):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
|
"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"}:
|
if status in {"awaiting_onboarding", "ready"}:
|
||||||
response["onboarding_url"] = f"/onboarding?code={code}"
|
response["onboarding_url"] = f"/onboarding?code={code}"
|
||||||
if status in {"awaiting_onboarding", "ready"}:
|
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"] = {
|
response["onboarding"] = {
|
||||||
"required_steps": list(ONBOARDING_STEPS),
|
"required_steps": list(ONBOARDING_STEPS),
|
||||||
"completed_steps": completed,
|
"completed_steps": completed,
|
||||||
@ -284,6 +339,8 @@ def register(app) -> None:
|
|||||||
return jsonify({"error": "request_code is required"}), 400
|
return jsonify({"error": "request_code is required"}), 400
|
||||||
if step not in ONBOARDING_STEPS:
|
if step not in ONBOARDING_STEPS:
|
||||||
return jsonify({"error": "invalid step"}), 400
|
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 ""
|
username = getattr(g, "keycloak_username", "") or ""
|
||||||
if not username:
|
if not username:
|
||||||
@ -325,7 +382,7 @@ def register(app) -> None:
|
|||||||
|
|
||||||
# Re-evaluate completion to update request status to ready if applicable.
|
# Re-evaluate completion to update request status to ready if applicable.
|
||||||
status = _advance_status(conn, code, username, status)
|
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:
|
except Exception:
|
||||||
return jsonify({"error": "failed to update onboarding"}), 502
|
return jsonify({"error": "failed to update onboarding"}), 502
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,12 @@ const sections = [
|
|||||||
target: "_blank",
|
target: "_blank",
|
||||||
description: "Password manager (Bitwarden-compatible).",
|
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>
|
</div>
|
||||||
|
|
||||||
<p class="muted">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div v-if="initialPassword" class="initial-password">
|
<div v-if="initialPassword" class="initial-password">
|
||||||
@ -85,13 +85,39 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isStepDone('keycloak_password_changed')"
|
:checked="isStepDone('keycloak_password_changed')"
|
||||||
:disabled="!auth.authenticated || loading"
|
disabled
|
||||||
@change="toggleStep('keycloak_password_changed', $event)"
|
|
||||||
/>
|
/>
|
||||||
<span>Change your Keycloak password</span>
|
<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>
|
</label>
|
||||||
<p class="muted">
|
<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>.
|
<a href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">account console</a>.
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
@ -263,6 +289,10 @@ async function toggleStep(step, event) {
|
|||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (step === "keycloak_password_changed" || step === "keycloak_mfa_configured") {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
error.value = "";
|
error.value = "";
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -432,6 +462,13 @@ button.primary {
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-pill {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.check-item input[type="checkbox"] {
|
.check-item input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user