aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-rw-r--r--src/api/auth.rs18
-rw-r--r--src/api/dashboard/dashboard.html55
2 files changed, 55 insertions, 18 deletions
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