diff options
| author | tv <tv@krebsco.de> | 2026-11-09 11:50:37 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-09 12:51:20 +0100 |
| commit | 00b80d182363e03d447be0a6c1812ec46f8faee1 (patch) | |
| tree | 41f43ca17242afbf763e0f87ee5951e0c3f680d0 /src/api/dashboard | |
| parent | 8a7df0caa51fc1b01e2a744c9e48dd3a1af39296 (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/api/dashboard')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 55 |
1 files changed, 44 insertions, 11 deletions
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 |
