feat(other): 登录与密码管理混合加密传输
- 新增 utils/crypto.ts(Web Crypto 零依赖):RSA-OAEP + AES-256-GCM 混合加密 - loginApi 先拉公钥再加密用户名密码,网络传输无明文密码 - createAdmin/resetAdminPassword 密码走加密字段(复用公钥缓存)
This commit is contained in:
parent
7e4bfee095
commit
a1bddc348d
@ -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 便于联调
|
||||
|
||||
|
||||
@ -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<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) {
|
||||
const publicKey = await getPublicKeyApi();
|
||||
const { encryptedKey, encryptedData } = await hybridEncryptLogin(
|
||||
publicKey,
|
||||
data.username ?? '',
|
||||
data.password ?? '',
|
||||
);
|
||||
return requestClient.post<AuthApi.LoginResult>(
|
||||
'/api/service/admin/system/auth/login',
|
||||
data,
|
||||
{ encryptedKey, encryptedData },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}`,
|
||||
|
||||
157
apps/web-tdesign/src/utils/crypto.ts
Normal file
157
apps/web-tdesign/src/utils/crypto.ts
Normal 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 加密的明文载荷(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<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) || 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<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() });
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user