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
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user