onboarding: rotate Keycloak after Vaultwarden
This commit is contained in:
parent
f708bee4bf
commit
698ed49a9b
@ -202,17 +202,21 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
existing_email_user = admin_client().find_user_by_email(email)
|
existing_email_user = admin_client().find_user_by_email(email)
|
||||||
if existing_email_user and (existing_email_user.get("username") or "") != username:
|
if existing_email_user and (existing_email_user.get("username") or "") != username:
|
||||||
raise RuntimeError("email is already associated with an existing Atlas account")
|
raise RuntimeError("email is already associated with an existing Atlas account")
|
||||||
# Always enforce email verification in Keycloak itself (even if the portal
|
# The portal already verified the external contact email before approval,
|
||||||
# already verified an external email before approval).
|
# so mark it as verified in Keycloak.
|
||||||
# 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
|
# Do not force password rotation on first login: the onboarding flow
|
||||||
# mandatory for every service.
|
# intentionally guides users through Vaultwarden first, then triggers a
|
||||||
required_actions = ["UPDATE_PASSWORD", "VERIFY_EMAIL"]
|
# Keycloak password change step later.
|
||||||
|
#
|
||||||
|
# Do not force MFA enrollment during initial login: users can opt into MFA
|
||||||
|
# later.
|
||||||
|
required_actions: list[str] = []
|
||||||
payload = {
|
payload = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"email": email,
|
"email": email,
|
||||||
"emailVerified": False,
|
"emailVerified": True,
|
||||||
"requiredActions": required_actions,
|
"requiredActions": required_actions,
|
||||||
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
|
"attributes": {MAILU_EMAIL_ATTR: [mailu_email]},
|
||||||
}
|
}
|
||||||
@ -256,7 +260,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return ProvisionResult(ok=False, status="accounts_building")
|
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:
|
try:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise RuntimeError("missing user id")
|
raise RuntimeError("missing user id")
|
||||||
@ -280,7 +284,7 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
initial_password = password_value
|
initial_password = password_value
|
||||||
|
|
||||||
if 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:
|
if isinstance(initial_password, str) and initial_password:
|
||||||
_upsert_task(conn, request_code, "keycloak_password", "ok", None)
|
_upsert_task(conn, request_code, "keycloak_password", "ok", None)
|
||||||
|
|||||||
@ -57,13 +57,13 @@ def _verify_url(request_code: str, token: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||||
"keycloak_password_changed",
|
|
||||||
"keycloak_mfa_optional",
|
|
||||||
"vaultwarden_master_password",
|
"vaultwarden_master_password",
|
||||||
"element_recovery_key",
|
|
||||||
"element_recovery_key_stored",
|
|
||||||
"vaultwarden_browser_extension",
|
"vaultwarden_browser_extension",
|
||||||
"vaultwarden_mobile_app",
|
"vaultwarden_mobile_app",
|
||||||
|
"keycloak_password_rotated",
|
||||||
|
"keycloak_mfa_optional",
|
||||||
|
"element_recovery_key",
|
||||||
|
"element_recovery_key_stored",
|
||||||
"elementx_setup",
|
"elementx_setup",
|
||||||
"jellyfin_login",
|
"jellyfin_login",
|
||||||
"mail_client_setup",
|
"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
|
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_STATE_ARTIFACT = "keycloak_mfa_optional_state"
|
||||||
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"}
|
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"}
|
||||||
|
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
|
||||||
|
|
||||||
|
|
||||||
def _sequential_prerequisites(
|
def _sequential_prerequisites(
|
||||||
steps: tuple[str, ...],
|
steps: tuple[str, ...],
|
||||||
keycloak_managed_steps: set[str],
|
|
||||||
optional_steps: set[str],
|
optional_steps: set[str],
|
||||||
) -> dict[str, set[str]]:
|
) -> dict[str, set[str]]:
|
||||||
completed: list[str] = []
|
completed: list[str] = []
|
||||||
prerequisites: dict[str, set[str]] = {}
|
prerequisites: dict[str, set[str]] = {}
|
||||||
for step in steps:
|
for step in steps:
|
||||||
if step in keycloak_managed_steps:
|
|
||||||
completed.append(step)
|
|
||||||
continue
|
|
||||||
prerequisites[step] = set(completed)
|
prerequisites[step] = set(completed)
|
||||||
if step not in optional_steps:
|
if step not in optional_steps:
|
||||||
completed.append(step)
|
completed.append(step)
|
||||||
@ -98,7 +95,6 @@ def _sequential_prerequisites(
|
|||||||
|
|
||||||
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
|
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
|
||||||
ONBOARDING_STEPS,
|
ONBOARDING_STEPS,
|
||||||
KEYCLOAK_MANAGED_STEPS,
|
|
||||||
ONBOARDING_OPTIONAL_STEPS,
|
ONBOARDING_OPTIONAL_STEPS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -126,11 +122,26 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
|||||||
return completed
|
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:
|
if not username:
|
||||||
return set()
|
return set()
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
return set()
|
return set()
|
||||||
|
if not request_code:
|
||||||
|
return set()
|
||||||
|
|
||||||
completed: set[str] = set()
|
completed: set[str] = set()
|
||||||
try:
|
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)]
|
actions_list = [a for a in actions if isinstance(a, str)]
|
||||||
required_actions = set(actions_list)
|
required_actions = set(actions_list)
|
||||||
|
|
||||||
if "UPDATE_PASSWORD" not in required_actions:
|
if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions:
|
||||||
completed.add("keycloak_password_changed")
|
completed.add("keycloak_password_rotated")
|
||||||
|
|
||||||
# Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action,
|
# 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
|
# 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]:
|
def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]:
|
||||||
completed = _fetch_completed_onboarding_steps(conn, request_code)
|
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:
|
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]:
|
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
||||||
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
||||||
|
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||||
return {
|
return {
|
||||||
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
|
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
|
||||||
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
||||||
"completed_steps": completed_steps,
|
"completed_steps": completed_steps,
|
||||||
|
"keycloak": {
|
||||||
|
"password_rotation_requested": password_rotation_requested,
|
||||||
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"keycloak_mfa_optional": {
|
"keycloak_mfa_optional": {
|
||||||
"state": _fetch_optional_mfa_state(conn, request_code),
|
"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"])
|
@app.route("/api/access/request/onboarding/mfa", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def request_access_onboarding_mfa_optional() -> Any:
|
def request_access_onboarding_mfa_optional() -> Any:
|
||||||
|
|||||||
@ -90,8 +90,8 @@
|
|||||||
<div v-if="initialPassword" class="initial-password">
|
<div v-if="initialPassword" class="initial-password">
|
||||||
<h3>Temporary password</h3>
|
<h3>Temporary password</h3>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Use this password to log in for the first time. Keycloak will prompt you to change it. This password is shown
|
Use this password to log in for the first time. You won't be forced to change it immediately — you'll rotate
|
||||||
once — copy it now.
|
it later after Vaultwarden is set up. This password is shown once — copy it now.
|
||||||
</p>
|
</p>
|
||||||
<div class="request-code-row">
|
<div class="request-code-row">
|
||||||
<span class="label mono">Password</span>
|
<span class="label mono">Password</span>
|
||||||
@ -118,20 +118,88 @@
|
|||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isStepDone('keycloak_password_changed')"
|
:checked="isStepDone('vaultwarden_master_password')"
|
||||||
disabled
|
:disabled="!auth.authenticated || loading || isStepBlocked('vaultwarden_master_password')"
|
||||||
|
@change="toggleStep('vaultwarden_master_password', $event)"
|
||||||
/>
|
/>
|
||||||
<span>Change your Keycloak password</span>
|
<span>Set a Vaultwarden master password</span>
|
||||||
<span
|
<span class="pill mono auto-pill" :class="stepPillClass('vaultwarden_master_password')">
|
||||||
class="pill mono auto-pill"
|
{{ stepPillLabel("vaultwarden_master_password") }}
|
||||||
:class="isStepDone('keycloak_password_changed') ? 'pill-ok' : 'pill-warn'"
|
|
||||||
>
|
|
||||||
{{ isStepDone("keycloak_password_changed") ? "verified" : "pending" }}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Keycloak marks this complete once it no longer requires you to update your password.
|
Open <a href="https://vault.bstein.dev" target="_blank" rel="noreferrer">Passwords</a> and set a strong master
|
||||||
Use the <a href="https://sso.bstein.dev/realms/atlas/account" target="_blank" rel="noreferrer">account console</a>.
|
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>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@ -183,26 +251,6 @@
|
|||||||
</details>
|
</details>
|
||||||
</li>
|
</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">
|
<li class="check-item">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
|
<input type="checkbox" :checked="isStepDone('element_recovery_key')" disabled />
|
||||||
@ -239,6 +287,7 @@
|
|||||||
<p class="muted">
|
<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
|
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.
|
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>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@ -332,22 +381,8 @@ const aegisQr = ref("");
|
|||||||
const freeOtpQr = ref("");
|
const freeOtpQr = ref("");
|
||||||
const mfaQrError = ref("");
|
const mfaQrError = ref("");
|
||||||
const mfaQrReady = ref(false);
|
const mfaQrReady = ref(false);
|
||||||
|
const keycloakPasswordRotationRequested = ref(false);
|
||||||
const extraSteps = [
|
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",
|
id: "elementx_setup",
|
||||||
title: "Install Element X and sign in",
|
title: "Install Element X and sign in",
|
||||||
@ -364,7 +399,7 @@ const extraSteps = [
|
|||||||
id: "mail_client_setup",
|
id: "mail_client_setup",
|
||||||
title: "Set up mail on a device",
|
title: "Set up mail on a device",
|
||||||
description:
|
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" },
|
primaryLink: { href: "/account", text: "Account" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -408,7 +443,7 @@ function isMfaDecided() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isMfaBlocked() {
|
function isMfaBlocked() {
|
||||||
return !isStepDone("keycloak_password_changed");
|
return !isStepDone("keycloak_password_rotated");
|
||||||
}
|
}
|
||||||
|
|
||||||
function mfaPillLabel() {
|
function mfaPillLabel() {
|
||||||
@ -427,6 +462,20 @@ function mfaPillClass() {
|
|||||||
return "pill-warn";
|
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) {
|
async function maybeGenerateMfaQrs(event) {
|
||||||
if (mfaQrReady.value) return;
|
if (mfaQrReady.value) return;
|
||||||
const details = event?.target;
|
const details = event?.target;
|
||||||
@ -446,12 +495,12 @@ function isStepBlocked(step) {
|
|||||||
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
|
Array.isArray(onboarding.value?.required_steps) && onboarding.value.required_steps.length
|
||||||
? onboarding.value.required_steps
|
? onboarding.value.required_steps
|
||||||
: [
|
: [
|
||||||
"keycloak_password_changed",
|
|
||||||
"vaultwarden_master_password",
|
"vaultwarden_master_password",
|
||||||
"element_recovery_key",
|
|
||||||
"element_recovery_key_stored",
|
|
||||||
"vaultwarden_browser_extension",
|
"vaultwarden_browser_extension",
|
||||||
"vaultwarden_mobile_app",
|
"vaultwarden_mobile_app",
|
||||||
|
"keycloak_password_rotated",
|
||||||
|
"element_recovery_key",
|
||||||
|
"element_recovery_key_stored",
|
||||||
"elementx_setup",
|
"elementx_setup",
|
||||||
"jellyfin_login",
|
"jellyfin_login",
|
||||||
"mail_client_setup",
|
"mail_client_setup",
|
||||||
@ -499,15 +548,15 @@ async function check() {
|
|||||||
status.value = data.status || "unknown";
|
status.value = data.status || "unknown";
|
||||||
requestUsername.value = data.username || "";
|
requestUsername.value = data.username || "";
|
||||||
onboarding.value = data.onboarding || { required_steps: [], optional_steps: [], completed_steps: [], optional: {} };
|
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 : [];
|
tasks.value = Array.isArray(data.tasks) ? data.tasks : [];
|
||||||
blocked.value = Boolean(data.blocked);
|
blocked.value = Boolean(data.blocked);
|
||||||
if (data.initial_password) {
|
initialPassword.value = data.initial_password || "";
|
||||||
initialPassword.value = data.initial_password;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || "Failed to check status";
|
error.value = err.message || "Failed to check status";
|
||||||
tasks.value = [];
|
tasks.value = [];
|
||||||
blocked.value = false;
|
blocked.value = false;
|
||||||
|
keycloakPasswordRotationRequested.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -575,7 +624,7 @@ async function setMfaOptional(state) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isMfaBlocked()) {
|
if (isMfaBlocked()) {
|
||||||
error.value = "Change your Keycloak password first.";
|
error.value = "Rotate your Keycloak password first.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state !== "done" && state !== "skipped") 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) {
|
async function toggleStep(step, event) {
|
||||||
const checked = Boolean(event?.target?.checked);
|
const checked = Boolean(event?.target?.checked);
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step === "keycloak_password_changed") {
|
if (step === "keycloak_password_rotated") {
|
||||||
event?.preventDefault?.();
|
event?.preventDefault?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user