Задача 037: двухфакторная аутентификация TOTP по выбору пользователя

Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов
восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага:
/auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль.
Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода
(totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp,
segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс
в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 16:57:14 +03:00
1 parent c852f47f09
commit d3495fb077
18 files changed
+835 -56

No files matched your search

+130 -16
View File
@@ -41,6 +41,7 @@ let user = null;
let orgs = [];
let S = {}; // локальное состояние текущего экрана
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
let pendingMfa = null; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage
/* --------------------------------------------------------------------- api */
class ApiError extends Error {
@@ -60,7 +61,7 @@ async function api(path, { method = "GET", body, params } = {}) {
});
if (res.status === 204) return null;
const data = await res.json().catch(() => null);
if (res.status === 401 && path !== "/auth/login") {
if (res.status === 401 && path !== "/auth/login" && path !== "/auth/login/2fa") { // изменение 037: 401 здесь — истёкший/неверный mfa_token, обрабатывается на месте
store.token = null;
location.hash = "#/login";
throw new ApiError(401, { message: "Сессия истекла" });
@@ -133,7 +134,8 @@ const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["org
function shell(active, content) {
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
// изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
// изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти»
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div>${userMenu}</div>
@@ -184,7 +186,9 @@ async function submitDialog() {
const h = S.dialog;
try {
await h(readForm());
closeDialog();
// изменение 037: обработчик мог сам открыть следующее окно диалоговой цепочки (сменив S.dialog) — тогда
// закрывать нечего, иначе только что открытое окно закрылось бы сразу вслед за собой
if (S.dialog === h) closeDialog();
} catch (e) {
if (e instanceof ApiError) showFormError(e); else throw e;
}
@@ -707,6 +711,8 @@ screens.users = async () => {
const items2 = [
{ label: "Редактировать", value: "edit:" + u.id },
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
// изменение 037: сброс 2FA — только у чужих записей с включённой 2FA
...(!self && u.totp_enabled ? [{ label: "Сбросить 2FA", value: "totp-reset:" + u.id }] : []),
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
];
const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032
@@ -714,7 +720,7 @@ screens.users = async () => {
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}${u.totp_enabled ? " " + badge("blue", "2FA") : ""}</span>
<span style="font-size:13px">${u.last_login_at ? `<span style="display:flex;flex-direction:column;gap:2px"><span>${fmtDate(u.last_login_at)}</span><span class="mono muted" style="font-size:12px">${esc(u.last_login_ip || "—")}</span></span>` : '<span class="muted">—</span>'}</span>
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
@@ -772,8 +778,82 @@ function passwordDialog() {
});
}
// ---- 2FA (изменение 037): управление своей двухфакторной аутентификацией из меню логина в шапке
function totpDialog() {
return user.totp_enabled ? totpStatusDialog() : totpSetupPasswordDialog();
}
function totpStatusDialog() {
openDialog({
title: "Двухфакторная аутентификация",
body: `<div class="dialog-body"><div class="info">Включена с ${esc(fmtDate(user.totp_enabled_at))}</div></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "ghost" })}${btn("Отключить", "totp-disable-start", { cls: "danger" })}</div>`,
});
}
function totpSetupPasswordDialog() {
S.dialog = async (v) => {
const r = await api("/users/me/2fa/setup", { method: "POST", body: { password: v.password } });
closeDialog();
totpConfirmDialog(r);
};
openDialog({
title: "Включить двухфакторную аутентификацию",
note: "Понадобится приложение-аутентификатор (Google Authenticator, Aegis, 1Password, Bitwarden и подобные).",
body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })}`),
foot: dlgFoot("Далее"),
});
}
function totpConfirmDialog(setup) {
S.dialog = async (v) => {
const r = await api("/users/me/2fa/enable", { method: "POST", body: { code: v.code } });
closeDialog();
user = await api("/auth/me");
totpRecoveryCodesDialog(r.recovery_codes);
};
openDialog({
title: "Сканируйте QR-код",
width: 460,
note: "Отсканируйте код в приложении-аутентификаторе или введите секрет вручную, затем введите код из приложения.",
body: formBody(`<div style="display:flex;justify-content:center;padding:8px 0"><img src="${setup.qr}" alt="QR-код" width="220" height="220"></div>
<label class="field"><span>Секрет для ручного ввода</span><input class="input mono" value="${esc(setup.secret)}" readonly onclick="this.select()"></label>
${fInput("code", "Код из приложения", { ph: "000000" })}`),
foot: dlgFoot("Подтвердить"),
});
}
function totpRecoveryCodesDialog(codes) {
S.dialog = null;
const text = codes.join("\n");
openDialog({
title: "Коды восстановления", width: 460,
note: "Сохраните эти коды в надёжном месте — сервер больше не покажет их. Каждый код можно использовать один раз, вместо кода из приложения.",
body: `<div class="dialog-body"><pre class="json" style="text-align:center;line-height:1.8">${esc(text)}</pre></div>`,
foot: `<div class="dialog-foot">${btn("Скопировать", "copy", { data: `data-text="${esc(text)}"` })}${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
function totpDisableStartDialog() {
closeDialog();
S.dialog = async (v) => {
await api("/users/me/2fa/disable", { method: "POST", body: { password: v.password, code: v.code } });
closeDialog();
user = await api("/auth/me");
toast("Двухфакторная аутентификация отключена");
};
openDialog({
title: "Отключить двухфакторную аутентификацию",
body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })}
${fInput("code", "Код из приложения или код восстановления", { ph: "000000" })}`),
foot: dlgFoot("Отключить"),
});
}
// ---- login
function loginScreen(err = "") {
if (pendingMfa) {
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="mfa-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Код из приложения или код восстановления</span><input class="input mono" name="code" autocomplete="one-time-code" autofocus></label>
<button class="btn primary" type="submit">Подтвердить</button>
<button class="btn ghost" type="button" data-action="mfa-back" style="margin-top:8px;width:100%">Назад</button></form></div>`;
}
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="login-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Логин</span><input class="input" name="username" autocomplete="username" autofocus></label>
@@ -907,6 +987,9 @@ const rowActions = {
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
});
else if (act === "totp-reset" && confirm(`Сбросить двухфакторную аутентификацию пользователю ${u.username}?`)) await guarded(async () => {
await api(`/users/${id}/2fa/reset`, { method: "POST" }); toast("Двухфакторная аутентификация сброшена"); await draw();
}); // изменение 037
else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
},
};
@@ -928,7 +1011,8 @@ const actions = {
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
else if (id === "user") { // изменение 036: меню логина в шапке
document.querySelectorAll(".pop").forEach((el) => el.remove());
return value === "logout" ? actions.logout() : passwordDialog();
if (value === "logout") return actions.logout();
return value === "2fa" ? totpDialog() : passwordDialog(); // изменение 037
}
else {
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
@@ -988,6 +1072,8 @@ const actions = {
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"totp-disable-start": () => totpDisableStartDialog(), // изменение 037
"mfa-back": () => { pendingMfa = null; $("#app").innerHTML = loginScreen(); }, // изменение 037
"isp-new": () => ispDialog(null),
"dev-new": () => deviceDialog(null),
"pfx-new": () => prefixDialog(null),
@@ -1072,17 +1158,45 @@ document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); }
});
document.addEventListener("submit", async (e) => {
if (e.target.id !== "login-form") return;
e.preventDefault();
const f = new FormData(e.target);
try {
const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
store.token = access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
$("#app").innerHTML = loginScreen(err.message);
if (e.target.id === "login-form") {
e.preventDefault();
const f = new FormData(e.target);
try {
const r = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
if (r.mfa_required) { // изменение 037: второй шаг — код из приложения или код восстановления
pendingMfa = { mfa_token: r.mfa_token };
$("#app").innerHTML = loginScreen();
return;
}
store.token = r.access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
$("#app").innerHTML = loginScreen(err.message);
}
return;
}
if (e.target.id === "mfa-form") { // изменение 037
e.preventDefault();
const code = new FormData(e.target).get("code");
try {
const { access_token } = await api("/auth/login/2fa", { method: "POST", body: { mfa_token: pendingMfa.mfa_token, code } });
pendingMfa = null;
store.token = access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
// 401 «Неверный код» — остаёмся на втором шаге (можно повторить); другой 401 — истёкший/недействительный
// mfa_token или отключённая 2FA — возврат к паролю (изменение 037)
if (err instanceof ApiError && err.status === 401 && err.message !== "Неверный код") {
pendingMfa = null;
$("#app").innerHTML = loginScreen("Время на ввод кода истекло, войдите заново");
return;
}
$("#app").innerHTML = loginScreen(err.message);
}
}
});