aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:22 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:22 +0100
commitb99e7ddf38233120ea19485968c12c5548e7fa61 (patch)
tree0dbd1fca28e979d76b54d38c446a70e082da9595 /src/api/dashboard
parentc296fe8b2532f40c9a1f839ebdac2449e38342d5 (diff)
auth: a login page and browser sessions
GET /login without authentication: log in by password or with a token (from a login by key). POST/DELETE /auth/session keep the token as an HttpOnly, SameSite=Strict cookie; the dashboard sends a browser without a user there and renews the session while open. Auth plan M2/M3 (browsers).
Diffstat (limited to 'src/api/dashboard')
-rw-r--r--src/api/dashboard/dashboard.html11
-rw-r--r--src/api/dashboard/login.html82
-rw-r--r--src/api/dashboard/mod.rs7
3 files changed, 96 insertions, 4 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 300f4fe..8146f32 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -2276,8 +2276,9 @@ function unapplySubscriptions() {
}
overlaid.clear();
}
-// Tokens: this page's, from logging in on this node (the browser's password for it, or
-// the proxy's), and a narrower one, reading the member list only, to send to others.
+// 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.
let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null;
const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }];
@@ -2293,9 +2294,11 @@ async function tokenCall(url, bearer, body) {
async function obtainTokens() {
clearTimeout(tokenTimer);
try {
+ // Refreshed as the session (`POST /auth/session`), whose cookie lets the page in;
+ // the first: by the browser's password or the proxy's, else by that cookie.
token = token && token.expiresAt > Date.now() + 60000
- ? await tokenCall("../auth/refresh", token.token)
- : await tokenCall("../auth/login");
+ ? await tokenCall("../auth/session", token.token)
+ : await tokenCall("../auth/login").catch(() => tokenCall("../auth/session"));
narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS });
tokenWhy = null;
const life = Math.min(token.expiresAt, narrowToken.expiresAt) - Date.now();
diff --git a/src/api/dashboard/login.html b/src/api/dashboard/login.html
new file mode 100644
index 0000000..77668ee
--- /dev/null
+++ b/src/api/dashboard/login.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>continuum: log in</title>
+<style>
+ :root { color-scheme: light dark; --fg: #1d2330; --bg: #f6f7f9; --line: #c9ced8; --accent: #2a6fdb; --bad: #b3261e; }
+ @media (prefers-color-scheme: dark) { :root { --fg: #e4e7ee; --bg: #14171d; --line: #3a404c; --accent: #6ea2ff; --bad: #ff8a80; } }
+ body { margin: 0; font: 15px/1.45 system-ui, sans-serif; color: var(--fg); background: var(--bg); }
+ main { max-width: 22rem; margin: 12vh auto; padding: 0 16px; }
+ h1 { font-size: 1.3rem; margin: 0 0 1.2rem; }
+ form { display: grid; gap: .6rem; margin-bottom: 1rem; }
+ label { display: grid; gap: .2rem; font-size: .9rem; }
+ input { font: inherit; padding: .45rem .55rem; border: 1px solid var(--line); border-radius: 6px; background: transparent; color: inherit; }
+ button { font: inherit; padding: .45rem .8rem; border: 1px solid var(--accent); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; justify-self: start; }
+ button.quiet { background: transparent; color: var(--accent); }
+ .or { margin: 1.4rem 0 .6rem; font-size: .9rem; opacity: .8; }
+ #why { min-height: 1.4em; color: var(--bad); }
+ code { font-size: .85em; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Log in to continuum</h1>
+ <form id="password">
+ <label>User <input name="user" autocomplete="username" required></label>
+ <label>Password <input name="password" type="password" autocomplete="current-password" required></label>
+ <button>Log in</button>
+ </form>
+ <p class="or">or with a token, from <code>continuum-signed login</code>:</p>
+ <form id="token">
+ <label>Token <input name="token" autocomplete="off" spellcheck="false" required></label>
+ <button>Use it</button>
+ </form>
+ <p id="why" role="alert"></p>
+ <button id="out" class="quiet" type="button">Log out of this browser</button>
+</main>
+<script>
+// Logs in by password (POST /auth/login) or takes a token, then keeps a session for this
+// browser (POST /auth/session, a cookie no script reads) and opens the dashboard.
+const why = document.getElementById("why");
+function basic(user, password) {
+ let text = "";
+ for (const byte of new TextEncoder().encode(user + ":" + password)) text += String.fromCharCode(byte);
+ return "Basic " + btoa(text);
+}
+async function call(url, method, authorization) {
+ const headers = { "Content-Type": "application/json" };
+ if (authorization) headers.Authorization = authorization;
+ const response = await fetch(url, { method, headers, credentials: "same-origin",
+ body: method === "POST" ? "{}" : undefined });
+ const answer = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(answer.error || response.status + " " + response.statusText);
+ return answer;
+}
+async function begin(token) {
+ await call("auth/session", "POST", "Bearer " + token);
+ location.href = "admin/dashboard";
+}
+function failed(error) { why.textContent = "Not logged in: " + error.message; }
+document.getElementById("password").addEventListener("submit", async event => {
+ event.preventDefault();
+ why.textContent = "";
+ const form = event.target;
+ try {
+ const answer = await call("auth/login", "POST", basic(form.user.value, form.password.value));
+ await begin(answer.token);
+ } catch (error) { failed(error); }
+});
+document.getElementById("token").addEventListener("submit", async event => {
+ event.preventDefault();
+ why.textContent = "";
+ try { await begin(event.target.token.value.trim()); } catch (error) { failed(error); }
+});
+document.getElementById("out").addEventListener("click", async () => {
+ await fetch("auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {});
+ why.textContent = "Logged out of this browser.";
+});
+</script>
+</body>
+</html>
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 9cb4ea4..24fdacb 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -602,6 +602,13 @@ fn page() -> &'static (String, String) {
})
}
+/// `GET /login`: a page to log in on, by password or with a token, without
+/// authentication (it holds nothing): it keeps a session for the browser
+/// (`POST /auth/session`) and opens the dashboard.
+pub(crate) async fn login_page() -> Html<&'static str> {
+ Html(include_str!("login.html"))
+}
+
/// `GET /admin/dashboard`: the page.
async fn page_html() -> Html<&'static str> {
Html(page().1.as_str())