diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/rules.rs | 31 | ||||
| -rw-r--r-- | src/api/auth.rs | 7 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 225 | ||||
| -rw-r--r-- | src/api/mod.rs | 7 | ||||
| -rw-r--r-- | src/api/users.rs | 26 |
5 files changed, 211 insertions, 85 deletions
diff --git a/src/access/rules.rs b/src/access/rules.rs index ac7377f..d8162a6 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -279,13 +279,14 @@ pub(crate) fn judge_reading( { true } - // A user's own keys (`/auth/keys`), whatever else it may do, one left at - // least. + // A user's own keys (`/auth/keys`), whatever else it may do; its last too + // (the node it asks keeps that from one without a password there, which no + // other node can tell). (Some(meta::USERS), Some(owner)) if path.get(3).map(String::as_str) == Some("keys") && change.user == Some(owner) => { - keys_left(&at, owner, change.ops) + true } // A node forgotten while alive cancels its own record (`swarm::peer::forget`). (Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path), @@ -531,30 +532,6 @@ fn made_the_epoch(at: &At<'_>, epoch: Epoch, id: &str, node: &str) -> bool { == Some(node) } -/// Whether `owner` has a key left after `ops`, its keys at `at` as they change them. -fn keys_left(at: &At<'_>, owner: &str, ops: &[Op]) -> bool { - let keys = meta::path(&[meta::USERS, owner, "keys"]); - let mut left: BTreeSet<String> = store::keys_at(at, &keys) - .ok() - .flatten() - .unwrap_or_default() - .into_iter() - .collect(); - for op in ops.iter().filter(|op| op.path.starts_with(&keys)) { - match (op.path.get(keys.len()), &op.action) { - (None, _) => left.clear(), - (Some(id), Action::Delete) => { - left.remove(id); - } - (Some(id), _) if op.path.len() == keys.len() + 1 => { - left.insert(id.clone()); - } - _ => {} - } - } - !left.is_empty() -} - /// The coordinator remake `id`'s proposal names, at `at`. fn coordinator(at: &At<'_>, id: &str) -> Option<String> { let proposal = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"])) diff --git a/src/api/auth.rs b/src/api/auth.rs index a5a987c..ac70b82 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -154,6 +154,13 @@ impl Range { } impl AuthState { + /// Whether `user` has a password on this node (`--htpasswd`): it logs in by it. + pub(crate) fn has_password(&self, user: &str) -> bool { + self.users + .as_ref() + .is_some_and(|users| users.contains_key(user)) + } + /// From the options; None if there is neither an htpasswd file nor a user header /// (no users: no HTTP API, for a relay). pub(crate) fn from_settings( diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index c4e6276..0be3526 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -258,6 +258,22 @@ #dialog table { border-collapse: collapse; width: 100%; } #dialog td { padding: 2px 6px 2px 0; vertical-align: top; } #dialog td.key { font-family: var(--mono, monospace); word-break: break-all; color: var(--muted); } + /* The keys as wide as the dialog lets them, never wider: the name and the actions + their widths, the key the rest, cut in the middle (`keyLine`) as much as it must. */ + #dialog table.keys { table-layout: fixed; } + #dialog table.keys col.name { width: 7em; } + #dialog table.keys col.actions { width: 4em; } + #dialog table.keys td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #dialog table.keys td.actions { text-align: right; padding-right: 0; } + /* Being edited: its fields and buttons across the row. */ + #dialog .key-edit { display: flex; gap: 6px; align-items: baseline; margin: 2px 0; } + #dialog .key-edit .key-name { width: 7em; flex: none; } + #dialog .key-edit .key-value { flex: 1; min-width: 0; } + #dialog table.keys td.key { word-break: normal; overflow: hidden; } + .key-line { display: flex; min-width: 0; } + .key-line .start { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; } + .key-line .end { flex: none; white-space: pre; } #dialog form { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } #dialog input { font: inherit; } #dialog input[name="line"] { flex: 1; min-width: 16em; } @@ -281,8 +297,9 @@ .editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; } .editor button { font: inherit; } - .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad); - border-radius: 3px; } + .editor button.delete, #dialog button.delete { color: #fff; background: var(--bad); + border: 1px solid var(--bad); border-radius: 3px; } + #dialog button.delete:disabled { opacity: .5; } .tree details.editing > :not(summary):not(.editor) { display: none; } .tree .leaf[title] { cursor: text; } table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } @@ -454,13 +471,19 @@ const handOver = (() => { // The nodes whose own links this page reads from them (`follow`), kept in the browser, // and how each goes: declared before anything draws. const SUBSCRIPTIONS = "continuum-subscriptions"; +// Opened from another node's page (`handOver`), that node is subscribed to too: its +// links and messages were that page's own, and are another's here. const subscriptions = (() => { + let kept = new Set(); try { - const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]"); - return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []); - } catch (_) { - return new Set(); + const listed = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]"); + kept = new Set(Array.isArray(listed) ? listed.filter(id => typeof id === "string") : []); + } catch (_) { /* none kept */ } + if (handOver && typeof handOver.from === "string" && !kept.has(handOver.from)) { + kept.add(handOver.from); + try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...kept])); } catch (_) {} } + return kept; })(); // Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why` // not, its latest `report` and when it came (`at`), and how to stop it (`stop`). @@ -1635,6 +1658,31 @@ async function readStore(path, shallow) { const EMPTY = { map: "{}", list: "[]", text: "\"\"" }; const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" }; +// A red `Delete` that acts on a second click, from half a second to 3 s after the first +// (`Delete: sure?`): a double-click doesn't delete. `act()`: whether it did; if not, the +// button is `Delete` again. +function deleteButton(act) { + const button = el("button", { type: "button", class: "delete" }, "Delete"); + let sure = null, askedAt = 0; + button.addEventListener("click", async () => { + if (sure === null) { + button.textContent = "Delete: sure?"; + askedAt = performance.now(); + sure = setTimeout(() => { sure = null; button.textContent = "Delete"; }, 3000); + return; + } + if (performance.now() - askedAt < 500) return; + clearTimeout(sure); + button.disabled = true; + if (!(await act())) { + sure = null; + button.textContent = "Delete"; + button.disabled = false; + } + }); + return button; +} + // --- Editing: `Edit value` in an entry's menu turns it into a field holding its contents; // saved with PUT /store/<path> (the whole value, objects too) --- // A string is edited as text (sent as text/plain), anything else as JSON; "JSON" @@ -1701,21 +1749,9 @@ function editor(path, value, done, options = {}) { }; save.addEventListener("click", submit); cancel.addEventListener("click", () => done(false)); - // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second - // click to be sure, not too soon after the first: a double-click doesn't delete. - const remove = options.extra ? null : el("button", { type: "button", class: "delete" }, - "Delete"); - let sure = null, askedAt = 0; - remove?.addEventListener("click", async () => { - if (sure === null) { - remove.textContent = "Delete: sure?"; - askedAt = performance.now(); - sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000); - return; - } - if (performance.now() - askedAt < 500) return; - clearTimeout(sure); - remove.disabled = save.disabled = true; + // Editing what's there: it can be deleted too (DELETE /store/<path>, `deleteButton`). + const remove = options.extra ? null : deleteButton(async () => { + save.disabled = true; message.className = "note"; message.textContent = "Deleting…"; try { @@ -1726,11 +1762,11 @@ function editor(path, value, done, options = {}) { throw new Error("HTTP " + response.status + (why ? ": " + why : "")); } done("deleted", response); + return true; } catch (error) { - sure = null; - remove.textContent = "Delete"; - remove.disabled = save.disabled = false; + save.disabled = false; fail("Not deleted: " + error.message); + return false; } }); // Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does. @@ -2291,6 +2327,8 @@ function handOff(id, handed) { pinned: [...pinned], view: { ...view }, focused: focused === id ? null : focused, + // The node this page is of: the next page subscribes to it. + from: state ? state.node.id : undefined, settings, legend: !document.getElementById("legends").hidden, figures: [...open], @@ -2592,8 +2630,10 @@ async function logOut() { location.assign("../login"); } } -// The user's credentials: its keys, added and removed (`/auth/keys`), but the last; -// under a strict policy, how to sign them anew; where its password is. +// The user's credentials: its keys (`/auth/keys`): each edited (its name, its line), +// removed (its last only where it has a password on this node, which it logs in by +// then), or one added; under a strict policy, how to sign them anew; where its password +// is. What fails says so where it was asked, which stays as it was, to be set right. async function credentials() { const password = el("p", {}, "Your password is in each node's password file " + "(--htpasswd): its operator changes it there."); @@ -2607,49 +2647,134 @@ async function credentials() { return; } const keys = Object.entries(answer.keys || {}); - const rows = keys.map(([id, line]) => { - const remove = el("button", { type: "button" }, "remove"); - if (answer.signed || keys.length < 2) { - remove.disabled = true; - remove.title = answer.signed ? "signed: see below" : "your last key: add another first"; - } - remove.addEventListener("click", () => changeKey(id, null)); - return el("tr", {}, el("td", {}, id), el("td", { class: "key" }, line), el("td", {}, remove)); - }); + const signed = !!answer.signed; + // The columns' widths set once, whatever its rows (one edited spans them all). + const table = el("table", { class: "keys" }, el("colgroup", {}, + el("col", { class: "name" }), el("col", {}), el("col", { class: "actions" }))); + const row = (id, line) => { + const tr = el("tr", {}); + // Its name, as much of the key as fits, and `edit`, at the right. + const shown = () => { + const edit = el("button", { type: "button" }, "edit"); + if (signed) { + edit.disabled = true; + edit.title = "signed: see below"; + } + edit.addEventListener("click", editing); + tr.replaceChildren(el("td", { class: "name", title: id }, id), + el("td", { class: "key", title: line }, keyLine(line)), + el("td", { class: "actions" }, edit)); + }; + // Its name and line in fields, across the row; saved under the name (a new name: + // written under it, then the old one removed), or deleted (`deleteButton`; its last + // only where the user has a password on this node). + const editing = () => { + const name = el("input", { size: 12, spellcheck: "false", class: "key-name" }); + name.value = id; + const value = el("input", { spellcheck: "false", class: "key-value" }); + value.value = line; + const save = el("button", { type: "button" }, "save"); + const cancel = el("button", { type: "button" }, "cancel"); + const failed = el("span", { class: "error" }); + const remove = deleteButton(async () => { + save.disabled = true; + const why = await keyRequest(id, null); + if (why === null) { credentials(); return true; } + save.disabled = false; + failed.textContent = "Not deleted: " + why; + return false; + }); + if (keys.length < 2 && !answer.password) { + remove.disabled = true; + remove.title = "your last key, and you have no password on this node: add another first"; + } + const submit = async () => { + const to = name.value.trim(), text = value.value.trim(); + if (to === "" || text === "") { failed.textContent = "A name and a key, please."; return; } + if (to !== id && keys.some(([other]) => other === to)) { + failed.textContent = "\"" + to + "\" is a key of yours already."; return; + } + save.disabled = true; + failed.textContent = ""; + let why = await keyRequest(to, text); + if (why === null && to !== id) { + const old = await keyRequest(id, null); + if (old !== null) why = "saved as \"" + to + "\", but \"" + id + "\" not removed: " + old; + } + if (why === null) credentials(); + else { save.disabled = false; failed.textContent = "Not saved: " + why; } + }; + save.addEventListener("click", submit); + cancel.addEventListener("click", shown); + for (const field of [name, value]) { + field.addEventListener("keydown", event => { + if (event.key === "Enter") { event.preventDefault(); submit(); } + else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); shown(); } + }); + } + tr.replaceChildren(el("td", { colspan: "3" }, + el("div", { class: "key-edit" }, name, value), + el("div", { class: "key-edit" }, save, cancel, remove, failed))); + value.focus(); + }; + shown(); + return tr; + }; + table.append(...keys.map(([id, line]) => row(id, line))); const parts = [password, el("h3", {}, "Your keys"), - keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")]; - if (answer.signed) { + keys.length ? table : el("p", { class: "note" }, "none")]; + if (signed) { parts.push(el("p", {}, "This swarm has its users sign their entries: change your " + "keys by signing yours anew, with a key you hold: continuum-signed key-add NAME " + "FILE.pub, continuum-signed key-remove NAME.")); } else { + const failed = el("p", { class: "error" }); const form = el("form", {}, el("input", { name: "id", placeholder: "name (laptop)", required: "", size: 12 }), el("input", { name: "line", placeholder: "ssh-ed25519 AAAA… (its .pub)", required: "", spellcheck: "false" }), el("button", {}, "add")); - form.addEventListener("submit", event => { + form.addEventListener("submit", async event => { event.preventDefault(); - changeKey(form.elements.id.value.trim(), form.elements.line.value.trim()); + const id = form.elements.id.value.trim(), line = form.elements.line.value.trim(); + if (keys.some(([other]) => other === id)) { + failed.textContent = "\"" + id + "\" is a key of yours already: edit it instead."; + return; + } + failed.textContent = ""; + const why = await keyRequest(id, line); + if (why === null) credentials(); + else failed.textContent = "Not added: " + why; }); - parts.push(form); + parts.push(form, failed); } say("Credentials", ...parts); } -// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew. -async function changeKey(id, line) { +// A key as much of it as fits: its type and what tells it apart (an Ed25519 key's +// base64 after the part every one of them shares), cut in the middle where it doesn't +// fit, its last eight characters and its comment always shown. +const ED25519_COMMON = "AAAAC3NzaC1lZDI1NTE5AAAAI"; +function keyLine(line) { + const [type = "", data = "", ...comment] = line.trim().split(/\s+/); + const own = data.startsWith(ED25519_COMMON) ? data.slice(ED25519_COMMON.length) : data; + const cut = Math.max(0, own.length - 8); + return el("span", { class: "key-line" }, + el("span", { class: "start" }, type + " " + own.slice(0, cut)), + el("span", { class: "end" }, own.slice(cut) + (comment.length ? " " + comment.join(" ") : ""))); +} +// Key `id` of the user's set to `line`, or removed (`line` null): null if done, else why +// not. +async function keyRequest(id, line) { const init = line === null ? { method: "DELETE" } : { method: "PUT", headers: { "Content-Type": "text/plain" }, body: line }; - const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init)) - .catch(error => ({ ok: false, statusText: error.message, json: async () => ({}) })); - if (!response.ok) { + try { + const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init)); + if (response.ok) return null; const answer = await response.json().catch(() => ({})); - say("Credentials", el("p", { class: "error" }, - (line === null ? "Couldn't remove " : "Couldn't add ") + id + ": " + - (answer.error || response.status + " " + response.statusText))); - return; + return answer.error || response.status + " " + response.statusText; + } catch (error) { + return error.message; } - credentials(); } async function obtainTokens() { clearTimeout(tokenTimer); diff --git a/src/api/mod.rs b/src/api/mod.rs index 0dde808..5c7c8e7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -114,7 +114,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> state.clone(), access::authorize, )) - .layer(middleware::from_fn_with_state(auth_state, basic_auth)) + .layer(middleware::from_fn_with_state( + auth_state.clone(), + basic_auth, + )) + // Who has a password here (`/auth/keys`: a user's last key goes only then). + .layer(axum::Extension(auth_state)) // Outside authentication: a token, if the request brings one, names the user. .layer(middleware::from_fn_with_state(state.clone(), login::bearer)) // Outermost: pages of other origins, and their preflights, which bring no diff --git a/src/api/users.rs b/src/api/users.rs index 1dda33b..d6950af 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -24,8 +24,10 @@ //! * `GET /auth/keys`: the caller's keys, its entry, and the envelope holding it under a //! strict policy (`signed`), to sign the next one against. //! * `PUT /auth/keys/{id}`: one added or replaced (the line as the body); -//! `DELETE /auth/keys/{id}` removes one. Under a strict policy `409`: the user signs -//! its entry with the keys changed, nothing else (`continuum-signed key-add`). +//! `DELETE /auth/keys/{id}` removes one, the last only of a user with a password on +//! this node (`--htpasswd`, `password` in `GET /auth/keys`), which logs in by it. Under +//! a strict policy `409`: the user signs its entry with the keys changed, nothing else +//! (`continuum-signed key-add`). //! //! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm //! without a registry, being in `--protocol-writers`). Every change is one change to @@ -142,6 +144,7 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> { pub(crate) async fn own_keys( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, + Extension(auth): Extension<super::auth::AuthState>, ) -> Result<Json<JsonValue>, ApiError> { let replica = state.doc.read().await; let path = registry::user_path(&user.0); @@ -157,6 +160,7 @@ pub(crate) async fn own_keys( "keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})), "entry": entry, "signed": signed, + "password": auth.has_password(&user.0), }))) } @@ -168,24 +172,28 @@ pub(crate) async fn put_own_key( body: String, ) -> Result<StatusCode, ApiError> { let key = crate::access::sshsig::PublicKey::parse(body.trim()).map_err(|why| bad(&why))?; - own_key(&state, &user.0, id, Some(key.line())).await + own_key(&state, &user.0, id, Some(key.line()), false).await } /// `DELETE /auth/keys/{id}`. pub(crate) async fn delete_own_key( State(state): State<AppState>, Extension(user): Extension<AuthenticatedUser>, + Extension(auth): Extension<super::auth::AuthState>, Path(id): Path<String>, ) -> Result<StatusCode, ApiError> { - own_key(&state, &user.0, id, None).await + let password = auth.has_password(&user.0); + own_key(&state, &user.0, id, None, password).await } -/// `user`'s key `id` set to `line`, or removed: one change, as `user`. +/// `user`'s key `id` set to `line`, or removed: one change, as `user`. Its last key only +/// with `password` (it has one here, and logs in by it). async fn own_key( state: &AppState, user: &str, id: String, line: Option<String>, + password: bool, ) -> Result<StatusCode, ApiError> { if !registry::valid_name(&id) { return Err(bad("not a key's name: 1 to 64 of A-Z a-z 0-9 . _ @ -")); @@ -216,7 +224,7 @@ async fn own_key( Some("no entry of yours".to_owned()), )); } - if line.is_none() { + if line.is_none() && !password { let mut keys = entry.clone(); keys.push("keys".to_owned()); let others = store::keys_at(&*doc, &keys)? @@ -227,7 +235,11 @@ async fn own_key( if others == 0 { return Err(ApiError( StatusCode::CONFLICT, - Some("your last key: add another first".to_owned()), + Some( + "your last key, and you have no password on this node: add \ + another first" + .to_owned(), + ), )); } } |
