portal: add vaultwarden grandfathered claim
This commit is contained in:
parent
498e1f4154
commit
4732334c44
@ -276,6 +276,26 @@ class KeycloakAdminClient:
|
||||
walk(items)
|
||||
return sorted(names)
|
||||
|
||||
def list_user_groups(self, user_id: str) -> list[str]:
|
||||
url = (
|
||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||
f"/users/{quote(user_id, safe='')}/groups"
|
||||
)
|
||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||
resp = client.get(url, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
items = resp.json()
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
names: list[str] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
names.append(name.lstrip("/"))
|
||||
return names
|
||||
|
||||
def add_user_to_group(self, user_id: str, group_id: str) -> None:
|
||||
url = (
|
||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||
|
||||
@ -218,7 +218,8 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||
"jellyfin_tv_setup": {"jellyfin_web_access"},
|
||||
}
|
||||
|
||||
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"}
|
||||
VAULTWARDEN_GRANDFATHERED_FLAG = "vaultwarden_grandfathered"
|
||||
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready", "grandfathered"}
|
||||
|
||||
|
||||
def _normalize_status(status: str) -> str:
|
||||
@ -241,6 +242,66 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||
return completed
|
||||
|
||||
|
||||
def _normalize_flag_list(raw: Any) -> set[str]:
|
||||
if isinstance(raw, list):
|
||||
return {item for item in raw if isinstance(item, str) and item}
|
||||
if isinstance(raw, str) and raw:
|
||||
return {raw}
|
||||
return set()
|
||||
|
||||
|
||||
def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]:
|
||||
row = conn.execute(
|
||||
"SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
||||
(request_code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return set(), ""
|
||||
flags = _normalize_flag_list(row.get("approval_flags"))
|
||||
email = row.get("contact_email") if isinstance(row, dict) else ""
|
||||
return flags, (email or "").strip()
|
||||
|
||||
|
||||
def _user_in_group(username: str, group_name: str) -> bool:
|
||||
if not username or not group_name:
|
||||
return False
|
||||
if not admin_client().ready():
|
||||
return False
|
||||
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 False
|
||||
groups = admin_client().list_user_groups(user_id)
|
||||
except Exception:
|
||||
return False
|
||||
return group_name in groups
|
||||
|
||||
|
||||
def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[bool, str]:
|
||||
flags, contact_email = _fetch_request_flags_and_email(conn, request_code)
|
||||
if VAULTWARDEN_GRANDFATHERED_FLAG in flags:
|
||||
return True, contact_email
|
||||
if _user_in_group(username, VAULTWARDEN_GRANDFATHERED_FLAG):
|
||||
return True, contact_email
|
||||
return False, contact_email
|
||||
|
||||
|
||||
def _resolve_recovery_email(username: str, fallback: str) -> str:
|
||||
if username and admin_client().ready():
|
||||
try:
|
||||
user = admin_client().find_user(username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
full = admin_client().get_user(user_id)
|
||||
email = full.get("email")
|
||||
if isinstance(email, str) and email.strip():
|
||||
return email.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return (fallback or "").strip()
|
||||
|
||||
|
||||
def _password_rotation_requested(conn, request_code: str) -> bool:
|
||||
row = conn.execute(
|
||||
"""
|
||||
@ -438,6 +499,8 @@ def _advance_status(conn, request_code: str, username: str, status: 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)
|
||||
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
||||
recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else ""
|
||||
return {
|
||||
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
|
||||
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
||||
@ -445,6 +508,10 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
|
||||
"keycloak": {
|
||||
"password_rotation_requested": password_rotation_requested,
|
||||
},
|
||||
"vaultwarden": {
|
||||
"grandfathered": grandfathered,
|
||||
"recovery_email": recovery_email,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -913,6 +980,7 @@ def register(app) -> None:
|
||||
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||
step = (payload.get("step") or "").strip()
|
||||
completed = payload.get("completed")
|
||||
vaultwarden_claim = bool(payload.get("vaultwarden_claim"))
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
@ -922,6 +990,7 @@ def register(app) -> None:
|
||||
return jsonify({"error": "step is managed by keycloak"}), 400
|
||||
|
||||
username = ""
|
||||
token_groups: set[str] = set()
|
||||
bearer = request.headers.get("Authorization", "")
|
||||
if bearer:
|
||||
parts = bearer.split(None, 1)
|
||||
@ -935,11 +1004,14 @@ def register(app) -> None:
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
username = claims.get("preferred_username") or ""
|
||||
groups = claims.get("groups")
|
||||
if isinstance(groups, list):
|
||||
token_groups = {g.lstrip("/") for g in groups if isinstance(g, str) and g}
|
||||
|
||||
try:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT username, status FROM access_requests WHERE request_code = %s",
|
||||
"SELECT username, status, approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
||||
(code,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@ -956,6 +1028,8 @@ def register(app) -> None:
|
||||
mark_done = completed
|
||||
|
||||
request_username = row.get("username") or ""
|
||||
approval_flags = _normalize_flag_list(row.get("approval_flags"))
|
||||
contact_email = (row.get("contact_email") or "").strip()
|
||||
|
||||
if mark_done:
|
||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
|
||||
@ -965,6 +1039,17 @@ def register(app) -> None:
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
if step == "vaultwarden_master_password":
|
||||
if vaultwarden_claim and not username:
|
||||
return jsonify({"error": "login required"}), 401
|
||||
grandfathered = (
|
||||
VAULTWARDEN_GRANDFATHERED_FLAG in approval_flags
|
||||
or VAULTWARDEN_GRANDFATHERED_FLAG in token_groups
|
||||
or _user_in_group(request_username, VAULTWARDEN_GRANDFATHERED_FLAG)
|
||||
)
|
||||
if vaultwarden_claim and not grandfathered:
|
||||
return jsonify({"error": "vaultwarden claim not allowed"}), 403
|
||||
if vaultwarden_claim and not admin_client().ready():
|
||||
return jsonify({"error": "keycloak admin unavailable"}), 503
|
||||
try:
|
||||
_request_keycloak_password_rotation(conn, code, request_username)
|
||||
except Exception:
|
||||
@ -972,16 +1057,36 @@ def register(app) -> None:
|
||||
if request_username and admin_client().ready():
|
||||
try:
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if vaultwarden_claim:
|
||||
recovery_email = _resolve_recovery_email(request_username, contact_email)
|
||||
if not recovery_email:
|
||||
return jsonify({"error": "recovery email missing"}), 409
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_email",
|
||||
recovery_email,
|
||||
)
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_status",
|
||||
"grandfathered",
|
||||
)
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_synced_at",
|
||||
now,
|
||||
)
|
||||
else:
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_status",
|
||||
"already_present",
|
||||
)
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_master_password_set_at",
|
||||
now,
|
||||
)
|
||||
admin_client().set_user_attribute(
|
||||
request_username,
|
||||
"vaultwarden_status",
|
||||
"already_present",
|
||||
)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to update vaultwarden status"}), 502
|
||||
|
||||
|
||||
@ -272,3 +272,20 @@ class AccessRequestTests(TestCase):
|
||||
data = resp.get_json()
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data.get("initial_password"), "temp-pass")
|
||||
|
||||
def test_onboarding_payload_includes_vaultwarden_grandfathered(self):
|
||||
rows = {
|
||||
"SELECT approval_flags": {
|
||||
"approval_flags": ["vaultwarden_grandfathered"],
|
||||
"contact_email": "alice@example.com",
|
||||
}
|
||||
}
|
||||
conn = DummyConn(rows_by_query=rows)
|
||||
with (
|
||||
mock.patch.object(ar, "_completed_onboarding_steps", lambda *args, **kwargs: set()),
|
||||
mock.patch.object(ar, "_password_rotation_requested", lambda *args, **kwargs: False),
|
||||
):
|
||||
payload = ar._onboarding_payload(conn, "alice~CODE123", "alice")
|
||||
vault = payload.get("vaultwarden") or {}
|
||||
self.assertTrue(vault.get("grandfathered"))
|
||||
self.assertEqual(vault.get("recovery_email"), "alice@example.com")
|
||||
|
||||
@ -489,7 +489,9 @@ const admin = reactive({
|
||||
selectedFlags: {},
|
||||
});
|
||||
const onboardingUrl = ref("/onboarding");
|
||||
const vaultwardenReady = computed(() => ["ready", "already_present", "active"].includes(vaultwarden.status));
|
||||
const vaultwardenReady = computed(() =>
|
||||
["ready", "already_present", "active", "grandfathered"].includes(vaultwarden.status),
|
||||
);
|
||||
const vaultwardenDisplayStatus = computed(() => (vaultwardenReady.value ? "ready" : vaultwarden.status));
|
||||
const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
|
||||
|
||||
|
||||
@ -177,6 +177,31 @@
|
||||
|
||||
<p class="muted" v-if="step.description">{{ step.description }}</p>
|
||||
|
||||
<div
|
||||
v-if="step.id === 'vaultwarden_master_password' && vaultwardenGrandfathered"
|
||||
class="claim-box"
|
||||
>
|
||||
<p class="muted">
|
||||
Already have a Vaultwarden account? Claim it with
|
||||
<span class="mono">{{ vaultwardenRecoveryEmail || "your recovery email" }}</span>.
|
||||
This skips the invite flow and keeps your existing vault.
|
||||
</p>
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="claimVaultwarden"
|
||||
:disabled="
|
||||
loading ||
|
||||
vaultwardenClaiming ||
|
||||
!auth.authenticated ||
|
||||
isStepDone(step.id) ||
|
||||
isStepBlocked(step.id)
|
||||
"
|
||||
>
|
||||
{{ vaultwardenClaiming ? "Claiming..." : "Claim existing account" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="step.bullets && step.bullets.length" class="step-bullets">
|
||||
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
|
||||
</ul>
|
||||
@ -318,6 +343,7 @@ const guideShots = ref({});
|
||||
const guidePage = ref({});
|
||||
const lightboxShot = ref(null);
|
||||
const confirmingStepId = ref("");
|
||||
const vaultwardenClaiming = ref(false);
|
||||
|
||||
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
||||
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
|
||||
@ -326,6 +352,8 @@ const passwordRevealHint = computed(() =>
|
||||
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
|
||||
: "",
|
||||
);
|
||||
const vaultwardenGrandfathered = computed(() => Boolean(onboarding.value?.vaultwarden?.grandfathered));
|
||||
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
|
||||
|
||||
const STEP_PREREQS = {
|
||||
vaultwarden_master_password: [],
|
||||
@ -912,7 +940,7 @@ async function toggleStep(stepId, event) {
|
||||
await setStepCompletion(stepId, checked);
|
||||
}
|
||||
|
||||
async function setStepCompletion(stepId, completed) {
|
||||
async function setStepCompletion(stepId, completed, extra = {}) {
|
||||
if (!requestCode.value.trim()) {
|
||||
error.value = "Request code is missing.";
|
||||
return;
|
||||
@ -927,13 +955,13 @@ async function setStepCompletion(stepId, completed) {
|
||||
let resp = await requester("/api/access/request/onboarding/attest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed, ...extra }),
|
||||
});
|
||||
if ([401, 403].includes(resp.status) && requester === authFetch) {
|
||||
resp = await fetch("/api/access/request/onboarding/attest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed, ...extra }),
|
||||
});
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
@ -987,6 +1015,18 @@ async function confirmStep(step) {
|
||||
}
|
||||
}
|
||||
|
||||
async function claimVaultwarden() {
|
||||
if (isStepDone("vaultwarden_master_password") || isStepBlocked("vaultwarden_master_password")) return;
|
||||
vaultwardenClaiming.value = true;
|
||||
try {
|
||||
await setStepCompletion("vaultwarden_master_password", true, { vaultwarden_claim: true });
|
||||
} catch (err) {
|
||||
error.value = err?.message || "Failed to claim Vaultwarden account";
|
||||
} finally {
|
||||
vaultwardenClaiming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runRotationCheck(service) {
|
||||
if (!auth.authenticated) {
|
||||
throw new Error("Log in to update onboarding steps.");
|
||||
@ -1484,6 +1524,16 @@ button.copy:disabled {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.claim-box {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user