diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 8e54746..9998d3b 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -1,6 +1,8 @@ # admin.xpcool.com 变更记录 > 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要 +2026-08-27 | CHG | 登录/创建/重置密码全链路加密传输(RSA+AES 混合加密,与后端配套):①新增 src/utils/crypto.ts——Web Crypto API 零依赖:importKey('spki') 导入后端公钥、随机 AES-256-GCM 加密载荷(nonce 12B 前置、GCM tag 随密文)、RSA-OAEP(SHA-256) 加密 AES 密钥,输出 {encryptedKey, encryptedData};公钥 5 分钟内存缓存。②api/core/auth.ts:getPublicKeyApi(并发去重)+ loginApi 先拉公钥再加密 username/password 提交,网络传输无明文密码。③api/system.ts:createAdmin/resetAdminPassword 的 password 走 hybridEncryptField 加密(encryptField 复用公钥缓存)。④注意:Web Crypto 仅安全上下文可用(https/localhost/127.0.0.1),http://IP 访问会明确报错。typecheck + oxlint 全绿 + 2026-08-27 | CHG | 用户管理改造(页面标题改"用户管理",表单/列表加 Bark设备ID、pushplus token)+ 通知模块 3 页(规则:渠道多选+用户多选+标题/内容模板+测试按钮;渠道:启用+JSON 配置;日志:事件/渠道/结果/时间筛选)+ 自动任务 2 页(任务列表:cron 编辑/启停/手动执行;运行日志:按任务筛选+耗时/摘要/错误);api/notice.ts + autoJob.ts + system.ts 扩展(AdminItem 加渠道字段);typecheck 通过 2026-08-27 | FIX | 前后端联查修复「界面/菜单/工作台异常」:①本地库缺 010_server_log_enhance 增量列(admin_operation_log 无 ip_location/admin_username 等)致操作日志接口 500,已导;②本地缺工作台/招聘菜单+admin_role_menu 关联,已导 013_workbench_menu+recruitment/003_menu;③工作台快捷入口 /system/log/* 是死链(真实路由 /log/*)已改;④打通 Drawer 重构遗留编译错:6 页 @vben-core/popup-ui 误引改 @vben/common-ui、preferences 补 languageToggleButtonPosition、清未用变量、menu/log 页 lint 债(7dc9bc6,钩子全绿);⑤发现审计 admin_username/user_agent 长期为空=线上跑旧二进制,新构建验证 248 号记录字段完整,历史空账号已按 user_id 回填(121 条);⑥本地 xxcool/opuser 密码已重置为 opuser123 便于联调 diff --git a/apps/web-tdesign/src/api/core/auth.ts b/apps/web-tdesign/src/api/core/auth.ts index c2c88c9..80b34bd 100644 --- a/apps/web-tdesign/src/api/core/auth.ts +++ b/apps/web-tdesign/src/api/core/auth.ts @@ -1,7 +1,12 @@ import { baseRequestClient, requestClient } from '#/api/request'; +import { + cachePublicKey, + getCachedPublicKey, + hybridEncryptLogin, +} from '#/utils/crypto'; export namespace AuthApi { - /** 登录接口参数 */ + /** 登录接口参数(明文账号密码仅在前端加密流程内消费,不会直接出网) */ export interface LoginParams { password?: string; username?: string; @@ -16,13 +21,53 @@ export namespace AuthApi { } } +// 公钥拉取并发去重:同一时刻只发一次请求。 +let publicKeyRequest: null | Promise = null; + /** - * 登录 + * 获取登录加密公钥(RSA 公钥 PEM,公开接口免鉴权),带 5 分钟内存缓存。 + * baseRequestClient 为 raw 模式(axios response),信封 {code,message,data}。 + */ +export async function getPublicKeyApi(): Promise { + const cached = getCachedPublicKey(); + if (cached) { + return cached; + } + if (!publicKeyRequest) { + publicKeyRequest = baseRequestClient + .post<{ + data: { code: number; data: { publicKey: string }; message: string }; + }>('/api/service/admin/system/auth/public-key') + .then((resp) => { + const publicKey = resp.data?.data?.publicKey; + if (!publicKey) { + throw new Error('获取登录加密公钥失败:响应缺少 publicKey'); + } + cachePublicKey(publicKey); + return publicKey; + }) + .finally(() => { + publicKeyRequest = null; + }); + } + return publicKeyRequest; +} + +/** + * 登录:密码采用「RSA + AES-GCM」混合加密传输(对称+非对称结合)。 + * 前端随机 AES 密钥加密 {username,password,ts},再用 RSA 公钥加密 AES 密钥, + * 提交 encryptedKey / encryptedData,网络传输全程无明文密码。 */ export async function loginApi(data: AuthApi.LoginParams) { + const publicKey = await getPublicKeyApi(); + const { encryptedKey, encryptedData } = await hybridEncryptLogin( + publicKey, + data.username ?? '', + data.password ?? '', + ); return requestClient.post( '/api/service/admin/system/auth/login', - data, + { encryptedKey, encryptedData }, ); } diff --git a/apps/web-tdesign/src/api/system.ts b/apps/web-tdesign/src/api/system.ts index bc5cfe5..a11940c 100644 --- a/apps/web-tdesign/src/api/system.ts +++ b/apps/web-tdesign/src/api/system.ts @@ -1,6 +1,22 @@ import { requestClient } from '#/api/request'; +import { getCachedPublicKey, hybridEncryptField } from '#/utils/crypto'; // ---------- 用户管理(/api/service/admin/admin) ---------- + +/** + * 加密单值字段(创建/重置密码等):拉取公钥(带缓存)+ RSA+AES 混合加密。 + * 返回 {encryptedKey, encryptedData},网络传输全程无明文密码。 + */ +async function encryptField(value: string) { + let publicKey = getCachedPublicKey(); + if (!publicKey) { + const resp = await requestClient.post<{ publicKey: string }>( + '/api/service/admin/system/auth/public-key', + ); + publicKey = resp.publicKey; + } + return hybridEncryptField(publicKey, value); +} export interface AdminItem { id: number; username: string; @@ -29,7 +45,7 @@ export function getAdminList(data: { ); } -export function createAdmin(data: { +export async function createAdmin(data: { barkDeviceId?: string; nickname: string; password: string; @@ -37,10 +53,13 @@ export function createAdmin(data: { roleIds: number[]; username: string; }) { - return requestClient.post<{ id: number }>( - '/api/service/admin/admin/create', - data, - ); + // 密码加密传输(RSA+AES 混合加密),其余字段明文。 + const encrypted = await encryptField(data.password); + return requestClient.post<{ id: number }>('/api/service/admin/admin/create', { + ...data, + password: undefined, + ...encrypted, + }); } export function updateAdmin( @@ -56,9 +75,11 @@ export function updateAdmin( return requestClient.post(`/api/service/admin/admin/update/${id}`, data); } -export function resetAdminPassword(id: number, password: string) { +export async function resetAdminPassword(id: number, password: string) { + // 密码加密传输(RSA+AES 混合加密)。 + const encrypted = await encryptField(password); return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, { - password, + ...encrypted, }); } @@ -106,7 +127,7 @@ export function createRole(data: { export function updateRole( id: number, - data: { menuIds: number[]; name: string; status: number; }, + data: { menuIds: number[]; name: string; status: number }, ) { return requestClient.post( `/api/service/admin/system/role/update/${id}`, diff --git a/apps/web-tdesign/src/utils/crypto.ts b/apps/web-tdesign/src/utils/crypto.ts new file mode 100644 index 0000000..540959c --- /dev/null +++ b/apps/web-tdesign/src/utils/crypto.ts @@ -0,0 +1,157 @@ +/** + * 登录密码「RSA + AES-GCM」混合加密工具(基于浏览器原生 Web Crypto API,零依赖)。 + * + * 加密流程(对称+非对称结合): + * 1. 随机生成 AES-256 会话密钥(对称加密,负责加密实际载荷); + * 2. AES-GCM 加密 {username, password, ts},输出 base64(nonce || ciphertext); + * 3. 用后端 RSA 公钥(RSA-OAEP/SHA-256,非对称加密)加密 AES 密钥,输出 base64。 + * + * 注意:Web Crypto 仅在安全上下文可用(https 或 localhost / 127.0.0.1)。 + * 若以 http://IP 访问开发服务器,crypto.subtle 不可用,登录会给出明确错误提示。 + */ + +/** 混合加密后的登录请求体 */ +export interface EncryptedLoginPayload { + /** RSA-OAEP 加密的 AES-256 会话密钥(base64) */ + encryptedKey: string; + /** AES-GCM 加密的明文载荷(base64,nonce 12B 前置) */ + encryptedData: string; +} + +/** AES-GCM 推荐随机数长度(字节) */ +const AES_NONCE_SIZE = 12; +/** 公钥内存缓存有效期(毫秒),避免每次登录都拉取 */ +const PUBKEY_TTL = 5 * 60 * 1000; + +let cachedPubKey: null | { expireAt: number; pem: string } = null; + +/** 缓存 RSA 公钥(5 分钟有效) */ +export function cachePublicKey(pem: string): void { + cachedPubKey = { pem, expireAt: Date.now() + PUBKEY_TTL }; +} + +/** 取缓存的公钥,过期返回 null */ +export function getCachedPublicKey(): null | string { + if (cachedPubKey && Date.now() < cachedPubKey.expireAt) { + return cachedPubKey.pem; + } + return null; +} + +/** Uint8Array → base64(分段处理避免大数组栈溢出) */ +function bytesToBase64(bytes: Uint8Array): string { + let bin = ''; + const chunk = 0x80_00; + for (let i = 0; i < bytes.length; i += chunk) { + bin += String.fromCodePoint(...bytes.subarray(i, i + chunk)); + } + return btoa(bin); +} + +/** base64 → Uint8Array */ +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + const code = bin.codePointAt(i); + bytes[i] = code ?? 0; + } + return bytes; +} + +/** 拼接两个字节数组 */ +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +/** 解析 PEM 公钥为 Web Crypto CryptoKey(SPKI / RSA-OAEP / SHA-256) */ +async function importPublicKey(pem: string): Promise { + const pemBody = pem + .replaceAll('-----BEGIN PUBLIC KEY-----', '') + .replaceAll('-----END PUBLIC KEY-----', '') + .replaceAll(/\s+/g, ''); + const der = base64ToBytes(pemBody); + return crypto.subtle.importKey( + 'spki', + der.buffer as ArrayBuffer, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'], + ); +} + +/** + * 混合加密核心实现:RSA 公钥加密随机 AES 密钥 + AES-GCM 加密 JSON 载荷。 + * @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串) + * @param payloadObj 需加密的明文对象(含 ts 毫秒时间戳,后端做防重放校验) + * @returns { encryptedKey, encryptedData } 可直接提交的密文 + */ +async function hybridEncrypt( + publicKeyPem: string, + payloadObj: Record, +): Promise { + if (!globalThis.crypto?.subtle) { + throw new Error( + '当前环境不支持 Web Crypto(请使用 https 或 localhost 访问)', + ); + } + // 1. 随机生成 AES-256 会话密钥并导出原始字节(供 RSA 加密) + const aesKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'], + ); + const aesRaw = new Uint8Array(await crypto.subtle.exportKey('raw', aesKey)); + + // 2. AES-GCM 加密载荷:密文结构 = nonce(12B) || ciphertext,GCM 自带完整性校验 + const nonce = crypto.getRandomValues(new Uint8Array(AES_NONCE_SIZE)); + const payload = new TextEncoder().encode(JSON.stringify(payloadObj)); + const cipher = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + aesKey, + payload, + ), + ); + const encryptedData = bytesToBase64(concatBytes(nonce, cipher)); + + // 3. RSA-OAEP(SHA-256) 公钥加密 AES 密钥 + const publicKey = await importPublicKey(publicKeyPem); + const encKey = new Uint8Array( + await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, aesRaw), + ); + + return { encryptedKey: bytesToBase64(encKey), encryptedData }; +} + +/** + * 混合加密登录载荷:RSA 公钥加密随机 AES 密钥 + AES-GCM 加密明文。 + * @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串) + * @param username 登录用户名(明文,仅存在于加密载荷中) + * @param password 登录密码(明文,仅存在于加密载荷中) + * @returns 可直接提交登录接口的密文请求体 + */ +export async function hybridEncryptLogin( + publicKeyPem: string, + username: string, + password: string, +): Promise { + return hybridEncrypt(publicKeyPem, { username, password, ts: Date.now() }); +} + +/** + * 混合加密单个字段(创建管理员/重置密码等场景复用): + * 载荷 JSON 结构 {"value":"...","ts":...},与后端 DecryptField 对应。 + * @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串) + * @param value 需加密的字段值(如新密码) + * @returns 可直接提交的密文请求体 + */ +export async function hybridEncryptField( + publicKeyPem: string, + value: string, +): Promise { + return hybridEncrypt(publicKeyPem, { value, ts: Date.now() }); +}