diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index 8b3dff9..6bca00b 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -73,6 +73,17 @@ def ensure_schema() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS access_request_onboarding_artifacts ( + request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE, + artifact TEXT NOT NULL, + value_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (request_code, artifact) + ) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS access_requests_status_created_at @@ -91,6 +102,12 @@ def ensure_schema() -> None: ON access_request_onboarding_steps (request_code) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS access_request_onboarding_artifacts_request_code + ON access_request_onboarding_artifacts (request_code) + """ + ) conn.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index a77563d..24318ae 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -61,19 +61,38 @@ ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_master_password", "element_recovery_key", "element_recovery_key_stored", + "vaultwarden_browser_extension", + "vaultwarden_mobile_app", + "elementx_setup", + "jellyfin_login", + "mail_client_setup", ) KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_changed"} -ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { - "vaultwarden_master_password": {"keycloak_password_changed"}, - "element_recovery_key": {"keycloak_password_changed", "vaultwarden_master_password"}, - "element_recovery_key_stored": { - "keycloak_password_changed", - "vaultwarden_master_password", - "element_recovery_key", - }, -} + +def _sequential_prerequisites( + steps: tuple[str, ...], + keycloak_managed_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) + completed.append(step) + return prerequisites + + +ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites( + ONBOARDING_STEPS, + KEYCLOAK_MANAGED_STEPS, +) + +_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256" +_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") def _normalize_status(status: str) -> str: @@ -572,6 +591,11 @@ def register(app) -> None: mark_done = completed if mark_done: + if step == "element_recovery_key": + return ( + jsonify({"error": "step requires verification"}), + 400, + ) prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set()) if prerequisites: current_completed = _completed_onboarding_steps(conn, code, username) @@ -591,6 +615,11 @@ def register(app) -> None: "DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s", (code, step), ) + if step == "element_recovery_key": + conn.execute( + "DELETE FROM access_request_onboarding_artifacts WHERE request_code = %s AND artifact = %s", + (code, _ELEMENT_RECOVERY_ARTIFACT), + ) # Re-evaluate completion to update request status to ready if applicable. status = _advance_status(conn, code, username, status) @@ -605,3 +634,78 @@ def register(app) -> None: "onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps}, } ) + + @app.route("/api/access/request/onboarding/element-recovery", methods=["POST"]) + @require_auth + def request_access_onboarding_element_recovery() -> 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() + sha256_hex = (payload.get("sha256") or payload.get("sha256_hex") or "").strip().lower() + + if not code: + return jsonify({"error": "request_code is required"}), 400 + if not sha256_hex: + return jsonify({"error": "sha256 is required"}), 400 + if not _SHA256_HEX_RE.fullmatch(sha256_hex): + return jsonify({"error": "invalid sha256"}), 400 + + username = getattr(g, "keycloak_username", "") or "" + if not username: + return jsonify({"error": "invalid token"}), 401 + + 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("element_recovery_key", 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 + + conn.execute( + """ + INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) + VALUES (%s, %s, %s) + ON CONFLICT (request_code, artifact) DO UPDATE + SET value_hash = EXCLUDED.value_hash, + created_at = NOW() + """, + (code, _ELEMENT_RECOVERY_ARTIFACT, sha256_hex), + ) + conn.execute( + """ + INSERT INTO access_request_onboarding_steps (request_code, step) + VALUES (%s, %s) + ON CONFLICT (request_code, step) DO NOTHING + """, + (code, "element_recovery_key"), + ) + + status = _advance_status(conn, code, username, status) + completed_steps = sorted(_completed_onboarding_steps(conn, code, username)) + except Exception: + return jsonify({"error": "failed to verify element recovery key"}), 502 + + return jsonify( + { + "ok": True, + "status": status, + "onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps}, + } + ) diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 9954e9b..dae44ac 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -156,19 +156,40 @@
- In Element, create a recovery key so you can restore encrypted history if you lose a device. + 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.
Save the recovery key in Vaultwarden so it doesn't get lost.
+ ++ {{ step.description }} + + {{ step.primaryLink.text }}. + + + + {{ step.secondaryLink.text }}. + +
+