aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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