From f8463e9f84e9a44e563b3badff1de8e72448ee73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Fri, 28 Aug 2026 00:17:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(other):=20=E7=94=9F=E4=BA=A7=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E8=AF=B7=E6=B1=82/=E5=93=8D=E5=BA=94=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E6=8B=A6=E6=88=AA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crypto.ts 增 encryptPayload/decryptPayload(整体加密 + 会话密钥响应解密) - request.ts 增 installCryptoInterceptors:请求整体加密、响应解密最先注册, requestClient 与 baseRequestClient 均生效,public-key 豁免 - auth/system 全量模式下直接提交明文密码由拦截器整体加密 - .env.production 开 VITE_GLOB_API_ENCRYPT=true(开发 false 保持仅密码加密) --- .workbuddy/memory/CHANGELOG.md | 2 + apps/web-tdesign/.env.development | 3 + apps/web-tdesign/.env.production | 3 + apps/web-tdesign/src/api/core/auth.ts | 53 +++++------- apps/web-tdesign/src/api/request.ts | 112 +++++++++++++++++++++++++- apps/web-tdesign/src/api/system.ts | 38 ++++----- apps/web-tdesign/src/utils/crypto.ts | 70 ++++++++++++++-- 7 files changed, 220 insertions(+), 61 deletions(-) diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 9998d3b..9e33a4d 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 | 全量请求/响应加密(生产 VITE_GLOB_API_ENCRYPT=true):①crypto.ts 增 encryptPayload(整体加密任意对象,返回 aesKeyB64 会话密钥)+ decryptPayload(AES-GCM 解密后端响应,密文=nonce||ciphertext||tag);②request.ts 增 installCryptoInterceptors——请求拦截器将整个 JSON body 混合加密(public-key 豁免、FormData/无 body 跳过)、响应解密拦截器最先注册(axios 响应拦截器按注册顺序执行)还原 {code,message,data},requestClient 与 baseRequestClient 均安装;apiFullBodyEncrypt/fetchPublicKeyForCrypto 导出;③auth.ts loginApi、system.ts createAdmin/resetAdminPassword 全量模式下直接传明文密码由拦截器整体加密(开发模式仍走密码字段混合加密);④.env.production VITE_GLOB_API_ENCRYPT=true / .env.development=false。⚠️TS 坑:Uint8Array.subarray 返回 ArrayBufferLike 泛型不满足 BufferSource,需 new Uint8Array() 拷贝;axios 类型从 @vben/request re-export 导入(web-tdesign 未声明 axios 依赖)。typecheck+oxlint 全绿,全量/仅密码两种模式 E2E 均通过 + 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 通过 diff --git a/apps/web-tdesign/.env.development b/apps/web-tdesign/.env.development index 08aa2ad..66d9b27 100644 --- a/apps/web-tdesign/.env.development +++ b/apps/web-tdesign/.env.development @@ -14,3 +14,6 @@ VITE_DEVTOOLS=false # 是否注入全局loading VITE_INJECT_APP_LOADING=true + +# 是否启用全量请求/响应加密(生产 true:所有接口 body/响应整体加密,public-key 豁免;开发 false:仅加密密码字段) +VITE_GLOB_API_ENCRYPT=false diff --git a/apps/web-tdesign/.env.production b/apps/web-tdesign/.env.production index f61880e..db08ac9 100644 --- a/apps/web-tdesign/.env.production +++ b/apps/web-tdesign/.env.production @@ -17,3 +17,6 @@ VITE_INJECT_APP_LOADING=true # 打包后是否生成dist.zip VITE_ARCHIVER=true + +# 全量请求/响应加密(与后端 encrypt.fullBody=true 配套) +VITE_GLOB_API_ENCRYPT=true diff --git a/apps/web-tdesign/src/api/core/auth.ts b/apps/web-tdesign/src/api/core/auth.ts index 80b34bd..0585a1e 100644 --- a/apps/web-tdesign/src/api/core/auth.ts +++ b/apps/web-tdesign/src/api/core/auth.ts @@ -1,9 +1,10 @@ -import { baseRequestClient, requestClient } from '#/api/request'; import { - cachePublicKey, - getCachedPublicKey, - hybridEncryptLogin, -} from '#/utils/crypto'; + apiFullBodyEncrypt, + baseRequestClient, + fetchPublicKeyForCrypto, + requestClient, +} from '#/api/request'; +import { hybridEncryptLogin } from '#/utils/crypto'; export namespace AuthApi { /** 登录接口参数(明文账号密码仅在前端加密流程内消费,不会直接出网) */ @@ -21,44 +22,26 @@ export namespace AuthApi { } } -// 公钥拉取并发去重:同一时刻只发一次请求。 -let publicKeyRequest: null | Promise = null; - /** - * 获取登录加密公钥(RSA 公钥 PEM,公开接口免鉴权),带 5 分钟内存缓存。 - * baseRequestClient 为 raw 模式(axios response),信封 {code,message,data}。 + * 获取登录加密公钥(RSA 公钥 PEM,公开接口免鉴权),复用请求层缓存与并发去重。 */ 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; + return fetchPublicKeyForCrypto(); } /** - * 登录:密码采用「RSA + AES-GCM」混合加密传输(对称+非对称结合)。 - * 前端随机 AES 密钥加密 {username,password,ts},再用 RSA 公钥加密 AES 密钥, - * 提交 encryptedKey / encryptedData,网络传输全程无明文密码。 + * 登录(密码加密传输,对称+非对称结合): + * - 生产(全量加密):提交明文 {username,password},由请求拦截器整体加密 body; + * - 开发(仅密码加密):前端随机 AES 密钥加密 {username,password,ts},RSA 公钥加密 AES 密钥, + * 提交 {encryptedKey, encryptedData}。两种模式网络传输均无明文密码。 */ export async function loginApi(data: AuthApi.LoginParams) { + if (apiFullBodyEncrypt) { + return requestClient.post( + '/api/service/admin/system/auth/login', + { username: data.username, password: data.password }, + ); + } const publicKey = await getPublicKeyApi(); const { encryptedKey, encryptedData } = await hybridEncryptLogin( publicKey, diff --git a/apps/web-tdesign/src/api/request.ts b/apps/web-tdesign/src/api/request.ts index 4c00830..5c52d97 100644 --- a/apps/web-tdesign/src/api/request.ts +++ b/apps/web-tdesign/src/api/request.ts @@ -1,7 +1,11 @@ /** * 该文件可自行根据业务逻辑进行调整 */ -import type { RequestClientOptions } from '@vben/request'; +import type { + AxiosResponse, + InternalAxiosRequestConfig, + RequestClientOptions, +} from '@vben/request'; import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; @@ -15,17 +19,121 @@ import { useAccessStore } from '@vben/stores'; import { message } from '#/adapter/tdesign'; import { useAuthStore } from '#/store'; +import { + cachePublicKey, + decryptPayload, + encryptPayload, + getCachedPublicKey, +} from '#/utils/crypto'; import { refreshTokenApi } from './core'; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); +/** + * 是否启用全量请求/响应加密(生产环境 VITE_GLOB_API_ENCRYPT=true)。 + * 开发环境为 false:仅加密密码字段,便于抓包联调。 + */ +export const apiFullBodyEncrypt = + import.meta.env.VITE_GLOB_API_ENCRYPT === 'true'; + +/** 全量加密豁免端点(path 片段匹配),此类请求明文收发 */ +const PLAIN_ROUTE_FRAGMENTS = ['/public-key']; + +// 公钥拉取并发去重:同一时刻只发一次请求。 +let publicKeyPromise: null | Promise = null; + +/** + * 获取登录加密公钥(RSA 公钥 PEM),带 5 分钟内存缓存 + 并发去重。 + * 供登录密码加密与全量加密拦截器共用;public-key 本身为明文豁免端点。 + */ +export async function fetchPublicKeyForCrypto(): Promise { + const cached = getCachedPublicKey(); + if (cached) { + return cached; + } + if (!publicKeyPromise) { + publicKeyPromise = 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(() => { + publicKeyPromise = null; + }); + } + return publicKeyPromise; +} + +/** 请求配置扩展:携带本次请求的 AES 会话密钥,供响应解密使用 */ +interface CryptoRequestConfig extends InternalAxiosRequestConfig { + __aesKey?: string; +} + +/** + * 安装全量加解密拦截器(生产模式): + * - 请求:除豁免端点外,将整个 JSON body 混合加密为 {encryptedKey, encryptedData}, + * 并暂存 AES 会话密钥到请求配置; + * - 响应:用同一会话密钥解密 {encryptedData},还原为标准响应信封后交下游拦截器。 + */ +function installCryptoInterceptors(client: RequestClient) { + // 响应解密:必须最先注册(axios 响应拦截器按注册顺序执行),在解包/鉴权/报错之前还原明文。 + client.addResponseInterceptor({ + fulfilled: async (response: AxiosResponse) => { + if (!apiFullBodyEncrypt) { + return response; + } + const cfg = response.config as CryptoRequestConfig; + const data = response.data as { encryptedData?: string }; + if (cfg.__aesKey && data && typeof data.encryptedData === 'string') { + const plain = await decryptPayload(cfg.__aesKey, data.encryptedData); + response.data = plain; + } + return response; + }, + }); + + // 请求加密:在发送前整体加密 body(豁免端点与无 body 请求跳过)。 + client.addRequestInterceptor({ + fulfilled: async (config) => { + if (!apiFullBodyEncrypt) { + return config; + } + const url = config.url ?? ''; + if (PLAIN_ROUTE_FRAGMENTS.some((f) => url.includes(f))) { + return config; + } + if (config.data === undefined || config.data instanceof FormData) { + return config; + } + const publicKey = await fetchPublicKeyForCrypto(); + const { encryptedKey, encryptedData, aesKeyB64 } = await encryptPayload( + publicKey, + config.data as Record, + ); + (config as CryptoRequestConfig).__aesKey = aesKeyB64; + config.data = { encryptedKey, encryptedData }; + return config; + }, + }); +} + function createRequestClient(baseURL: string, options?: RequestClientOptions) { const client = new RequestClient({ ...options, baseURL, }); + // 全量加解密拦截器(生产模式):响应解密须最先注册。 + installCryptoInterceptors(client); + /** * 重新认证逻辑 */ @@ -117,3 +225,5 @@ export const requestClient = createRequestClient(apiURL, { }); export const baseRequestClient = new RequestClient({ baseURL: apiURL }); +// 登录/刷新/登出等走 baseRequestClient 的请求同样需要全量加密(生产模式)。 +installCryptoInterceptors(baseRequestClient); diff --git a/apps/web-tdesign/src/api/system.ts b/apps/web-tdesign/src/api/system.ts index a11940c..3809269 100644 --- a/apps/web-tdesign/src/api/system.ts +++ b/apps/web-tdesign/src/api/system.ts @@ -1,21 +1,23 @@ -import { requestClient } from '#/api/request'; -import { getCachedPublicKey, hybridEncryptField } from '#/utils/crypto'; +import { + apiFullBodyEncrypt, + fetchPublicKeyForCrypto, + requestClient, +} from '#/api/request'; +import { hybridEncryptField } from '#/utils/crypto'; // ---------- 用户管理(/api/service/admin/admin) ---------- /** - * 加密单值字段(创建/重置密码等):拉取公钥(带缓存)+ RSA+AES 混合加密。 - * 返回 {encryptedKey, encryptedData},网络传输全程无明文密码。 + * 准备密码字段: + * - 生产(全量加密):返回明文 password,由请求拦截器整体加密 body; + * - 开发(仅密码加密):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; +async function preparePasswordField(password: string) { + if (apiFullBodyEncrypt) { + return { password }; } - return hybridEncryptField(publicKey, value); + const publicKey = await fetchPublicKeyForCrypto(); + return hybridEncryptField(publicKey, password); } export interface AdminItem { id: number; @@ -53,12 +55,12 @@ export async function createAdmin(data: { roleIds: number[]; username: string; }) { - // 密码加密传输(RSA+AES 混合加密),其余字段明文。 - const encrypted = await encryptField(data.password); + // 密码加密传输:生产整体加密 / 开发仅密码字段加密,其余字段明文。 + const pwdField = await preparePasswordField(data.password); return requestClient.post<{ id: number }>('/api/service/admin/admin/create', { ...data, password: undefined, - ...encrypted, + ...pwdField, }); } @@ -76,10 +78,10 @@ export function updateAdmin( } export async function resetAdminPassword(id: number, password: string) { - // 密码加密传输(RSA+AES 混合加密)。 - const encrypted = await encryptField(password); + // 密码加密传输:生产整体加密 / 开发仅密码字段加密。 + const pwdField = await preparePasswordField(password); return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, { - ...encrypted, + ...pwdField, }); } diff --git a/apps/web-tdesign/src/utils/crypto.ts b/apps/web-tdesign/src/utils/crypto.ts index 540959c..f6ca004 100644 --- a/apps/web-tdesign/src/utils/crypto.ts +++ b/apps/web-tdesign/src/utils/crypto.ts @@ -18,6 +18,12 @@ export interface EncryptedLoginPayload { encryptedData: string; } +/** 全量加密载荷:附带回传用会话密钥(base64 原始字节),供响应解密使用 */ +export interface EncryptedPayload extends EncryptedLoginPayload { + /** AES-256 会话密钥原始字节(base64),仅前端持有,用于解密后端响应 */ + aesKeyB64: string; +} + /** AES-GCM 推荐随机数长度(字节) */ const AES_NONCE_SIZE = 12; /** 公钥内存缓存有效期(毫秒),避免每次登录都拉取 */ @@ -87,12 +93,12 @@ async function importPublicKey(pem: string): Promise { * 混合加密核心实现:RSA 公钥加密随机 AES 密钥 + AES-GCM 加密 JSON 载荷。 * @param publicKeyPem 后端下发的 RSA 公钥(PEM 字符串) * @param payloadObj 需加密的明文对象(含 ts 毫秒时间戳,后端做防重放校验) - * @returns { encryptedKey, encryptedData } 可直接提交的密文 + * @returns { encryptedKey, encryptedData, aesKeyB64 } 密文 + 会话密钥(响应解密用) */ -async function hybridEncrypt( +export async function encryptPayload( publicKeyPem: string, payloadObj: Record, -): Promise { +): Promise { if (!globalThis.crypto?.subtle) { throw new Error( '当前环境不支持 Web Crypto(请使用 https 或 localhost 访问)', @@ -106,7 +112,7 @@ async function hybridEncrypt( ); const aesRaw = new Uint8Array(await crypto.subtle.exportKey('raw', aesKey)); - // 2. AES-GCM 加密载荷:密文结构 = nonce(12B) || ciphertext,GCM 自带完整性校验 + // 2. AES-GCM 加密载荷:密文结构 = nonce(12B) || ciphertext||tag,GCM 自带完整性校验 const nonce = crypto.getRandomValues(new Uint8Array(AES_NONCE_SIZE)); const payload = new TextEncoder().encode(JSON.stringify(payloadObj)); const cipher = new Uint8Array( @@ -124,7 +130,48 @@ async function hybridEncrypt( await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, aesRaw), ); - return { encryptedKey: bytesToBase64(encKey), encryptedData }; + return { + encryptedKey: bytesToBase64(encKey), + encryptedData, + aesKeyB64: bytesToBase64(aesRaw), + }; +} + +/** + * 解密后端响应密文(AES-256-GCM):密文结构 = base64(nonce(12B) || ciphertext||tag)。 + * @param aesKeyB64 请求加密时生成的会话密钥(base64 原始字节) + * @param encryptedDataB64 后端返回的密文 + * @returns 解密后的 JSON 对象(统一响应信封 {code,message,data}) + */ +export async function decryptPayload( + aesKeyB64: string, + encryptedDataB64: string, +): Promise> { + if (!globalThis.crypto?.subtle) { + throw new Error( + '当前环境不支持 Web Crypto(请使用 https 或 localhost 访问)', + ); + } + const aesKey = await crypto.subtle.importKey( + 'raw', + base64ToBytes(aesKeyB64).buffer as ArrayBuffer, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'], + ); + const data = base64ToBytes(encryptedDataB64); + if (data.length <= AES_NONCE_SIZE) { + throw new Error('响应密文格式非法'); + } + // 拷贝为独立 ArrayBuffer 视图(subarray 可能携带 SharedArrayBuffer 泛型,不满足 BufferSource) + const nonce = new Uint8Array(data.subarray(0, AES_NONCE_SIZE)); + const cipher = new Uint8Array(data.subarray(AES_NONCE_SIZE)); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce }, + aesKey, + cipher, + ); + return JSON.parse(new TextDecoder().decode(plain)) as Record; } /** @@ -139,7 +186,12 @@ export async function hybridEncryptLogin( username: string, password: string, ): Promise { - return hybridEncrypt(publicKeyPem, { username, password, ts: Date.now() }); + const { encryptedKey, encryptedData } = await encryptPayload(publicKeyPem, { + username, + password, + ts: Date.now(), + }); + return { encryptedKey, encryptedData }; } /** @@ -153,5 +205,9 @@ export async function hybridEncryptField( publicKeyPem: string, value: string, ): Promise { - return hybridEncrypt(publicKeyPem, { value, ts: Date.now() }); + const { encryptedKey, encryptedData } = await encryptPayload(publicKeyPem, { + value, + ts: Date.now(), + }); + return { encryptedKey, encryptedData }; }