Задача 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:
1 parent
c852f47f09
commit
d3495fb077
18 files changed
+835
-56
No files matched your search
+130
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user