add security test 12

This commit is contained in:
TronoSfera 2026-03-02 18:25:50 +03:00
parent a6a11629bf
commit 4cb33afd6b
3 changed files with 251 additions and 18 deletions

View file

@ -2746,6 +2746,33 @@
font-size: 1.2rem;
}
.totp-setup-grid {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 0.9rem;
margin-bottom: 0.8rem;
align-items: start;
}
.totp-qr-box {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.6rem;
display: grid;
place-items: center;
min-height: 240px;
}
.totp-qr-img {
width: 220px;
height: 220px;
max-width: 100%;
object-fit: contain;
border-radius: 8px;
background: #fff;
}
.close {
border: 1px solid var(--line);
width: 34px;
@ -2817,6 +2844,12 @@
.filters {
grid-template-columns: 1fr;
}
.totp-setup-grid { grid-template-columns: 1fr; }
.totp-qr-box { min-height: 200px; }
.totp-qr-img {
width: 200px;
height: 200px;
}
.lawyer-dashboard-grid { grid-template-columns: 1fr; }
.lawyer-dashboard-card {
grid-template-columns: 1fr;

View file

@ -60,6 +60,7 @@ import {
translateApiError,
userInitials,
} from "./admin/shared/utils.js";
import QRCode from "qrcode";
(function () {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
@ -503,6 +504,90 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
);
}
function TotpSetupModal({
open,
status,
secret,
uri,
qrDataUrl,
code,
loading,
onCodeChange,
onClose,
onSubmit,
onCopySecret,
onCopyUri,
}) {
if (!open) return null;
return (
<Overlay open={open} id="totp-setup-overlay" onClose={(event) => event.target.id === "totp-setup-overlay" && onClose()}>
<div className="modal" style={{ width: "min(700px, 100%)" }} onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<div>
<h3>Настройка 2FA</h3>
<p className="muted" style={{ marginTop: "0.35rem" }}>
Сканируйте QR-код в Google Authenticator и подтвердите 6-значным кодом.
</p>
</div>
<button className="close" type="button" onClick={onClose}>
×
</button>
</div>
<div className="totp-setup-grid">
<div className="totp-qr-box">
{qrDataUrl ? (
<img className="totp-qr-img" src={qrDataUrl} alt="QR-код для настройки 2FA" />
) : (
<p className="muted">QR-код не удалось сгенерировать. Используйте ключ вручную.</p>
)}
</div>
<div className="stack">
<div className="field">
<label htmlFor="totp-secret">Секретный ключ</label>
<input id="totp-secret" type="text" value={secret} readOnly />
</div>
<div className="field">
<label htmlFor="totp-uri">URI (otpauth)</label>
<textarea id="totp-uri" rows={3} value={uri} readOnly />
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={onCopySecret}>
Копировать ключ
</button>
<button className="btn secondary" type="button" onClick={onCopyUri}>
Копировать URI
</button>
</div>
</div>
</div>
<form className="stack" onSubmit={onSubmit}>
<div className="field">
<label htmlFor="totp-verify-code">Код из Google Authenticator</label>
<input
id="totp-verify-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={code}
onChange={onCodeChange}
/>
</div>
<div style={{ display: "flex", gap: "0.6rem", flexWrap: "wrap" }}>
<button className="btn" type="submit" disabled={loading}>
{loading ? "Включаем..." : "Включить 2FA"}
</button>
<button className="btn secondary" type="button" onClick={onClose} disabled={loading}>
Отмена
</button>
</div>
<StatusLine status={status} />
</form>
</div>
</Overlay>
);
}
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
const [resolvedUrl, setResolvedUrl] = useState("");
const [resolvedText, setResolvedText] = useState("");
@ -943,6 +1028,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
required: false,
has_backup_codes: false,
});
const [totpSetupModal, setTotpSetupModal] = useState({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
const [recordModal, setRecordModal] = useState({
open: false,
@ -2650,30 +2743,113 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
[api, token]
);
const closeTotpSetupModal = useCallback(() => {
setTotpSetupModal({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
setStatus("totpSetup", "", "");
}, [setStatus]);
const updateTotpSetupCode = useCallback((event) => {
setTotpSetupModal((prev) => ({ ...prev, code: event.target.value }));
}, []);
const copyTotpSecret = useCallback(async () => {
const value = String(totpSetupModal.secret || "").trim();
if (!value) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
setStatus("totpSetup", "Ключ скопирован в буфер обмена", "ok");
} else {
setStatus("totpSetup", "Буфер обмена недоступен в этом браузере", "error");
}
} catch (_) {
setStatus("totpSetup", "Не удалось скопировать ключ", "error");
}
}, [setStatus, totpSetupModal.secret]);
const copyTotpUri = useCallback(async () => {
const value = String(totpSetupModal.uri || "").trim();
if (!value) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(value);
setStatus("totpSetup", "URI скопирован в буфер обмена", "ok");
} else {
setStatus("totpSetup", "Буфер обмена недоступен в этом браузере", "error");
}
} catch (_) {
setStatus("totpSetup", "Не удалось скопировать URI", "error");
}
}, [setStatus, totpSetupModal.uri]);
const setupTotp = useCallback(async () => {
try {
const setup = await api("/api/admin/auth/totp/setup", { method: "POST", body: {} });
const secret = String(setup?.secret || "").trim();
const uri = String(setup?.otpauth_uri || "").trim();
if (!secret || !uri) throw new Error("Не удалось получить секрет TOTP");
window.alert(
"Сканируйте QR/URI в Google Authenticator:\n\n" +
uri +
"\n\nИли введите ключ вручную:\n" +
secret
);
const code = String(window.prompt("Введите текущий 6-значный код из Authenticator", "") || "").trim();
if (!code) return;
const enabled = await api("/api/admin/auth/totp/enable", { method: "POST", body: { secret, code } });
let qrDataUrl = "";
try {
qrDataUrl = await QRCode.toDataURL(uri, {
margin: 1,
width: 240,
errorCorrectionLevel: "M",
});
} catch (_) {
qrDataUrl = "";
}
setTotpSetupModal({
open: true,
secret,
uri,
qrDataUrl,
code: "",
loading: false,
});
setStatus("totpSetup", "", "");
} catch (error) {
setStatus("login", "Ошибка настройки 2FA: " + error.message, "error");
}
}, [api, setStatus]);
const submitTotpSetup = useCallback(
async (event) => {
event.preventDefault();
const secret = String(totpSetupModal.secret || "").trim();
const rawCode = String(totpSetupModal.code || "").trim();
const digitsOnly = rawCode.replace(/\D+/g, "");
if (!secret) {
setStatus("totpSetup", "Не найден TOTP secret. Перезапустите настройку.", "error");
return;
}
if (digitsOnly.length !== 6) {
setStatus("totpSetup", "Введите корректный 6-значный код", "error");
return;
}
try {
setTotpSetupModal((prev) => ({ ...prev, loading: true }));
const enabled = await api("/api/admin/auth/totp/enable", { method: "POST", body: { secret, code: digitsOnly } });
closeTotpSetupModal();
setStatus("login", "2FA включена", "ok");
const backupCodes = Array.isArray(enabled?.backup_codes) ? enabled.backup_codes : [];
window.alert(
"2FA включена.\nСохраните резервные коды (однократно):\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-")
);
await loadTotpStatus();
} catch (error) {
setStatus("login", "Ошибка настройки 2FA: " + error.message, "error");
setTotpSetupModal((prev) => ({ ...prev, loading: false }));
setStatus("totpSetup", "Ошибка включения 2FA: " + error.message, "error");
}
}, [api, loadTotpStatus, setStatus]);
},
[api, closeTotpSetupModal, loadTotpStatus, setStatus, totpSetupModal.code, totpSetupModal.secret]
);
const regenerateTotpBackupCodes = useCallback(async () => {
try {
@ -2747,6 +2923,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
required: false,
has_backup_codes: false,
});
setTotpSetupModal({
open: false,
secret: "",
uri: "",
qrDataUrl: "",
code: "",
loading: false,
});
setActiveSection("dashboard");
}, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]);
@ -2870,7 +3054,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
}, [configActiveKey, dictionaryTableItems]);
const anyOverlayOpen = recordModal.open || filterModal.open || reassignModal.open || kanbanSortModal.open;
const anyOverlayOpen = recordModal.open || filterModal.open || reassignModal.open || kanbanSortModal.open || totpSetupModal.open;
useEffect(() => {
document.body.classList.toggle("modal-open", anyOverlayOpen);
return () => document.body.classList.remove("modal-open");
@ -2883,10 +3067,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
setFilterModal((prev) => ({ ...prev, open: false }));
closeKanbanSortModal();
setReassignModal((prev) => ({ ...prev, open: false }));
closeTotpSetupModal();
};
document.addEventListener("keydown", onEsc);
return () => document.removeEventListener("keydown", onEsc);
}, [closeKanbanSortModal]);
}, [closeKanbanSortModal, closeTotpSetupModal]);
const menuItems = useMemo(() => {
return [
@ -3427,6 +3612,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
trackNumber={reassignModal.trackNumber}
/>
<TotpSetupModal
open={totpSetupModal.open}
status={getStatus("totpSetup")}
secret={totpSetupModal.secret}
uri={totpSetupModal.uri}
qrDataUrl={totpSetupModal.qrDataUrl}
code={totpSetupModal.code}
loading={totpSetupModal.loading}
onCodeChange={updateTotpSetupCode}
onClose={closeTotpSetupModal}
onSubmit={submitTotpSetup}
onCopySecret={copyTotpSecret}
onCopyUri={copyTotpUri}
/>
{!token || !role ? <LoginScreen onSubmit={login} status={getStatus("login")} /> : null}
<GlobalTooltipLayer />
</>

View file

@ -5,7 +5,7 @@ COPY app/web/admin.jsx ./admin.jsx
COPY app/web/client ./client
COPY app/web/client.jsx ./client.jsx
RUN npm init -y >/dev/null 2>&1 \
&& npm install --silent esbuild@0.25.10 react@18.2.0 react-dom@18.2.0 \
&& npm install --silent esbuild@0.25.10 react@18.2.0 react-dom@18.2.0 qrcode@1.5.4 \
&& npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js \
&& npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js \
&& mkdir -p vendor \