feat(auth): add optional admin PIN for all API writes

Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/*
request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also
closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog
and the pending save is retried after login. Reads stay open because the public overlay
hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same
routes by client address.

The PIN hash and signing secret live in data/, outside the app_state blob, so they
never leak through GET /api/state, exports or backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-24 22:14:58 +02:00
parent bcd6e12a6c
commit 9016ce02e4
20 changed files with 627 additions and 8 deletions

109
src/auth_view.js Normal file
View File

@@ -0,0 +1,109 @@
// Admin PIN UI: a login dialog shown whenever the backend answers 401 auth_required,
// and the PIN panel under Settings -> System & backup.
let dialogOpen = false;
export function installAuthFetchGuard({ onAuthRequired }) {
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const response = await originalFetch(input, init);
if (response.status === 401) {
const path = new URL(typeof input === "string" ? input : input.url, window.location.href).pathname;
if (path.startsWith("/api/") && !path.startsWith("/api/auth/")) {
const body = await response.clone().json().catch(() => ({}));
if (body.error === "auth_required") onAuthRequired();
}
}
return response;
};
}
async function postJson(path, body) {
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
return { status: response.status, body: await response.json().catch(() => ({})) };
}
export function showPinDialog({ t, escapeHtml, onSuccess }) {
if (dialogOpen) return;
dialogOpen = true;
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<form class="modal-card auth-dialog" id="authLoginForm">
<h3>${escapeHtml(t("auth.login_title"))}</h3>
<p class="hint">${escapeHtml(t("auth.login_hint"))}</p>
<input type="password" name="pin" inputmode="numeric" autocomplete="current-password" required />
<p class="error" id="authLoginError" hidden></p>
<div class="actions">
<button class="btn btn-primary" type="submit">${escapeHtml(t("auth.login"))}</button>
<button class="btn" type="button" id="authLoginCancel">${escapeHtml(t("auth.cancel"))}</button>
</div>
</form>`;
document.body.appendChild(overlay);
const close = () => { overlay.remove(); dialogOpen = false; };
const error = overlay.querySelector("#authLoginError");
overlay.querySelector("input").focus();
overlay.querySelector("#authLoginCancel").addEventListener("click", close);
overlay.querySelector("form").addEventListener("submit", async (event) => {
event.preventDefault();
const { status } = await postJson("/api/auth/login", { pin: new FormData(event.currentTarget).get("pin") });
if (status === 200) {
close();
onSuccess?.();
return;
}
error.textContent = t(status === 429 ? "auth.too_many" : "auth.wrong_pin");
error.hidden = false;
});
}
export async function mountAuthSettings(container, { t, escapeHtml }) {
if (!container) return;
const status = await fetch("/api/auth/status").then((res) => res.json()).catch(() => null);
if (!status) {
container.innerHTML = `<p class="error">${escapeHtml(t("auth.status_error"))}</p>`;
return;
}
const remount = () => mountAuthSettings(container, { t, escapeHtml });
const stateText = !status.enabled ? t("auth.state_off") : status.authenticated ? t("auth.state_on_logged_in") : t("auth.state_on_logged_out");
container.innerHTML = `
<p><strong>${escapeHtml(stateText)}</strong></p>
<p class="hint">${escapeHtml(t("auth.hint"))}</p>
<form id="authPinForm" class="form-grid cols-2">
<label>${escapeHtml(t(status.enabled ? "auth.new_pin_change" : "auth.new_pin"))}<input type="password" name="newPin" minlength="4" autocomplete="new-password" required /></label>
<label>${escapeHtml(t("auth.confirm_pin"))}<input type="password" name="confirmPin" minlength="4" autocomplete="new-password" required /></label>
<div class="actions">
<button class="btn btn-primary" type="submit">${escapeHtml(t(status.enabled ? "auth.change_pin" : "auth.enable"))}</button>
${status.enabled ? `<button class="btn" type="button" id="authLogout">${escapeHtml(t("auth.logout"))}</button>
<button class="btn btn-danger" type="button" id="authDisable">${escapeHtml(t("auth.disable"))}</button>` : ""}
</div>
<p class="error" id="authPinError" hidden></p>
</form>`;
const error = container.querySelector("#authPinError");
container.querySelector("#authPinForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
if (form.get("newPin") !== form.get("confirmPin")) {
error.textContent = t("auth.pin_mismatch");
error.hidden = false;
return;
}
const { status: code, body } = await postJson("/api/auth/pin", { newPin: form.get("newPin") });
if (code !== 200) {
error.textContent = code === 401 ? t("auth.login_required") : body.error || t("auth.status_error");
error.hidden = false;
return;
}
remount();
});
container.querySelector("#authLogout")?.addEventListener("click", async () => { await postJson("/api/auth/logout"); remount(); });
container.querySelector("#authDisable")?.addEventListener("click", async () => {
if (!window.confirm(t("auth.disable_confirm"))) return;
const { status: code } = await postJson("/api/auth/disable");
if (code === 401) {
error.textContent = t("auth.login_required");
error.hidden = false;
return;
}
remount();
});
}