feat(other): 登录与密码管理混合加密传输

- 新增 utils/crypto.ts(Web Crypto 零依赖):RSA-OAEP + AES-256-GCM 混合加密
- loginApi 先拉公钥再加密用户名密码,网络传输无明文密码
- createAdmin/resetAdminPassword 密码走加密字段(复用公钥缓存)
This commit is contained in:
夏犀麟 2026-08-27 23:45:03 +08:00
parent 7e4bfee095
commit a1bddc348d
4 changed files with 236 additions and 11 deletions

View File

@ -1,6 +1,8 @@
# admin.xpcool.com 变更记录 # admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式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.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 通过 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 便于联调 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 便于联调

View File

@ -1,7 +1,12 @@
import { baseRequestClient, requestClient } from '#/api/request'; import { baseRequestClient, requestClient } from '#/api/request';
import {
cachePublicKey,
getCachedPublicKey,
hybridEncryptLogin,
} from '#/utils/crypto';
export namespace AuthApi { export namespace AuthApi {
/** 登录接口参数 */ /** 登录接口参数(明文账号密码仅在前端加密流程内消费,不会直接出网) */
export interface LoginParams { export interface LoginParams {
password?: string; password?: string;
username?: string; username?: string;
@ -16,13 +21,53 @@ export namespace AuthApi {
} }
} }
// 公钥拉取并发去重:同一时刻只发一次请求。
let publicKeyRequest: null | Promise<string> = null;
/** /**
* * RSA PEM 5
* baseRequestClient raw axios response {code,message,data}
*/
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;
}
/**
* RSA + AES-GCM+
* AES {username,password,ts} RSA AES
* encryptedKey / encryptedData
*/ */
export async function loginApi(data: AuthApi.LoginParams) { export async function loginApi(data: AuthApi.LoginParams) {
const publicKey = await getPublicKeyApi();
const { encryptedKey, encryptedData } = await hybridEncryptLogin(
publicKey,
data.username ?? '',
data.password ?? '',
);
return requestClient.post<AuthApi.LoginResult>( return requestClient.post<AuthApi.LoginResult>(
'/api/service/admin/system/auth/login', '/api/service/admin/system/auth/login',
data, { encryptedKey, encryptedData },
); );
} }

View File

@ -1,6 +1,22 @@
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
import { getCachedPublicKey, hybridEncryptField } from '#/utils/crypto';
// ---------- 用户管理(/api/service/admin/admin ---------- // ---------- 用户管理(/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 { export interface AdminItem {
id: number; id: number;
username: string; username: string;
@ -29,7 +45,7 @@ export function getAdminList(data: {
); );
} }
export function createAdmin(data: { export async function createAdmin(data: {
barkDeviceId?: string; barkDeviceId?: string;
nickname: string; nickname: string;
password: string; password: string;
@ -37,10 +53,13 @@ export function createAdmin(data: {
roleIds: number[]; roleIds: number[];
username: string; username: string;
}) { }) {
return requestClient.post<{ id: number }>( // 密码加密传输RSA+AES 混合加密),其余字段明文。
'/api/service/admin/admin/create', const encrypted = await encryptField(data.password);
data, return requestClient.post<{ id: number }>('/api/service/admin/admin/create', {
); ...data,
password: undefined,
...encrypted,
});
} }
export function updateAdmin( export function updateAdmin(
@ -56,9 +75,11 @@ export function updateAdmin(
return requestClient.post(`/api/service/admin/admin/update/${id}`, data); 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}`, { return requestClient.post(`/api/service/admin/admin/resetPwd/${id}`, {
password, ...encrypted,
}); });
} }
@ -106,7 +127,7 @@ export function createRole(data: {
export function updateRole( export function updateRole(
id: number, id: number,
data: { menuIds: number[]; name: string; status: number; }, data: { menuIds: number[]; name: string; status: number },
) { ) {
return requestClient.post( return requestClient.post(
`/api/service/admin/system/role/update/${id}`, `/api/service/admin/system/role/update/${id}`,

View File

@ -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 加密的明文载荷base64nonce 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 CryptoKeySPKI / RSA-OAEP / SHA-256 */
async function importPublicKey(pem: string): Promise<CryptoKey> {
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<string, unknown>,
): Promise<EncryptedLoginPayload> {
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) || ciphertextGCM 自带完整性校验
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<EncryptedLoginPayload> {
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<EncryptedLoginPayload> {
return hybridEncrypt(publicKeyPem, { value, ts: Date.now() });
}