feat(other): 生产全量请求/响应加密拦截器

- crypto.ts 增 encryptPayload/decryptPayload(整体加密 + 会话密钥响应解密)
- request.ts 增 installCryptoInterceptors:请求整体加密、响应解密最先注册,
  requestClient 与 baseRequestClient 均生效,public-key 豁免
- auth/system 全量模式下直接提交明文密码由拦截器整体加密
- .env.production 开 VITE_GLOB_API_ENCRYPT=true(开发 false 保持仅密码加密)
This commit is contained in:
夏犀麟 2026-08-28 00:17:55 +08:00
parent a1bddc348d
commit f8463e9f84
7 changed files with 220 additions and 61 deletions

View File

@ -1,6 +1,8 @@
# admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | CHG | 全量请求/响应加密(生产 VITE_GLOB_API_ENCRYPT=true①crypto.ts 增 encryptPayload整体加密任意对象返回 aesKeyB64 会话密钥)+ decryptPayloadAES-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.tsgetPublicKeyApi并发去重+ loginApi 先拉公钥再加密 username/password 提交网络传输无明文密码。③api/system.tscreateAdmin/resetAdminPassword 的 password 走 hybridEncryptField 加密encryptField 复用公钥缓存。④注意Web Crypto 仅安全上下文可用https/localhost/127.0.0.1http://IP 访问会明确报错。typecheck + oxlint 全绿
2026-08-27 | CHG | 用户管理改造(页面标题改"用户管理",表单/列表加 Bark设备ID、pushplus token+ 通知模块 3 页(规则:渠道多选+用户多选+标题/内容模板+测试按钮;渠道:启用+JSON 配置;日志:事件/渠道/结果/时间筛选)+ 自动任务 2 页任务列表cron 编辑/启停/手动执行;运行日志:按任务筛选+耗时/摘要/错误api/notice.ts + autoJob.ts + system.ts 扩展AdminItem 加渠道字段typecheck 通过

View File

@ -14,3 +14,6 @@ VITE_DEVTOOLS=false
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
# 是否启用全量请求/响应加密(生产 true所有接口 body/响应整体加密public-key 豁免;开发 false仅加密密码字段
VITE_GLOB_API_ENCRYPT=false

View File

@ -17,3 +17,6 @@ VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true
# 全量请求/响应加密(与后端 encrypt.fullBody=true 配套)
VITE_GLOB_API_ENCRYPT=true

View File

@ -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<string> = null;
/**
* RSA PEM 5
* baseRequestClient raw axios response {code,message,data}
* RSA PEM
*/
export async function getPublicKeyApi(): Promise<string> {
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<AuthApi.LoginResult>(
'/api/service/admin/system/auth/login',
{ username: data.username, password: data.password },
);
}
const publicKey = await getPublicKeyApi();
const { encryptedKey, encryptedData } = await hybridEncryptLogin(
publicKey,

View File

@ -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<string> = null;
/**
* RSA PEM 5 +
* public-key
*/
export async function fetchPublicKeyForCrypto(): Promise<string> {
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<string, unknown>,
);
(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);

View File

@ -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,
});
}

View File

@ -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<CryptoKey> {
* 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<string, unknown>,
): Promise<EncryptedLoginPayload> {
): Promise<EncryptedPayload> {
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) || ciphertextGCM 自带完整性校验
// 2. AES-GCM 加密载荷:密文结构 = nonce(12B) || ciphertext||tagGCM 自带完整性校验
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<Record<string, unknown>> {
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<string, unknown>;
}
/**
@ -139,7 +186,12 @@ export async function hybridEncryptLogin(
username: string,
password: string,
): Promise<EncryptedLoginPayload> {
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<EncryptedLoginPayload> {
return hybridEncrypt(publicKeyPem, { value, ts: Date.now() });
const { encryptedKey, encryptedData } = await encryptPayload(publicKeyPem, {
value,
ts: Date.now(),
});
return { encryptedKey, encryptedData };
}