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;
}