aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 11:50:37 +0000
committertv <tv@krebsco.de>2026-11-09 12:51:20 +0100
commit00b80d182363e03d447be0a6c1812ec46f8faee1 (patch)
tree41f43ca17242afbf763e0f87ee5951e0c3f680d0
parent8a7df0caa51fc1b01e2a744c9e48dd3a1af39296 (diff)
dashboard: tokens renewed after sleep too, never basic authentication
The page renewed its token half way through its life by a timer. A timer fires late where the computer slept or the tab was in the background, by when the token (and the session cookie, which lives as long) may have ended: the page then fell back to `POST /auth/login`, which takes a password the browser keeps (basic authentication), and without one retried every 30 s, logged out without saying so. Now whether the token is half way through its life is looked at again when the page shows and every 30 s (`refreshIfDue`, one renewal at a time), so it is renewed while it still can be, until the login ends (`--session-lifetime`). Where the login ended, or there is none (400, 401, 403), the page goes to the login page; a node away is tried again in 30 s. And the page no longer gets in by a password the browser keeps: only by its session or through a proxy naming the user (`throughProxy`, `login: "header"`). The node never challenges a browser for a password either (`WWW-Authenticate`, the browser's prompt): not only on the dashboard's paths and for a page's own requests, but for any request with `Sec-Fetch-*` or a `Mozilla/…` user agent. curl sends the password unasked with `-u`, as before.
-rw-r--r--docs/dashboard.md12
-rw-r--r--src/api/auth.rs18
-rw-r--r--src/api/dashboard/dashboard.html55
3 files changed, 62 insertions, 23 deletions
diff --git a/docs/dashboard.md b/docs/dashboard.md
index da69a16..4973227 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -18,14 +18,16 @@ It is at `/admin/dashboard` on the node's HTTP API, by default
(`--user-header`) or one logged in on the page `/login`, which a browser opening the
dashboard without one is sent to. There, a user logs in with its password
(`--htpasswd`, the same file the API's basic authentication takes, which the dashboard
-never asks for itself: no browser password prompt), or with a token: from `continuum-signed login` (by key,
+never asks for itself, nor takes from the browser: no browser password prompt, the
+node never challenges a browser), or with a token: from `continuum-signed login` (by key,
[`access.md`](access.md), Tokens), or any other. The page keeps a session for the
browser, a cookie no script reads and no other site's page sends, renewed while the
-dashboard is open; it ends with the token's lifetime, or with "Log out of this
-browser" on the login page, or "log out" in the user's menu on the dashboard (its
+dashboard is open (half way through the token's life, looked at again when the page
+shows, so after the computer slept too) until the login ends (`--session-lifetime`),
+then back to the login page; it ends with the token's lifetime once not renewed, or
+with "Log out of this browser" on the login page, or "log out" in the user's menu on the dashboard (its
name, top right), which ends this browser's session only. Where the page got in by
-what it can't end, the browser's password or the proxy's, "log out" is greyed, and
-says how instead. The menu's "credentials…" shows the user's keys, each by its name
+what it can't end, the proxy's, "log out" is greyed, and says how instead. The menu's "credentials…" shows the user's keys, each by its name
and as much of the key as fits the dialog, cut in the middle where it doesn't (its end
and comment always shown, the whole in its tooltip), and `edit` at the right: its name
and `.pub` line in fields, to save, or to delete with the red `Delete` (on a second
diff --git a/src/api/auth.rs b/src/api/auth.rs
index f4806ca..abb8c1a 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -324,15 +324,19 @@ pub(crate) async fn basic_auth(
)
.into_response();
}
- // A challenge only where basic authentication can answer it, for the API (curl),
- // never for what is meant for browsers: the dashboard, nor a page's own requests
- // (`Sec-Fetch-Dest: empty`: fetch, EventSource). The browser would ask for a
- // password over the page, which has its own login (`/login`), taking the
- // passwords of the same file.
+ // A challenge only where basic authentication can answer it, for the API (curl,
+ // which sends the password unasked with `-u` anyway), never to a browser: it would
+ // ask for a password over the page, which has its own login (`/login`), taking
+ // the passwords of the same file. A browser: the dashboard's paths, any request
+ // with `Sec-Fetch-*` (every browser for years: pages, downloads, fetch,
+ // EventSource), or one calling itself `Mozilla/…`, as every browser does.
let for_browsers = request.uri().path().starts_with("/admin/dashboard")
+ || headers.contains_key("sec-fetch-dest")
+ || headers.contains_key("sec-fetch-mode")
|| headers
- .get("sec-fetch-dest")
- .is_some_and(|dest| dest == "empty");
+ .get(axum::http::header::USER_AGENT)
+ .and_then(|agent| agent.to_str().ok())
+ .is_some_and(|agent| agent.starts_with("Mozilla/"));
return match state.users {
Some(_) if !for_browsers => (
StatusCode::UNAUTHORIZED,
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index d6f034b..d32fa7a 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -2625,8 +2625,9 @@ function unapplySubscriptions() {
// Tokens: this page's, from logging in on this node (the browser's password for it, the
// proxy's, or the session the login page began), and a narrower one, reading the member
// list only, to send to others.
-// Each refreshed half way through its life.
+// Each refreshed half way through its life (`tokenAt`: when the page got it).
let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null;
+let tokenAt = 0, obtaining = false;
const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }];
// A request that changes something, with this page's token: the session's cookie lets
// in only those that read (`login::bearer`).
@@ -2640,19 +2641,31 @@ async function tokenCall(url, bearer, body) {
const response = await fetch(url, { method: "POST", headers,
body: JSON.stringify(body || {}) });
const answer = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText));
+ if (!response.ok) throw refused(response.status, answer.error || response.statusText);
return answer;
}
-// How the page got in: by its session ("session"), which logging out ends, or by what
-// it can't end (`login` of `POST /auth/login`): the browser's password ("htpasswd"), the
-// proxy's ("header").
+// An error with the status it came with (`status`): 400, 401 and 403 are the login's
+// end, others pass.
+function refused(status, why) {
+ const error = new Error(status + " " + why);
+ error.status = status;
+ return error;
+}
+// How the page got in: by its session ("session"), which logging out ends, or through
+// the proxy, which names the user ("header"), which it can't end. Never by a password
+// the browser keeps (basic authentication): the login page takes passwords.
let loggedIn = null;
const CANT_LOG_OUT = {
- htpasswd: "This page got in by the password the browser keeps for this site, which a " +
- "page can't make it forget: close the browser (all its windows) to log out.",
header: "This page got in by your proxy, which names you to the node: log out of the " +
"proxy (its own logout, or closing the browser).",
};
+// Through the proxy, if it names the user (`POST /auth/login`); refused otherwise, a
+// password the browser keeps included.
+async function throughProxy() {
+ const answer = await tokenCall("../auth/login");
+ if (answer.login !== "header") throw refused(401, "log in on the login page");
+ return answer;
+}
function showWho() {
const who = document.getElementById("who");
who.hidden = !token;
@@ -2698,7 +2711,8 @@ async function logOut() {
token = narrowToken = null;
// In still, by what the page can't end: said so; else to the login page.
try {
- token = await tokenCall("../auth/login");
+ token = await throughProxy();
+ tokenAt = Date.now();
loggedIn = token.login;
showWho();
say("Logged out of this session", el("p", {}, CANT_LOG_OUT[loggedIn] ||
@@ -2855,21 +2869,25 @@ async function keyRequest(id, line) {
}
}
async function obtainTokens() {
+ if (obtaining) return;
+ obtaining = true;
clearTimeout(tokenTimer);
try {
// Refreshed as the session (`POST /auth/session`), whose cookie lets the page in;
- // the first: by that cookie, else by the browser's password or the proxy's.
+ // the first: by that cookie, else through the proxy.
if (token && token.expiresAt > Date.now() + 60000) {
token = await tokenCall("../auth/session", token.token);
} else {
try {
token = await tokenCall("../auth/session");
loggedIn = "session";
- } catch (_) {
- token = await tokenCall("../auth/login");
+ } catch (error) {
+ if (!(error.status >= 400 && error.status < 500)) throw error;
+ token = await throughProxy();
loggedIn = token.login;
}
}
+ tokenAt = Date.now();
showWho();
narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS });
tokenWhy = null;
@@ -2884,9 +2902,24 @@ async function obtainTokens() {
token = narrowToken = null;
tokenWhy = "no token: " + error.message;
showWho();
+ // The login ended (or none): to the login page; else (the node away) again soon.
+ if ([400, 401, 403].includes(error.status)) {
+ location.assign("../login");
+ return;
+ }
tokenTimer = setTimeout(obtainTokens, 30000);
+ } finally {
+ obtaining = false;
}
}
+// A timer fires late where the computer slept or the tab was in the background: whether
+// the token is half way through its life is looked at again when the page shows, and
+// every 30 s, refreshed while it still can be.
+function refreshIfDue() {
+ if (token && Date.now() - tokenAt >= (token.expiresAt - tokenAt) / 2) obtainTokens();
+}
+document.addEventListener("visibilitychange", () => { if (!document.hidden) refreshIfDue(); });
+setInterval(refreshIfDue, 30000);
// Follows `id`'s own links, again after a while if it fails (2 s, doubling to a minute).
// Not this node itself (a subscription handed over from another node's page, where it
// was another): its links and messages are the page's own already, and read twice