mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
add security test 12
This commit is contained in:
parent
a6a11629bf
commit
4cb33afd6b
3 changed files with 251 additions and 18 deletions
|
|
@ -2746,6 +2746,33 @@
|
||||||
font-size: 1.2rem;
|
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 {
|
.close {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
width: 34px;
|
width: 34px;
|
||||||
|
|
@ -2817,6 +2844,12 @@
|
||||||
.filters {
|
.filters {
|
||||||
grid-template-columns: 1fr;
|
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-grid { grid-template-columns: 1fr; }
|
||||||
.lawyer-dashboard-card {
|
.lawyer-dashboard-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import {
|
||||||
translateApiError,
|
translateApiError,
|
||||||
userInitials,
|
userInitials,
|
||||||
} from "./admin/shared/utils.js";
|
} from "./admin/shared/utils.js";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
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 }) {
|
function AttachmentPreviewModal({ open, title, url, fileName, mimeType, onClose }) {
|
||||||
const [resolvedUrl, setResolvedUrl] = useState("");
|
const [resolvedUrl, setResolvedUrl] = useState("");
|
||||||
const [resolvedText, setResolvedText] = useState("");
|
const [resolvedText, setResolvedText] = useState("");
|
||||||
|
|
@ -943,6 +1028,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
required: false,
|
required: false,
|
||||||
has_backup_codes: false,
|
has_backup_codes: false,
|
||||||
});
|
});
|
||||||
|
const [totpSetupModal, setTotpSetupModal] = useState({
|
||||||
|
open: false,
|
||||||
|
secret: "",
|
||||||
|
uri: "",
|
||||||
|
qrDataUrl: "",
|
||||||
|
code: "",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
const [recordModal, setRecordModal] = useState({
|
const [recordModal, setRecordModal] = useState({
|
||||||
open: false,
|
open: false,
|
||||||
|
|
@ -2650,30 +2743,113 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
[api, token]
|
[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 () => {
|
const setupTotp = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const setup = await api("/api/admin/auth/totp/setup", { method: "POST", body: {} });
|
const setup = await api("/api/admin/auth/totp/setup", { method: "POST", body: {} });
|
||||||
const secret = String(setup?.secret || "").trim();
|
const secret = String(setup?.secret || "").trim();
|
||||||
const uri = String(setup?.otpauth_uri || "").trim();
|
const uri = String(setup?.otpauth_uri || "").trim();
|
||||||
if (!secret || !uri) throw new Error("Не удалось получить секрет TOTP");
|
if (!secret || !uri) throw new Error("Не удалось получить секрет TOTP");
|
||||||
window.alert(
|
let qrDataUrl = "";
|
||||||
"Сканируйте QR/URI в Google Authenticator:\n\n" +
|
try {
|
||||||
uri +
|
qrDataUrl = await QRCode.toDataURL(uri, {
|
||||||
"\n\nИли введите ключ вручную:\n" +
|
margin: 1,
|
||||||
secret
|
width: 240,
|
||||||
);
|
errorCorrectionLevel: "M",
|
||||||
const code = String(window.prompt("Введите текущий 6-значный код из Authenticator", "") || "").trim();
|
});
|
||||||
if (!code) return;
|
} catch (_) {
|
||||||
const enabled = await api("/api/admin/auth/totp/enable", { method: "POST", body: { secret, code } });
|
qrDataUrl = "";
|
||||||
const backupCodes = Array.isArray(enabled?.backup_codes) ? enabled.backup_codes : [];
|
}
|
||||||
window.alert(
|
setTotpSetupModal({
|
||||||
"2FA включена.\nСохраните резервные коды (однократно):\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-")
|
open: true,
|
||||||
);
|
secret,
|
||||||
await loadTotpStatus();
|
uri,
|
||||||
|
qrDataUrl,
|
||||||
|
code: "",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
setStatus("totpSetup", "", "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("login", "Ошибка настройки 2FA: " + error.message, "error");
|
setStatus("login", "Ошибка настройки 2FA: " + error.message, "error");
|
||||||
}
|
}
|
||||||
}, [api, loadTotpStatus, setStatus]);
|
}, [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) {
|
||||||
|
setTotpSetupModal((prev) => ({ ...prev, loading: false }));
|
||||||
|
setStatus("totpSetup", "Ошибка включения 2FA: " + error.message, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, closeTotpSetupModal, loadTotpStatus, setStatus, totpSetupModal.code, totpSetupModal.secret]
|
||||||
|
);
|
||||||
|
|
||||||
const regenerateTotpBackupCodes = useCallback(async () => {
|
const regenerateTotpBackupCodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2747,6 +2923,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
required: false,
|
required: false,
|
||||||
has_backup_codes: false,
|
has_backup_codes: false,
|
||||||
});
|
});
|
||||||
|
setTotpSetupModal({
|
||||||
|
open: false,
|
||||||
|
secret: "",
|
||||||
|
uri: "",
|
||||||
|
qrDataUrl: "",
|
||||||
|
code: "",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
setActiveSection("dashboard");
|
setActiveSection("dashboard");
|
||||||
}, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]);
|
}, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]);
|
||||||
|
|
||||||
|
|
@ -2870,7 +3054,7 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
if (!hasCurrent) setConfigActiveKey(dictionaryTableItems[0].key);
|
||||||
}, [configActiveKey, dictionaryTableItems]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
document.body.classList.toggle("modal-open", anyOverlayOpen);
|
||||||
return () => document.body.classList.remove("modal-open");
|
return () => document.body.classList.remove("modal-open");
|
||||||
|
|
@ -2883,10 +3067,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
setFilterModal((prev) => ({ ...prev, open: false }));
|
setFilterModal((prev) => ({ ...prev, open: false }));
|
||||||
closeKanbanSortModal();
|
closeKanbanSortModal();
|
||||||
setReassignModal((prev) => ({ ...prev, open: false }));
|
setReassignModal((prev) => ({ ...prev, open: false }));
|
||||||
|
closeTotpSetupModal();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onEsc);
|
document.addEventListener("keydown", onEsc);
|
||||||
return () => document.removeEventListener("keydown", onEsc);
|
return () => document.removeEventListener("keydown", onEsc);
|
||||||
}, [closeKanbanSortModal]);
|
}, [closeKanbanSortModal, closeTotpSetupModal]);
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
|
@ -3427,6 +3612,21 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__";
|
||||||
trackNumber={reassignModal.trackNumber}
|
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}
|
{!token || !role ? <LoginScreen onSubmit={login} status={getStatus("login")} /> : null}
|
||||||
<GlobalTooltipLayer />
|
<GlobalTooltipLayer />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ COPY app/web/admin.jsx ./admin.jsx
|
||||||
COPY app/web/client ./client
|
COPY app/web/client ./client
|
||||||
COPY app/web/client.jsx ./client.jsx
|
COPY app/web/client.jsx ./client.jsx
|
||||||
RUN npm init -y >/dev/null 2>&1 \
|
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 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 \
|
&& npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js \
|
||||||
&& mkdir -p vendor \
|
&& mkdir -p vendor \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue