diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/auth.rs | 18 | ||||
| -rw-r--r-- | src/api/dashboard/dashboard.html | 55 |
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 |
