onboarding: rotate Keycloak after Vaultwarden

This commit is contained in:
Brad Stein 2026-01-04 22:49:34 -03:00
parent f708bee4bf
commit 698ed49a9b
3 changed files with 247 additions and 80 deletions

View File

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

View File

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

View File

@ -90,8 +90,8 @@
<div v-if="initialPassword" class="initial-password">
<h3>Temporary password</h3>
<p class="muted">
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.
</p>
<div class="request-code-row">
<span class="label mono">Password</span>
@ -118,20 +118,88 @@
<label>
<input
type="checkbox"
:checked="isStepDone('keycloak_password_changed')"
disabled
:checked="isStepDone('vaultwarden_master_password')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_master_password')"
@change="toggleStep('vaultwarden_master_password', $event)"
/>
<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>Set a Vaultwarden master password</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_master_password')">
{{ stepPillLabel("vaultwarden_master_password") }}
</span>
</label>
<p class="muted">
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>.
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
password you won't forget. If you can't sign in yet, check your Atlas mailbox in
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Nextcloud Mail</a> for the invite link.
</p>
</li>
<li class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_browser_extension')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_browser_extension')"
@change="toggleStep('vaultwarden_browser_extension', $event)"
/>
<span>Install the Vaultwarden browser extension</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_browser_extension')">
{{ stepPillLabel("vaultwarden_browser_extension") }}
</span>
</label>
<p class="muted">
Install Bitwarden in your browser and point it at vault.bstein.dev (Settings Account Environment Self-hosted).
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_mobile_app')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_mobile_app')"
@change="toggleStep('vaultwarden_mobile_app', $event)"
/>
<span>Install Bitwarden on your phone</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_mobile_app')">
{{ stepPillLabel("vaultwarden_mobile_app") }}
</span>
</label>
<p class="muted">
Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.
<a href="https://bitwarden.com/download" target="_blank" rel="noreferrer">Bitwarden downloads</a>.
</p>
</li>
<li class="check-item">
<label>
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
<span>Rotate your Keycloak password</span>
<span class="pill mono auto-pill" :class="keycloakRotationPillClass()">
{{ keycloakRotationPillLabel() }}
</span>
</label>
<div class="mfa-actions">
<button
class="secondary"
type="button"
@click="requestKeycloakPasswordRotation"
:disabled="
!auth.authenticated ||
loading ||
isStepDone('keycloak_password_rotated') ||
isStepBlocked('keycloak_password_rotated') ||
keycloakPasswordRotationRequested
"
>
Enable rotation
</button>
<a class="mono" href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">Open Keycloak</a>
</div>
<p class="muted">
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.
</p>
</li>
@ -183,26 +251,6 @@
</details>
</li>
<li class="check-item">
<label>
<input
type="checkbox"
:checked="isStepDone('vaultwarden_master_password')"
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_master_password')"
@change="toggleStep('vaultwarden_master_password', $event)"
/>
<span>Set a Vaultwarden master password</span>
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_master_password')">
{{ stepPillLabel("vaultwarden_master_password") }}
</span>
</label>
<p class="muted">
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
password you won't forget. If you can't sign in yet, check your Atlas mailbox in
<a href="https://cloud.bstein.dev" target="_blank" rel="noreferrer">Nextcloud Mail</a> for the invite link.
</p>
</li>
<li class="check-item">
<label>
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
@ -239,6 +287,7 @@
<p class="muted">
In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a SHA-256 hash so the
recovery key itself is never saved server-side.
Open <a href="https://live.bstein.dev/#/settings" target="_blank" rel="noreferrer">Element settings</a> Encryption.
</p>
</li>
@ -332,22 +381,8 @@ const aegisQr = ref("");
const freeOtpQr = ref("");
const mfaQrError = ref("");
const mfaQrReady = ref(false);
const keycloakPasswordRotationRequested = ref(false);
const extraSteps = [
{
id: "vaultwarden_browser_extension",
title: "Install the Vaultwarden browser extension",
description:
"Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted).",
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
},
{
id: "vaultwarden_mobile_app",
title: "Install Bitwarden on your phone",
description: "Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock.",
primaryLink: { href: "https://bitwarden.com/download", text: "Bitwarden downloads" },
secondaryLink: { href: "https://vault.bstein.dev", text: "Passwords" },
},
{
id: "elementx_setup",
title: "Install Element X and sign in",
@ -364,7 +399,7 @@ const extraSteps = [
id: "mail_client_setup",
title: "Set up mail on a device",
description:
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (Thunderbird, Apple Mail, Outlook, etc).",
"Use the IMAP/SMTP details on your Account page to add mail to your phone or desktop client (FairEmail, Thunderbird, Apple Mail, Outlook, etc).",
primaryLink: { href: "/account", text: "Account" },
},
];
@ -408,7 +443,7 @@ function isMfaDecided() {
}
function isMfaBlocked() {
return !isStepDone("keycloak_password_changed");
return !isStepDone("keycloak_password_rotated");
}
function mfaPillLabel() {
@ -427,6 +462,20 @@ function mfaPillClass() {
return "pill-warn";
}
function keycloakRotationPillLabel() {
if (isStepDone("keycloak_password_rotated")) return "done";
if (isStepBlocked("keycloak_password_rotated")) return "blocked";
if (keycloakPasswordRotationRequested.value) return "rotate now";
return "ready";
}
function keycloakRotationPillClass() {
if (isStepDone("keycloak_password_rotated")) return "pill-ok";
if (isStepBlocked("keycloak_password_rotated")) return "pill-wait";
if (keycloakPasswordRotationRequested.value) return "pill-warn";
return "pill-info";
}
async function maybeGenerateMfaQrs(event) {
if (mfaQrReady.value) return;
const details = event?.target;
@ -446,12 +495,12 @@ function isStepBlocked(step) {
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
? onboarding.value.required_steps
: [
"keycloak_password_changed",
"vaultwarden_master_password",
"element_recovery_key",
"element_recovery_key_stored",
"vaultwarden_browser_extension",
"vaultwarden_mobile_app",
"keycloak_password_rotated",
"element_recovery_key",
"element_recovery_key_stored",
"elementx_setup",
"jellyfin_login",
"mail_client_setup",
@ -499,15 +548,15 @@ async function check() {
status.value = data.status || "unknown";
requestUsername.value = data.username || "";
onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [], optional: {} };
keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
blocked.value = Boolean(data.blocked);
if (data.initial_password) {
initialPassword.value = data.initial_password;
}
initialPassword.value = data.initial_password || "";
} catch (err) {
error.value = err.message || "Failed to check status";
tasks.value = [];
blocked.value = false;
keycloakPasswordRotationRequested.value = false;
} finally {
loading.value = false;
}
@ -575,7 +624,7 @@ async function setMfaOptional(state) {
return;
}
if (isMfaBlocked()) {
error.value = "Change your Keycloak password first.";
error.value = "Rotate your Keycloak password first.";
return;
}
if (state !== "done" && state !== "skipped") return;
@ -598,13 +647,43 @@ async function setMfaOptional(state) {
}
}
async function requestKeycloakPasswordRotation() {
if (!auth.authenticated) {
error.value = "Log in to request password rotation.";
return;
}
if (isStepBlocked("keycloak_password_rotated")) {
error.value = "Complete earlier onboarding steps first.";
return;
}
if (keycloakPasswordRotationRequested.value) return;
error.value = "";
loading.value = true;
try {
const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_code: requestCode.value.trim() }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
status.value = data.status || status.value;
onboarding.value = data.onboarding || onboarding.value;
keycloakPasswordRotationRequested.value = Boolean(data.onboarding?.keycloak?.password_rotation_requested);
} catch (err) {
error.value = err.message || "Failed to request password rotation";
} finally {
loading.value = false;
}
}
async function toggleStep(step, event) {
const checked = Boolean(event?.target?.checked);
if (!auth.authenticated) {
event?.preventDefault?.();
return;
}
if (step === "keycloak_password_changed") {
if (step === "keycloak_password_rotated") {
event?.preventDefault?.();
return;
}