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") || "";