Files
brass-and-sigil/server/wwwroot/modules/auth.js
T
Matt Sijbers bf53b65706 feat(auth): first-run password setup via web panel
When `webPassword` is null and the daemon starts headless (systemd, piped
SSH), no longer auto-generate a random password. Instead:
  - Boot normally with the gate denying everything except /api/auth/setup
  - Panel UI eagerly probes new /api/auth/state on load and renders a
    first-run setup overlay (password + confirm) when needsSetup=true
  - POST /api/auth/setup writes the chosen password and issues the auth
    cookie in the same response, so the operator lands logged in

Interactive TTY behaviour (prompt at the console) is unchanged. The gate
middleware is now registered unconditionally so first-run mode is still
locked-down instead of wide-open.
2026-05-18 23:20:27 +01:00

175 lines
7.0 KiB
JavaScript

// Login overlay + Account panel (logout / change password).
//
// Cookie is set server-side as HttpOnly so JS never sees it -- that defeats
// XSS-based exfiltration. We only briefly hold the password during input.
"use strict";
// We deliberately don't import apiJson here -- change-password returns its
// own error messages and we want to surface them verbatim to the user.
let overlayShown = false;
async function showOverlay(stateOverride) {
if (overlayShown) return;
overlayShown = true;
let state = stateOverride;
if (!state) {
try {
const res = await fetch("/api/auth/state");
if (res.ok) state = await res.json();
} catch { /* network blip -- fall through to login */ }
}
state = state || { needsSetup: false };
if (state.needsSetup) {
const overlay = document.getElementById("setupOverlay");
if (overlay) {
overlay.hidden = false;
document.getElementById("setupPassword")?.focus();
}
} else {
const overlay = document.getElementById("loginOverlay");
if (overlay) {
overlay.hidden = false;
document.getElementById("loginPassword")?.focus();
}
}
}
export function setupAuth() {
document.addEventListener("authrequired", () => showOverlay());
setupLoginForm();
setupAccountPanel();
setupSetupForm();
// Eager state probe so the right overlay appears before any API call fails.
fetch("/api/auth/state")
.then(r => r.ok ? r.json() : null)
.then(state => { if (state && !state.authed) showOverlay(state); })
.catch(() => { /* let authrequired handle it */ });
}
function setupLoginForm() {
const overlay = document.getElementById("loginOverlay");
const input = document.getElementById("loginPassword");
const button = document.getElementById("loginSubmit");
const errorEl = document.getElementById("loginError");
if (!overlay || !input || !button || !errorEl) return;
async function tryLogin() {
const pw = input.value;
if (!pw) return;
errorEl.textContent = "";
button.disabled = true;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
});
if (res.status === 401) { errorEl.textContent = "Wrong password."; input.select(); return; }
if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; }
if (!res.ok) { errorEl.textContent = `Error ${res.status}`; return; }
// Server set the cookie. Reload so SSE / pollers pick it up.
location.reload();
} catch (e) {
errorEl.textContent = e.message;
} finally {
button.disabled = false;
input.value = "";
}
}
button.addEventListener("click", tryLogin);
input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); });
}
function setupSetupForm() {
const overlay = document.getElementById("setupOverlay");
const pw1 = document.getElementById("setupPassword");
const pw2 = document.getElementById("setupConfirm");
const button = document.getElementById("setupSubmit");
const errorEl = document.getElementById("setupError");
if (!overlay || !pw1 || !pw2 || !button || !errorEl) return;
async function trySetup() {
errorEl.textContent = "";
if (pw1.value.length < 8) { errorEl.textContent = "Must be at least 8 characters."; pw1.select(); return; }
if (pw1.value !== pw2.value) { errorEl.textContent = "Passwords don't match."; pw2.select(); return; }
button.disabled = true;
try {
const res = await fetch("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw1.value }),
});
if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; }
const body = await res.json().catch(() => ({}));
if (!res.ok) { errorEl.textContent = body.error || `Error ${res.status}`; return; }
// Server has set the cookie and saved the password -- reload to enter
// the panel as a freshly-authed session.
location.reload();
} catch (e) {
errorEl.textContent = e.message;
} finally {
button.disabled = false;
pw1.value = pw2.value = "";
}
}
button.addEventListener("click", trySetup);
pw2.addEventListener("keydown", e => { if (e.key === "Enter") trySetup(); });
}
function setupAccountPanel() {
const logoutBtn = document.getElementById("acctLogout");
const changeBtn = document.getElementById("acctChangePw");
const form = document.getElementById("acctChangeForm");
const cur = document.getElementById("acctCurrent");
const nxt = document.getElementById("acctNew");
const cnf = document.getElementById("acctConfirm");
const submit = document.getElementById("acctSubmit");
const cancel = document.getElementById("acctCancel");
const msg = document.getElementById("acctMsg");
if (!logoutBtn || !changeBtn) return;
logoutBtn.addEventListener("click", async () => {
if (!confirm("Log out of the panel?")) return;
try { await fetch("/api/auth/logout", { method: "POST" }); }
finally { location.reload(); }
});
changeBtn.addEventListener("click", () => {
form.hidden = !form.hidden;
msg.textContent = "";
if (!form.hidden) cur.focus();
});
cancel.addEventListener("click", () => {
form.hidden = true;
cur.value = nxt.value = cnf.value = "";
msg.textContent = "";
});
submit.addEventListener("click", async () => {
msg.className = "acct-msg";
msg.textContent = "";
if (nxt.value.length < 8) { msg.textContent = "New password must be at least 8 characters."; return; }
if (nxt.value !== cnf.value) { msg.textContent = "New password and confirmation don't match."; return; }
submit.disabled = true;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current: cur.value, next: nxt.value }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { msg.textContent = body.error || `Error ${res.status}`; return; }
msg.className = "acct-msg ok";
msg.textContent = "Password changed.";
cur.value = nxt.value = cnf.value = "";
setTimeout(() => { form.hidden = true; msg.textContent = ""; }, 1500);
} catch (e) {
msg.textContent = e.message;
} finally {
submit.disabled = false;
}
});
}