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:
abrown38andClaude Opus 5.5 committed 2026-10-09 14:12:24 +00:00
1 parent b22ffee1bb
commit 8f9cdd0a17
35 files changed
+1965 -43

No files matched your search

+21 -1
View File
@@ -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") || "";
+71 -12
View File
@@ -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)));
+64 -1
View File
@@ -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();
+11
View File
@@ -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 {
+8
View File
@@ -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) {
+91
View File
@@ -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();
}