Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
@@ -23,8 +23,27 @@ export class APIError extends Error {
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, stage (bool), query}.
|
||||
// With two-factor login, some actions (API tokens, users) need a code
|
||||
// from the last few minutes. The server then answers totp_step_up (or
|
||||
// totp_invalid for a wrong code); askCode asks for one, and the request is
|
||||
// sent again with it. It returns null when the user cancels.
|
||||
let askCode = null;
|
||||
export function setCodePrompt(fn) { askCode = fn; }
|
||||
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
|
||||
|
||||
// api calls the REST API. opts: {body, stage (bool), query, code}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
try {
|
||||
return await call(method, path, opts);
|
||||
} catch (e) {
|
||||
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
|
||||
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
|
||||
if (!code) throw e;
|
||||
return api(method, path, { ...opts, code });
|
||||
}
|
||||
}
|
||||
|
||||
async function call(method, path, opts) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
@@ -40,6 +59,7 @@ export async function api(method, path, opts = {}) {
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
|
||||
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Jarl web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox, plural } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js";
|
||||
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
|
||||
import * as totp from "./totp.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as zones from "./pages/zones.js";
|
||||
import * as zone from "./pages/zone.js";
|
||||
@@ -65,22 +66,81 @@ function renderLogin(message) {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token;
|
||||
await start();
|
||||
const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
if (r.totp_required) { renderCodeStep(r.challenge); return; }
|
||||
await loggedIn(r);
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
pass.select();
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
mount(app, loginPanel(message, form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
function loginPanel(message, ...body) {
|
||||
return h("div", { class: "login panel" },
|
||||
logo(104, "login-logo"),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Rule your DNS holds"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
form));
|
||||
user.focus();
|
||||
...body);
|
||||
}
|
||||
|
||||
// renderCodeStep is the second login step for users with two-factor login.
|
||||
function renderCodeStep(challenge) {
|
||||
const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" });
|
||||
const err = h("div");
|
||||
let recovery = false;
|
||||
const hint = h("div", { class: "small muted" });
|
||||
const toggle = h("a", { href: "#", class: "small", onclick: (e) => {
|
||||
e.preventDefault();
|
||||
recovery = !recovery;
|
||||
code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code";
|
||||
code.inputMode = recovery ? "text" : "numeric";
|
||||
toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code";
|
||||
code.value = "";
|
||||
code.focus();
|
||||
} }, "Lost your phone? Use a recovery code");
|
||||
mount(hint, "Enter the code from your authenticator app.");
|
||||
const form = h("form", { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } });
|
||||
await loggedIn(me);
|
||||
} catch (ex) {
|
||||
if (ex.code === "login_expired") { renderLogin(ex.message); return; }
|
||||
mount(err, errorBox(ex));
|
||||
code.select();
|
||||
}
|
||||
} }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err);
|
||||
mount(app, loginPanel(null, form));
|
||||
code.focus();
|
||||
}
|
||||
|
||||
async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
if (me.totp_setup_required) renderSetupRequired();
|
||||
else await start();
|
||||
}
|
||||
|
||||
// renderSetupRequired: the server requires two-factor login for this
|
||||
// user (totp.require), and it isn't set up yet. Nothing else works until
|
||||
// it is.
|
||||
function renderSetupRequired() {
|
||||
const box = h("div");
|
||||
mount(app, h("div", { class: "login panel wide" },
|
||||
logo(64, "login-logo"),
|
||||
h("h1", null, "Set up two-factor login"),
|
||||
h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."),
|
||||
box,
|
||||
h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out"))));
|
||||
totp.setup(box, state.me, async () => {
|
||||
const me = await get("/auth/whoami");
|
||||
await loggedIn(me);
|
||||
});
|
||||
}
|
||||
|
||||
function layout() {
|
||||
@@ -183,12 +243,11 @@ setUnauthorizedHandler(() => {
|
||||
}
|
||||
});
|
||||
|
||||
setCodePrompt(totp.askCode);
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
const me = await get("/auth/whoami");
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
await start();
|
||||
await loggedIn(await get("/auth/whoami"));
|
||||
} catch (e) {
|
||||
if (e.status === 401) renderLogin();
|
||||
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e)));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h, mount, field, input, toast, errorBox } from "../ui.js";
|
||||
import { h, mount, field, input, toast, errorBox, time, modal, badge } from "../ui.js";
|
||||
import { api, get } from "../api.js";
|
||||
import * as totp from "../totp.js";
|
||||
import { tokenTable, newTokenForm, grantHelp } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
@@ -18,6 +19,7 @@ export async function render(el) {
|
||||
h("h1", null, "Account: " + me.user.username),
|
||||
h("div", { class: "panel" }, h("h2", null, "Permissions"),
|
||||
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
|
||||
me.method === "session" ? twoFactorPanel(me) : null,
|
||||
h("div", { class: "panel" }, h("h2", null, "Change password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
|
||||
err,
|
||||
@@ -36,3 +38,64 @@ export async function render(el) {
|
||||
grantHelp(grants));
|
||||
await drawTokens();
|
||||
}
|
||||
|
||||
// twoFactorPanel shows and changes the user's two-factor login.
|
||||
function twoFactorPanel(me) {
|
||||
const el = h("div", { class: "panel" });
|
||||
const draw = async () => {
|
||||
const st = await get("/me/totp");
|
||||
const body = h("div");
|
||||
const redraw = () => draw();
|
||||
if (!st.enabled) {
|
||||
mount(el, h("h2", null, "Two-factor login"),
|
||||
h("p", null, "Log in with your password and a code from an authenticator app on your phone, so a stolen password alone isn't enough.",
|
||||
st.required ? h("strong", null, " This server requires it for your account.") : null),
|
||||
body);
|
||||
totp.setup(body, me, redraw);
|
||||
return;
|
||||
}
|
||||
const actions = h("div", { class: "actions" });
|
||||
mount(el, h("h2", null, "Two-factor login"),
|
||||
h("p", null, badge("on", "ok"), " since ", time(st.enabled_at), ". ",
|
||||
st.recovery_codes_left + " of 10 recovery codes left.",
|
||||
st.required ? h("span", { class: "muted" }, " Required on this server.") : null),
|
||||
st.recovery_codes_left <= 3 ? h("div", { class: "msg warn" }, "Only " + st.recovery_codes_left + " recovery codes are left: make new ones.") : null,
|
||||
body, actions);
|
||||
mount(actions,
|
||||
h("button", { onclick: () => {
|
||||
mount(actions);
|
||||
totp.setup(body, me, redraw, { again: true, cancel: redraw });
|
||||
} }, "Move to a new phone"),
|
||||
h("button", { onclick: async () => {
|
||||
try {
|
||||
const r = await api("POST", "/me/totp/recovery-codes");
|
||||
mount(actions);
|
||||
mount(body, totp.recoveryBox(r.recovery_codes, me.user.username),
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: redraw }, "I have saved the codes")));
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
} }, "New recovery codes"),
|
||||
st.required ? null : h("button", { class: "danger", onclick: () => disable(me, redraw) }, "Turn off"));
|
||||
};
|
||||
draw();
|
||||
return el;
|
||||
}
|
||||
|
||||
async function disable(me, done) {
|
||||
const pw = input({ type: "password", autocomplete: "current-password" });
|
||||
const code = input({ autocomplete: "one-time-code", placeholder: "123456 or a recovery code" });
|
||||
const err = h("div");
|
||||
const ok = await modal("Turn off two-factor login", h("div", null,
|
||||
h("p", null, "You will log in with your password alone."),
|
||||
h("div", { class: "form-grid" },
|
||||
me.user.has_password ? field("Password", pw) : null,
|
||||
field("Code", code, "From your authenticator app, or a recovery code.")), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Turn off", kind: "danger", onclick: async () => {
|
||||
try {
|
||||
await api("POST", "/me/totp/disable", { body: { password: pw.value, code: code.value } });
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
if (ok) { toast("Two-factor login is off"); done(); }
|
||||
}
|
||||
@@ -40,6 +40,17 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
const tf = st.totp;
|
||||
if (tf && tf.missing && tf.missing.length) {
|
||||
parts.push(h("div", { class: "msg warn" },
|
||||
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
|
||||
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
|
||||
}
|
||||
if (tf && tf.shared_admin) {
|
||||
parts.push(h("div", { class: "msg info" },
|
||||
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
|
||||
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
|
||||
}
|
||||
const exp = st.expiring_tokens || [];
|
||||
if (exp.length) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -16,11 +16,13 @@ export async function render(el) {
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (u) => h("strong", null, u.username) },
|
||||
{ label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] },
|
||||
{ label: "2FA", render: (u) => u.totp_enabled_at ? badge("on", "ok") : u.totp_required ? badge("not set up", "warn") : !u.has_password ? h("span", { class: "muted" }, "–") : h("span", { class: "muted" }, "off") },
|
||||
{ label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" },
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") },
|
||||
{ label: "", render: (u) => h("span", null,
|
||||
h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ",
|
||||
h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ",
|
||||
u.totp_enabled_at ? [h("button", { class: "small", title: "Turn off their two-factor login (a lost phone without recovery codes)", onclick: () => resetTOTP(u) }, "Reset 2FA"), " "] : null,
|
||||
h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" },
|
||||
], users)),
|
||||
grantHelp(grants));
|
||||
@@ -78,6 +80,15 @@ async function userDialog(u, grants) {
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
async function resetTOTP(u) {
|
||||
if (!(await confirmDialog("Reset two-factor login", "Turn off two-factor login for " + u.username + "? Their recovery codes stop working and they are logged out. They log in with the password next time" + (u.totp_required ? " and must set it up again." : ", and can set it up again under Account."), "Reset", "danger"))) return;
|
||||
try {
|
||||
await api("DELETE", "/users/" + u.id + "/totp");
|
||||
toast("Two-factor login of " + u.username + " is off");
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function del(u) {
|
||||
if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return;
|
||||
try {
|
||||
|
||||
@@ -242,6 +242,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.kv dd { margin: 0; }
|
||||
.grants { font-family: var(--mono); font-size: 12px; }
|
||||
.copy-box { display: flex; gap: 6px; align-items: center; }
|
||||
.login.wide { max-width: 520px; }
|
||||
.totp-steps { padding-left: 20px; }
|
||||
.totp-steps li { margin-bottom: 12px; }
|
||||
.totp-qr { margin: 10px 0; }
|
||||
.totp-qr img { display: block; width: 200px; height: 200px; background: #fff; border-radius: var(--radius); }
|
||||
.totp-confirm { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
|
||||
input.totp-code { font-family: var(--mono); font-size: 18px; letter-spacing: 0.15em; max-width: 14em; }
|
||||
.recovery-codes { font-family: var(--mono); font-size: 15px; columns: 2; max-width: 360px; }
|
||||
.copy-box input { flex: 1; font-family: var(--mono); }
|
||||
.sortable-row { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 700px) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Two-factor login (TOTP): the code prompt, setting up an authenticator
|
||||
// app, and recovery codes.
|
||||
import { h, mount, modal, input, field, toast, errorBox, copyBox } from "./ui.js";
|
||||
import { api } from "./api.js";
|
||||
|
||||
function codeInput(attrs = {}) {
|
||||
return input({ inputmode: "numeric", autocomplete: "one-time-code", maxlength: 6, pattern: "[0-9]*", placeholder: "123456", class: "totp-code", ...attrs });
|
||||
}
|
||||
|
||||
// askCode asks for a current code (actions that need a recent one).
|
||||
// It returns the code, or null when canceled.
|
||||
export async function askCode(message) {
|
||||
const code = codeInput();
|
||||
const r = await modal("Confirm with your authenticator", h("div", null,
|
||||
message ? h("div", { class: "msg err" }, message) : null,
|
||||
h("p", null, "This action needs a current code from your authenticator app."),
|
||||
code), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Confirm", kind: "primary", onclick: () => code.value.trim().length > 0 },
|
||||
]);
|
||||
return r === "Confirm" ? code.value.trim() : null;
|
||||
}
|
||||
|
||||
// recoveryBox shows new recovery codes, to copy or download.
|
||||
export function recoveryBox(codes, username) {
|
||||
const text = "Jarl recovery codes for " + username + " (each works once)\n\n" + codes.join("\n") + "\n";
|
||||
const download = () => {
|
||||
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "text/plain" })), download: "jarl-recovery-codes-" + username + ".txt" });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
return h("div", null,
|
||||
h("div", { class: "msg warn" }, "Save these recovery codes somewhere safe (a password manager, or printed). Each logs you in once if you lose your phone. They are shown only now."),
|
||||
h("pre", { class: "recovery-codes" }, codes.join("\n")),
|
||||
h("div", { class: "actions" },
|
||||
h("button", { type: "button", onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(text); toast("Copied"); } catch { toast("Copy failed; select the codes instead", "err"); }
|
||||
} }, "Copy"),
|
||||
h("button", { type: "button", onclick: download }, "Download")));
|
||||
}
|
||||
|
||||
// setup walks through setting up an authenticator app in el: password,
|
||||
// QR code, confirming code, recovery codes. onDone runs when finished.
|
||||
export function setup(el, me, onDone, opts = {}) {
|
||||
const hasPassword = me.user.has_password !== false;
|
||||
const pw = input({ type: "password", autocomplete: "current-password" });
|
||||
const err = h("div");
|
||||
const start = async () => {
|
||||
mount(err);
|
||||
let s;
|
||||
try {
|
||||
s = await api("POST", "/me/totp/setup", { body: { password: pw.value } });
|
||||
} catch (e) { mount(err, errorBox(e)); return; }
|
||||
showQR(s);
|
||||
};
|
||||
const showQR = (s) => {
|
||||
const code = codeInput();
|
||||
const err2 = h("div");
|
||||
const confirm = async () => {
|
||||
mount(err2);
|
||||
try {
|
||||
const r = await api("POST", "/me/totp/enable", { body: { code: code.value } });
|
||||
mount(el,
|
||||
h("div", { class: "msg ok" }, "Two-factor login is on. From now on you log in with your password and a code from the app."),
|
||||
recoveryBox(r.recovery_codes, me.user.username),
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: onDone }, "I have saved the codes")));
|
||||
} catch (e) { mount(err2, errorBox(e)); code.select(); }
|
||||
};
|
||||
code.addEventListener("keydown", (e) => { if (e.key === "Enter") confirm(); });
|
||||
mount(el,
|
||||
h("ol", { class: "totp-steps" },
|
||||
h("li", null, "Scan this QR code with an authenticator app (Aegis, Google Authenticator, 1Password, Bitwarden, …).",
|
||||
h("div", { class: "totp-qr" }, h("img", { src: s.qr, alt: "QR code for " + s.issuer + ": " + s.account, width: 200, height: 200 })),
|
||||
h("details", null, h("summary", { class: "small" }, "Can't scan? Enter the key by hand"),
|
||||
h("div", { class: "small muted" }, "Account: " + s.issuer + ": " + s.account + " · time-based, 6 digits, 30 seconds"),
|
||||
copyBox(s.secret))),
|
||||
h("li", null, "Enter the 6-digit code the app shows:", h("div", { class: "totp-confirm" }, code,
|
||||
h("button", { class: "primary", onclick: confirm }, "Turn on")))),
|
||||
err2);
|
||||
code.focus();
|
||||
};
|
||||
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") start(); });
|
||||
mount(el,
|
||||
hasPassword ? h("div", { class: "form-grid" }, field("Your password", pw, "To make sure it's you.")) : null,
|
||||
err,
|
||||
h("div", { class: "actions", style: { marginTop: "8px" } },
|
||||
opts.cancel ? h("button", { onclick: opts.cancel }, "Cancel") : null,
|
||||
h("button", { class: "primary", onclick: start }, opts.again ? "Set up a new app" : "Set up")));
|
||||
if (hasPassword) pw.focus();
|
||||
}
|
||||
Reference in new issue
Block a user