/** * 登录密码加密(RSA)。 * * 公钥从后端 `GET /auth/public-key` 拉取并缓存 —— 这样**轮换密钥不需要重新构建前端**。 * 否则"后端换了密钥、前端还是旧的"会表现为"所有人密码都错",且极难定位。 * * ⚠️ 算法必须与服务端 `RsaCipher` 一致:**RSA/ECB/PKCS1Padding**(jsencrypt 的默认行为)。 * 换成 OAEP 会表现为"密码永远错"。 * * ⚠️ 它不是用来替代 HTTPS 的:唯一目的是**防止明文密码进日志** —— * 网关、Nginx、APM 都会记录请求体,明文一旦落进日志就是永久泄露。 */ import JSEncrypt from 'jsencrypt' // 直接用 http,不经 @/apis/xt —— 否则形成 xt → http → … → xt 的环 import http from '@/utils/http' /** 折叠空白用(提到模块级:每次调用都新建正则纯属浪费,lint 也会拦) */ const WHITESPACE_RE = /\s+/g let cachedKey: string | null = null let inflight: Promise | null = null /** 拉取公钥(并发调用只会真正请求一次) */ export async function fetchPublicKey(): Promise { if (cachedKey) { return cachedKey } if (inflight) { return inflight } inflight = http .get('/auth/public-key') .then((pem) => { cachedKey = pem return pem }) .finally(() => { inflight = null }) return inflight } /** 加密密码,返回 Base64 密文 */ export async function encryptPassword(plain: string): Promise { const pem = await fetchPublicKey() if (typeof pem !== 'string' || !pem.includes('BEGIN PUBLIC KEY')) { // ⚠️ 这一条是**踩过的坑**:`/auth/public-key` 若没走到后端(例如 baseURL 少了 // `/api/v1`,请求落到 SPA 兜底上),拿到的是 **index.html** 而不是公钥。 // 此时 jsencrypt 只会返回 false,界面显示"密码加密失败" —— 排查方向会完全跑偏 // (去查 jsencrypt、去查密钥轮换,而真正的原因是请求 URL)。 // 所以这里把"到底拿到了什么"直接说出来。 throw new Error(`公钥格式不对(拿到 ${typeof pem === 'string' ? `${pem.length} 字节` : typeof pem}:` + `${String(pem).slice(0, 30).replace(WHITESPACE_RE, ' ')}…)`) } const engine = new JSEncrypt() engine.setPublicKey(pem) const cipher = engine.encrypt(plain) if (!cipher) { // jsencrypt 在密钥格式不对时返回 false 而不是抛异常,必须自己判 throw new Error('密码加密失败,请刷新页面重试') } return cipher } /** 密钥轮换后调用 */ export function resetPublicKey(): void { cachedKey = null }