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:
parent
a1bddc348d
commit
f8463e9f84
@ -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 通过
|
||||
|
||||
@ -14,3 +14,6 @@ VITE_DEVTOOLS=false
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 是否启用全量请求/响应加密(生产 true:所有接口 body/响应整体加密,public-key 豁免;开发 false:仅加密密码字段)
|
||||
VITE_GLOB_API_ENCRYPT=false
|
||||
|
||||
@ -17,3 +17,6 @@ VITE_INJECT_APP_LOADING=true
|
||||
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 全量请求/响应加密(与后端 encrypt.fullBody=true 配套)
|
||||
VITE_GLOB_API_ENCRYPT=true
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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) || 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<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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user