portal: add vaultwarden grandfathered claim
This commit is contained in:
parent
498e1f4154
commit
4732334c44
@ -276,6 +276,26 @@ class KeycloakAdminClient:
|
|||||||
walk(items)
|
walk(items)
|
||||||
return sorted(names)
|
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:
|
def add_user_to_group(self, user_id: str, group_id: str) -> None:
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
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"},
|
"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:
|
def _normalize_status(status: str) -> str:
|
||||||
@ -241,6 +242,66 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
|||||||
return completed
|
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:
|
def _password_rotation_requested(conn, request_code: str) -> bool:
|
||||||
row = conn.execute(
|
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]:
|
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)
|
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 {
|
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),
|
||||||
@ -445,6 +508,10 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
|
|||||||
"keycloak": {
|
"keycloak": {
|
||||||
"password_rotation_requested": password_rotation_requested,
|
"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()
|
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
||||||
step = (payload.get("step") or "").strip()
|
step = (payload.get("step") or "").strip()
|
||||||
completed = payload.get("completed")
|
completed = payload.get("completed")
|
||||||
|
vaultwarden_claim = bool(payload.get("vaultwarden_claim"))
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return jsonify({"error": "request_code is required"}), 400
|
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
|
return jsonify({"error": "step is managed by keycloak"}), 400
|
||||||
|
|
||||||
username = ""
|
username = ""
|
||||||
|
token_groups: set[str] = set()
|
||||||
bearer = request.headers.get("Authorization", "")
|
bearer = request.headers.get("Authorization", "")
|
||||||
if bearer:
|
if bearer:
|
||||||
parts = bearer.split(None, 1)
|
parts = bearer.split(None, 1)
|
||||||
@ -935,11 +1004,14 @@ def register(app) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "invalid token"}), 401
|
return jsonify({"error": "invalid token"}), 401
|
||||||
username = claims.get("preferred_username") or ""
|
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:
|
try:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
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,),
|
(code,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@ -956,6 +1028,8 @@ def register(app) -> None:
|
|||||||
mark_done = completed
|
mark_done = completed
|
||||||
|
|
||||||
request_username = row.get("username") or ""
|
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:
|
if mark_done:
|
||||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
|
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
|
||||||
@ -965,6 +1039,17 @@ def register(app) -> None:
|
|||||||
if missing:
|
if missing:
|
||||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||||
if step == "vaultwarden_master_password":
|
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:
|
try:
|
||||||
_request_keycloak_password_rotation(conn, code, request_username)
|
_request_keycloak_password_rotation(conn, code, request_username)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -972,16 +1057,36 @@ def register(app) -> None:
|
|||||||
if request_username and admin_client().ready():
|
if request_username and admin_client().ready():
|
||||||
try:
|
try:
|
||||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
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(
|
admin_client().set_user_attribute(
|
||||||
request_username,
|
request_username,
|
||||||
"vaultwarden_master_password_set_at",
|
"vaultwarden_master_password_set_at",
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
admin_client().set_user_attribute(
|
|
||||||
request_username,
|
|
||||||
"vaultwarden_status",
|
|
||||||
"already_present",
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to update vaultwarden status"}), 502
|
return jsonify({"error": "failed to update vaultwarden status"}), 502
|
||||||
|
|
||||||
|
|||||||
@ -272,3 +272,20 @@ class AccessRequestTests(TestCase):
|
|||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertEqual(data.get("initial_password"), "temp-pass")
|
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: {},
|
selectedFlags: {},
|
||||||
});
|
});
|
||||||
const onboardingUrl = ref("/onboarding");
|
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 vaultwardenDisplayStatus = computed(() => (vaultwardenReady.value ? "ready" : vaultwarden.status));
|
||||||
const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
|
const vaultwardenOrder = computed(() => (vaultwardenReady.value ? 3 : 0));
|
||||||
|
|
||||||
|
|||||||
@ -177,6 +177,31 @@
|
|||||||
|
|
||||||
<p class="muted" v-if="step.description">{{ step.description }}</p>
|
<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">
|
<ul v-if="step.bullets && step.bullets.length" class="step-bullets">
|
||||||
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
|
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -318,6 +343,7 @@ const guideShots = ref({});
|
|||||||
const guidePage = ref({});
|
const guidePage = ref({});
|
||||||
const lightboxShot = ref(null);
|
const lightboxShot = ref(null);
|
||||||
const confirmingStepId = ref("");
|
const confirmingStepId = ref("");
|
||||||
|
const vaultwardenClaiming = ref(false);
|
||||||
|
|
||||||
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
||||||
const passwordRevealLocked = 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."
|
? "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 = {
|
const STEP_PREREQS = {
|
||||||
vaultwarden_master_password: [],
|
vaultwarden_master_password: [],
|
||||||
@ -912,7 +940,7 @@ async function toggleStep(stepId, event) {
|
|||||||
await setStepCompletion(stepId, checked);
|
await setStepCompletion(stepId, checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStepCompletion(stepId, completed) {
|
async function setStepCompletion(stepId, completed, extra = {}) {
|
||||||
if (!requestCode.value.trim()) {
|
if (!requestCode.value.trim()) {
|
||||||
error.value = "Request code is missing.";
|
error.value = "Request code is missing.";
|
||||||
return;
|
return;
|
||||||
@ -927,13 +955,13 @@ async function setStepCompletion(stepId, completed) {
|
|||||||
let resp = await requester("/api/access/request/onboarding/attest", {
|
let resp = await requester("/api/access/request/onboarding/attest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
if ([401, 403].includes(resp.status) && requester === authFetch) {
|
||||||
resp = await fetch("/api/access/request/onboarding/attest", {
|
resp = await fetch("/api/access/request/onboarding/attest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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(() => ({}));
|
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) {
|
async function runRotationCheck(service) {
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
throw new Error("Log in to update onboarding steps.");
|
throw new Error("Log in to update onboarding steps.");
|
||||||
@ -1484,6 +1524,16 @@ button.copy:disabled {
|
|||||||
text-decoration: underline;
|
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 {
|
.step-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user