feat: harden auth and study workflows

- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
-28
View File
@@ -1,5 +1,4 @@
const TOKEN_KEY = "ctms_token";
const CREDENTIAL_KEY = "ctms_credential";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
@@ -10,30 +9,3 @@ export const setToken = (token: string): void => {
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
export const setCachedCredential = (email: string, password: string) => {
try {
const payload = btoa(JSON.stringify({ email, password }));
localStorage.setItem(CREDENTIAL_KEY, payload);
} catch {
/* ignore */
}
};
export const getCachedCredential = (): { email: string; password: string } | null => {
const value = localStorage.getItem(CREDENTIAL_KEY);
if (!value) return null;
try {
const parsed = JSON.parse(atob(value));
if (parsed?.email && parsed?.password) {
return { email: parsed.email, password: parsed.password };
}
} catch {
/* ignore */
}
return null;
};
export const clearCachedCredential = (): void => {
localStorage.removeItem(CREDENTIAL_KEY);
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { encryptLoginPayload } from "./loginCrypto";
describe("encryptLoginPayload", () => {
it("fails with a clear error outside a secure browser context", async () => {
vi.stubGlobal("isSecureContext", false);
await expect(
encryptLoginPayload("unused", {
email: "admin@test.com",
password: "admin123",
challenge: "challenge-value-with-enough-length",
})
).rejects.toThrow("当前浏览器环境不支持安全登录加密");
vi.unstubAllGlobals();
});
});
+56
View File
@@ -0,0 +1,56 @@
const pemToArrayBuffer = (pem: string): ArrayBuffer => {
const base64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
.replace(/-----END PUBLIC KEY-----/g, "")
.replace(/\s/g, "");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
};
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
};
const assertSecureCryptoAvailable = () => {
if (!window.isSecureContext || !crypto?.subtle) {
throw new Error("当前浏览器环境不支持安全登录加密,请使用 HTTPS 或 localhost 访问系统");
}
};
export const encryptLoginPayload = async (
publicKeyPem: string,
payload: { email: string; password: string; challenge: string }
): Promise<string> => {
assertSecureCryptoAvailable();
const publicKey = await crypto.subtle.importKey(
"spki",
pemToArrayBuffer(publicKeyPem),
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["encrypt"]
);
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(payload));
const encryptedData = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded);
const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);
const encryptedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, rawAesKey);
const envelope = {
encrypted_key: arrayBufferToBase64(encryptedKey),
iv: arrayBufferToBase64(iv.buffer),
data: arrayBufferToBase64(encryptedData),
};
return btoa(JSON.stringify(envelope));
};
+1 -6
View File
@@ -118,13 +118,8 @@ const PROJECT_FIELD_LABEL_MAP: Record<keyof ProjectPublishSnapshot, string> = {
plan_end_date: "计划结束日期",
planned_site_count: "计划中心数",
planned_enrollment_count: "计划入组数",
summary_note: "方案摘要说明",
objective_note: "研究目标摘要",
status: "项目状态",
visit_interval_days: "访视间隔(天)",
visit_total: "访视总数",
visit_window_start_offset: "窗口期起始偏移",
visit_window_end_offset: "窗口期结束偏移",
visit_schedule: "访视计划",
};
export const buildProjectDiffRows = (