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:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user