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>
92 lines
4.6 KiB
JavaScript
92 lines
4.6 KiB
JavaScript
// 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();
|
|
}
|