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

+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 {