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