方案二 · 会话密钥改造:前后端实施手册

目标:让前端产物中不再存在任何可用密钥,并让”拿到公开信息即可伪造加密请求”这条路失效。 涉及仓库:ruoyi-office-vben(PC)、dy-ui-admin-uniapp(小程序)、宇擎后端 前置:本文档代码可直接整文件替换。落地顺序见第 6 节,不要跳步。


0. 改动清单总览

前端 · PC 端 ruoyi-office-vben

# 文件 动作 风险
F1 apps/web-antd/.env 删密钥、加 RSA 公钥 低
F2 apps/web-antd/.env.local 新增(不入库) 无
F3 apps/web-antd/.env.example 新增(入库) 无
F4 packages/types/global.d.ts 类型同步 无
F5 packages/@core/base/shared/src/utils/encrypt.ts 整文件替换 中
F6 apps/web-antd/src/api/request.ts 整文件替换 中
F7 apps/web-antd/src/api/core/auth.ts 改 loginApi + 新增协商接口 中
F8 apps/web-antd/src/store/auth.ts logout 补 clearSession 低
F9 apps/web-antd/src/bootstrap.ts 启动时协商(页面刷新场景) 中
F10 apps/web-ele / web-naive / web-tdesign / web-antdv-next 同 F6/F7/F8/F9 中
F11 apps/web-antd/Jenkinsfile 凭据注入 + 产物扫描 低

前端 · 小程序端 dy-ui-admin-uniapp

# 文件 动作
M1 env/.env 删密钥、加 RSA 公钥
M2 src/utils/encrypt.ts 整文件替换(改为模块级会话态)
M3 src/http/interceptor.ts 挂会话头 + 协商兜底
M4 src/api/login.ts 登录前 prepareSession()
M5 src/store/* 登出逻辑 补 clearSession()

后端

# 文件 动作
B1 application.yaml 新增 RSA 私钥、清空固定密钥
B2 EncryptProperties.java 新增 rsaPrivateKey 字段
B3 SessionCryptoUtils.java 新增(RSA 解包 + AES 加解密)
B4 SessionKeyStore.java 新增(Redis 存储)
B5 ApiEncryptFilter.java 改造密钥解析逻辑
B6 AuthController.java 登录成功绑定 + 新增 /session-key
B7 登出/强退逻辑 清理会话密钥

1. 协议契约(前后端必须逐条对齐)

1.1 请求头

头 何时带 含义
X-Api-Encrypt: true 需要加密的请求 声明请求体已加密(沿用现状)
X-Session-Id 所有加密请求 会话标识(uuid,前端生成)
X-Session-Key 仅协商请求(登录、刷新后重新协商) base64(RSA(sessionKey))

1.2 过滤器触发规则(重要)

  1. if (有 X-Session-Key) {
  2. // 协商分支:RSA 私钥解出 K,存入 request attribute,供业务层绑定
  3. // 若同时有 X-Api-Encrypt: true,则用 K 解 body
  4. } else if (有 X-Api-Encrypt: true) {
  5. // 已协商分支:按 X-Session-Id 查 Redis 取 K
  6. // 查不到 → 直接拒绝,不回退固定密钥
  7. // 查到 → 校验归属(owner),再解 body
  8. }

1.3 加密参数(保持不变,避免动后端密码学实现)

项 值
对称算法 AES/ECB/PKCS5Padding(前端 PKCS7,等价)
密钥长度 32 字符 = 32 字节 → AES-256
密钥字符集 仅 ASCII(见 2.5 的坑)
报文编码 Base64,密文直接作 HTTP body
非对称算法 RSA/ECB/PKCS1Padding(必须 PKCS1v1.5,因为小程序 jsencrypt 不支持 OAEP)

2. 前端 · PC 端改造

2.1 F1 apps/web-antd/.env(入库,全部非敏感)

  1. # 应用标题
  2. VITE_APP_TITLE=信息化综合管理平台
  3. # 应用命名空间
  4. VITE_APP_NAMESPACE=yudao-vben-antd
  5. # 是否开启模拟数据
  6. VITE_NITRO_MOCK=false
  7. # 租户开关
  8. VITE_APP_TENANT_ENABLE=true
  9. # 验证码开关
  10. VITE_APP_CAPTCHA_ENABLE=false
  11. # 文档地址开关
  12. VITE_APP_DOCALERT_ENABLE=true
  13. # 百度统计 / GA
  14. VITE_APP_BAIDU_CODE=ede2dc454379d7def7b4363ff72f8824
  15. VITE_APP_GA_ID=
  16. # API 加解密——只保留开关与协议参数,密钥一律不在此文件
  17. VITE_APP_API_ENCRYPT_ENABLE=true
  18. VITE_APP_API_ENCRYPT_HEADER=X-Api-Encrypt
  19. VITE_APP_API_ENCRYPT_ALGORITHM=AES
  20. # 后端 RSA 公钥(公钥可公开,允许入库)
  21. VITE_APP_RSA_PUBLIC_KEY=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...替换为真实公钥

从原文件删除:VITE_APP_STORE_SECURE_KEY、VITE_APP_API_ENCRYPT_REQUEST_KEY、 VITE_APP_API_ENCRYPT_RESPONSE_KEY、VITE_BAIDU_MAP_KEY、VITE_AMAP_KEY、VITE_GOVIEW_URL。

2.2 F2 apps/web-antd/.env.local(不入库,根 .gitignore 已忽略)

  1. # 本地开发专用,禁止提交
  2. VITE_APP_STORE_SECURE_KEY=<本地随机值>
  3. VITE_BAIDU_MAP_KEY=<你的 ak>
  4. VITE_AMAP_KEY=<你的 key>
  5. VITE_GOVIEW_URL='http://127.0.0.1:3000'

2.3 F3 apps/web-antd/.env.example(入库)

  1. # 复制为 .env.local 后填写真实值;本文件禁止填任何真值
  2. VITE_APP_STORE_SECURE_KEY= # 本地随机值 / CI 凭据 web-antd-store-secure-key
  3. VITE_BAIDU_MAP_KEY= # 百度地图控制台(须配域名白名单)
  4. VITE_AMAP_KEY= # 高德控制台(须配域名白名单)

2.4 F4 packages/types/global.d.ts

  1. export interface VbenAdminProAppConfigRaw {
  2. VITE_GLOB_API_URL: string;
  3. VITE_GLOB_AUTH_DINGDING_CLIENT_ID: string;
  4. VITE_GLOB_AUTH_DINGDING_CORP_ID: string;
  5. // API 加解密——只保留开关与协议参数
  6. VITE_APP_API_ENCRYPT_ENABLE: string;
  7. VITE_APP_API_ENCRYPT_HEADER: string;
  8. VITE_APP_API_ENCRYPT_ALGORITHM: string;
  9. // 后端 RSA 公钥(用于包裹会话密钥)
  10. VITE_APP_RSA_PUBLIC_KEY: string;
  11. }

删除 VITE_APP_API_ENCRYPT_REQUEST_KEY / VITE_APP_API_ENCRYPT_RESPONSE_KEY 两行。

2.5 F5 packages/@core/base/shared/src/utils/encrypt.ts(整文件替换)

  1. import CryptoJS from 'crypto-js';
  2. import { JSEncrypt } from 'jsencrypt';
  3. /**
  4. * API 加解密工具(方案二:会话密钥)
  5. *
  6. * 与旧实现的区别:
  7. * - 不再从环境变量读取固定 AES 密钥;
  8. * - 每次会话生成一把随机对称密钥 K,仅存内存,退出登录即销毁;
  9. * - K 用后端 RSA 公钥包裹后通过 X-Session-Key 上送,完成协商;
  10. * - 对称算法 / 填充 / 报文编码保持不变,复用后端既有实现。
  11. */
  12. /** AES 合法密钥长度(字节),对应 AES-128 / AES-192 / AES-256 */
  13. const AES_KEY_BYTES = [16, 24, 32];
  14. /** 会话密钥字符数 = UTF-8 字节数,必须落在 ASCII 区间 */
  15. const SESSION_KEY_LENGTH = 32;
  16. /**
  17. * 会话密钥字符集:**只允许 ASCII**。
  18. * 若混入非 ASCII 字符(含中文),CryptoJS.enc.Utf8.parse 后字节数会膨胀,
  19. * key.length 与 sigBytes 不再相等,AES 会直接抛 Invalid key size。
  20. */
  21. const SESSION_KEY_ALPHABET =
  22. 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  23. /**
  24. * 校验并返回 AES 密钥
  25. * @param key 原始密钥字符串
  26. * @param scene 场景名(加密 / 解密),用于错误提示
  27. */
  28. function assertAesKey(key: string, scene: string): CryptoJS.lib.WordArray {
  29. if (!key) {
  30. throw new Error(`AES ${scene}密钥不能为空`);
  31. }
  32. const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
  33. if (!AES_KEY_BYTES.includes(keyUtf8.sigBytes)) {
  34. throw new Error(
  35. `AES ${scene}密钥长度必须为 16/24/32 字节,当前 ${keyUtf8.sigBytes} 字节(字符数 ${key.length})`,
  36. );
  37. }
  38. return keyUtf8;
  39. }
  40. /** 取随机字节:优先 Web Crypto,回落到 crypto-js 随机源(小程序 / 老环境) */
  41. function randomBytes(len: number): Uint8Array {
  42. const g = globalThis as any;
  43. if (g.crypto?.getRandomValues) {
  44. const arr = new Uint8Array(len);
  45. g.crypto.getRandomValues(arr);
  46. return arr;
  47. }
  48. const words = CryptoJS.lib.WordArray.random(len);
  49. const out = new Uint8Array(len);
  50. for (let i = 0; i < len; i++) {
  51. out[i] = (words.words[i >>> 2] as number) >>> (24 - (i % 4) * 8) & 0xff;
  52. }
  53. return out;
  54. }
  55. /**
  56. * 生成会话对称密钥(32 个 ASCII 字符 ≈ 190 bit 熵)
  57. * 用拒绝采样消除取模偏置。
  58. */
  59. export function generateSessionKey(length = SESSION_KEY_LENGTH): string {
  60. const n = SESSION_KEY_ALPHABET.length; // 62
  61. const limit = Math.floor(256 / n) * n; // 248:>= limit 的字节丢弃
  62. let out = '';
  63. while (out.length < length) {
  64. const bytes = randomBytes(length);
  65. for (let i = 0; i < bytes.length && out.length < length; i++) {
  66. const b = bytes[i] as number;
  67. if (b < limit) {
  68. out += SESSION_KEY_ALPHABET[b % n];
  69. }
  70. }
  71. }
  72. return out;
  73. }
  74. /** 生成会话标识 */
  75. export function generateSessionId(): string {
  76. const g = globalThis as any;
  77. if (g.crypto?.randomUUID) {
  78. return g.crypto.randomUUID();
  79. }
  80. return `${Date.now().toString(36)}-${generateSessionKey(16)}`;
  81. }
  82. /** 用后端 RSA 公钥包裹会话密钥 */
  83. export function wrapSessionKey(rawKey: string, publicKeyPem: string): string {
  84. if (!publicKeyPem) {
  85. throw new Error('VITE_APP_RSA_PUBLIC_KEY 未配置,无法协商会话密钥');
  86. }
  87. const wrapped = RSA.encrypt(rawKey, publicKeyPem);
  88. if (wrapped === false) {
  89. throw new Error('会话密钥包裹失败:RSA 公钥无效');
  90. }
  91. return wrapped;
  92. }
  93. /**
  94. * 判断响应头是否声明了「响应体已加密」
  95. * 必须用 headers.get(name):AxiosHeaders 下标访问大小写敏感,取不到值。
  96. */
  97. export function isEncryptFlaggedHeader(
  98. headers: any,
  99. headerName: string,
  100. ): boolean {
  101. if (!headers || !headerName) {
  102. return false;
  103. }
  104. const raw =
  105. typeof headers.get === 'function'
  106. ? headers.get(headerName)
  107. : (headers[headerName] ?? headers[headerName.toLowerCase()]);
  108. return String(raw) === 'true';
  109. }
  110. /** 判断请求体是否为「不可 JSON 加密」的原始类型 */
  111. export function isNonJsonBody(data: any): boolean {
  112. if (!data || typeof data !== 'object') {
  113. return false;
  114. }
  115. if (typeof FormData !== 'undefined' && data instanceof FormData) {
  116. return true;
  117. }
  118. if (typeof Blob !== 'undefined' && data instanceof Blob) {
  119. return true;
  120. }
  121. if (
  122. typeof ArrayBuffer !== 'undefined' &&
  123. (data instanceof ArrayBuffer || ArrayBuffer.isView(data))
  124. ) {
  125. return true;
  126. }
  127. return (
  128. typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams
  129. );
  130. }
  131. /** 删除请求头(兼容 AxiosHeaders 与普通对象) */
  132. export function removeHeader(headers: any, name: string): void {
  133. if (!headers) {
  134. return;
  135. }
  136. if (typeof headers.delete === 'function') {
  137. headers.delete(name);
  138. } else {
  139. delete headers[name];
  140. }
  141. }
  142. /** AES 工具类 */
  143. export const AES = {
  144. encrypt(data: string, key: string): string {
  145. try {
  146. const keyUtf8 = assertAesKey(key, '加密');
  147. return CryptoJS.AES.encrypt(data, keyUtf8, {
  148. mode: CryptoJS.mode.ECB,
  149. padding: CryptoJS.pad.Pkcs7,
  150. }).toString();
  151. } catch (error) {
  152. console.error('AES 加密失败:', error);
  153. throw error;
  154. }
  155. },
  156. decrypt(encryptedData: string, key: string): string {
  157. const keyUtf8 = assertAesKey(key, '解密');
  158. if (!encryptedData) {
  159. throw new Error('AES 解密数据不能为空');
  160. }
  161. try {
  162. const result = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
  163. mode: CryptoJS.mode.ECB,
  164. padding: CryptoJS.pad.Pkcs7,
  165. }).toString(CryptoJS.enc.Utf8);
  166. if (!result) {
  167. throw new Error('解密结果为空');
  168. }
  169. return result;
  170. } catch (error) {
  171. console.error('AES 解密失败:', error);
  172. throw new Error(
  173. `AES 解密失败,可能是密钥配置错误或数据损坏:${(error as Error).message}`,
  174. );
  175. }
  176. },
  177. };
  178. /** RSA 工具类(jsencrypt 仅支持 PKCS1v1.5,与后端保持一致) */
  179. export const RSA = {
  180. encrypt(data: string, publicKey: string): false | string {
  181. try {
  182. if (!publicKey) {
  183. throw new Error('RSA 公钥不能为空');
  184. }
  185. const encryptor = new JSEncrypt();
  186. encryptor.setPublicKey(publicKey);
  187. const result = encryptor.encrypt(data);
  188. if (result === false) {
  189. throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长');
  190. }
  191. return result;
  192. } catch (error) {
  193. console.error('RSA 加密失败:', error);
  194. throw error;
  195. }
  196. },
  197. decrypt(encryptedData: string, privateKey: string): false | string {
  198. try {
  199. if (!privateKey) {
  200. throw new Error('RSA 私钥不能为空');
  201. }
  202. if (!encryptedData) {
  203. throw new Error('RSA 解密数据不能为空');
  204. }
  205. const encryptor = new JSEncrypt();
  206. encryptor.setPrivateKey(privateKey);
  207. const result = encryptor.decrypt(encryptedData);
  208. if (result === false) {
  209. throw new Error('RSA 解密失败,可能是私钥错误或数据损坏');
  210. }
  211. return result;
  212. } catch (error) {
  213. console.error('RSA 解密失败:', error);
  214. throw error;
  215. }
  216. },
  217. };
  218. /** API 加解密配置(不再包含固定密钥) */
  219. export interface ApiEncryptConfig {
  220. algorithm: 'AES' | 'RSA';
  221. enable: boolean;
  222. header: string;
  223. }
  224. /**
  225. * API 加解密主类(会话密钥版)
  226. */
  227. export class ApiEncrypt {
  228. private config: ApiEncryptConfig;
  229. /** 会话标识,所有加密请求都要带 */
  230. private sessionId = '';
  231. /** 会话对称密钥,仅存内存 */
  232. private sessionKey = '';
  233. /** 协商用 RSA 密文,只在密钥交换请求上带一次,带完即清 */
  234. private pendingWrappedKey = '';
  235. constructor(config: ApiEncryptConfig) {
  236. this.config = config;
  237. }
  238. // ---------- 会话生命周期 ----------
  239. setSession(sessionId: string, key: string): void {
  240. this.sessionId = sessionId;
  241. this.sessionKey = key;
  242. }
  243. clearSession(): void {
  244. this.sessionId = '';
  245. this.sessionKey = '';
  246. this.pendingWrappedKey = '';
  247. }
  248. getSessionId(): string {
  249. return this.sessionId;
  250. }
  251. hasSession(): boolean {
  252. return Boolean(this.sessionId && this.sessionKey);
  253. }
  254. setPendingWrappedKey(wrapped: string): void {
  255. this.pendingWrappedKey = wrapped;
  256. }
  257. /** 取出待发送的 RSA 密文(取一次即清空) */
  258. takePendingWrappedKey(): string {
  259. const v = this.pendingWrappedKey;
  260. this.pendingWrappedKey = '';
  261. return v;
  262. }
  263. /**
  264. * 加解密是否已就绪
  265. *
  266. * 关键改动:必须「已协商出会话密钥」才算启用。
  267. * 否则会发出「明文 body + X-Api-Encrypt: true」,后端按声明解密必然失败。
  268. */
  269. isEnabled(): boolean {
  270. return this.config.enable && Boolean(this.sessionKey);
  271. }
  272. getEncryptHeader(): string {
  273. return this.config.header;
  274. }
  275. /** 加密请求数据 */
  276. encryptRequest(data: any): any {
  277. if (!this.isEnabled()) {
  278. return data;
  279. }
  280. try {
  281. const jsonData = typeof data === 'string' ? data : JSON.stringify(data);
  282. if (this.config.algorithm.toUpperCase() === 'AES') {
  283. return AES.encrypt(jsonData, this.sessionKey);
  284. }
  285. if (this.config.algorithm.toUpperCase() === 'RSA') {
  286. const result = RSA.encrypt(jsonData, this.sessionKey);
  287. if (result === false) {
  288. throw new Error('RSA 加密失败');
  289. }
  290. return result;
  291. }
  292. throw new Error(`不支持的加密算法: ${this.config.algorithm}`);
  293. } catch (error) {
  294. console.error('请求数据加密失败:', error);
  295. throw error;
  296. }
  297. }
  298. /** 解密响应数据 */
  299. decryptResponse(encryptedData: string): any {
  300. if (!this.config.enable) {
  301. return encryptedData;
  302. }
  303. try {
  304. if (!this.sessionKey) {
  305. throw new Error('会话密钥缺失,无法解密响应');
  306. }
  307. let decryptedData: false | string = '';
  308. if (this.config.algorithm.toUpperCase() === 'AES') {
  309. decryptedData = AES.decrypt(encryptedData, this.sessionKey);
  310. } else if (this.config.algorithm.toUpperCase() === 'RSA') {
  311. decryptedData = RSA.decrypt(encryptedData, this.sessionKey);
  312. if (decryptedData === false) {
  313. throw new Error('RSA 解密失败');
  314. }
  315. } else {
  316. throw new Error(`不支持的解密算法: ${this.config.algorithm}`);
  317. }
  318. if (!decryptedData) {
  319. throw new Error('解密结果为空');
  320. }
  321. try {
  322. return JSON.parse(decryptedData);
  323. } catch {
  324. return decryptedData;
  325. }
  326. } catch (error) {
  327. console.error('响应数据解密失败:', error);
  328. throw error;
  329. }
  330. }
  331. }
  332. /**
  333. * 创建基于环境变量的 API 加解密实例
  334. * 注意:不再从 env 读取任何密钥。
  335. */
  336. export function createApiEncrypt(env: Record<string, any>): ApiEncrypt {
  337. return new ApiEncrypt({
  338. algorithm: env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES',
  339. enable: env.VITE_APP_API_ENCRYPT_ENABLE === 'true',
  340. header: env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt',
  341. });
  342. }

2.6 F6 apps/web-antd/src/api/request.ts(整文件替换)

  1. /**
  2. * 该文件可自行根据业务逻辑进行调整
  3. */
  4. import type { RequestClientOptions } from '@vben/request';
  5. import { isTenantEnable, useAppConfig } from '@vben/hooks';
  6. import { preferences } from '@vben/preferences';
  7. import {
  8. authenticateResponseInterceptor,
  9. defaultResponseInterceptor,
  10. errorMessageResponseInterceptor,
  11. RequestClient,
  12. } from '@vben/request';
  13. import { useAccessStore } from '@vben/stores';
  14. import {
  15. createApiEncrypt,
  16. generateSessionId,
  17. generateSessionKey,
  18. isEncryptFlaggedHeader,
  19. isNonJsonBody,
  20. removeHeader,
  21. wrapSessionKey,
  22. } from '@vben/utils';
  23. import { message } from 'ant-design-vue';
  24. import { useAuthStore } from '#/store';
  25. import { refreshTokenApi } from './core';
  26. const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
  27. const tenantEnable = isTenantEnable();
  28. /** 会话密钥实例:供 auth.ts / store 复用 */
  29. export const apiEncrypt = createApiEncrypt(import.meta.env);
  30. /** 防止并发重复协商 */
  31. let renewing: null | Promise<void> = null;
  32. function formatToken(token: null | string) {
  33. return token ? `Bearer ${token}` : null;
  34. }
  35. /**
  36. * 生成新的会话密钥,并把 RSA 密文暂存待发
  37. * 不发起任何请求 —— 密文会由「下一个加密请求」带出去(登录请求或协商请求)。
  38. */
  39. export function prepareSession(): void {
  40. const sessionId = generateSessionId();
  41. const rawKey = generateSessionKey();
  42. apiEncrypt.setSession(sessionId, rawKey);
  43. apiEncrypt.setPendingWrappedKey(
  44. wrapSessionKey(rawKey, import.meta.env.VITE_APP_RSA_PUBLIC_KEY),
  45. );
  46. }
  47. /**
  48. * 已登录场景下(页面刷新后内存丢失)把新会话密钥注册到后端
  49. * 幂等:同一时刻只会发起一次
  50. */
  51. export function ensureSessionReady(): Promise<void> {
  52. const accessStore = useAccessStore();
  53. if (!accessStore.accessToken) {
  54. return Promise.resolve();
  55. }
  56. if (apiEncrypt.hasSession()) {
  57. return Promise.resolve();
  58. }
  59. if (!renewing) {
  60. renewing = (async () => {
  61. prepareSession();
  62. const wrapped = apiEncrypt.takePendingWrappedKey();
  63. await baseRequestClient.post('/system/auth/session-key', null, {
  64. headers: {
  65. Authorization: formatToken(accessStore.accessToken as string),
  66. 'X-Session-Id': apiEncrypt.getSessionId(),
  67. 'X-Session-Key': wrapped,
  68. },
  69. });
  70. })()
  71. .catch((error) => {
  72. // 协商失败时清空,避免用一把后端不认识的密钥继续发加密请求
  73. apiEncrypt.clearSession();
  74. throw error;
  75. })
  76. .finally(() => {
  77. renewing = null;
  78. });
  79. }
  80. return renewing;
  81. }
  82. function createRequestClient(baseURL: string, options?: RequestClientOptions) {
  83. const client = new RequestClient({
  84. ...options,
  85. baseURL,
  86. });
  87. /** 重新认证逻辑 */
  88. async function doReAuthenticate() {
  89. console.warn('Access token or refresh token is invalid or expired. ');
  90. const accessStore = useAccessStore();
  91. const authStore = useAuthStore();
  92. accessStore.setAccessToken(null);
  93. if (
  94. preferences.app.loginExpiredMode === 'modal' &&
  95. accessStore.isAccessChecked
  96. ) {
  97. accessStore.setLoginExpired(true);
  98. } else {
  99. await authStore.logout();
  100. }
  101. }
  102. /** 刷新 token 逻辑 */
  103. async function doRefreshToken() {
  104. const accessStore = useAccessStore();
  105. const refreshToken = accessStore.refreshToken as string;
  106. if (!refreshToken) {
  107. throw new Error('Refresh token is null!');
  108. }
  109. const resp = await refreshTokenApi(refreshToken);
  110. const newToken = resp?.data?.data?.accessToken;
  111. // add by dy:这里一定要抛出 resp.data,从而触发刷新令牌失败!!!
  112. if (!newToken) {
  113. throw resp.data;
  114. }
  115. accessStore.setAccessToken(newToken);
  116. return newToken;
  117. }
  118. // 请求头处理
  119. client.addRequestInterceptor({
  120. fulfilled: async (config) => {
  121. const accessStore = useAccessStore();
  122. config.headers.Authorization = formatToken(accessStore.accessToken);
  123. config.headers['Accept-Language'] = preferences.app.locale;
  124. // 添加租户编号
  125. config.headers['tenant-id'] = tenantEnable
  126. ? accessStore.tenantId
  127. : undefined;
  128. // 只有登录时,才设置 visit-tenant-id 访问租户
  129. config.headers['visit-tenant-id'] = tenantEnable
  130. ? accessStore.visitTenantId
  131. : undefined;
  132. // 是否 API 加密
  133. if ((config.headers || {}).isEncrypt) {
  134. try {
  135. // 兜底:会话缺失(页面刷新)时先协商,避免发不出加密请求
  136. if (!apiEncrypt.hasSession()) {
  137. await ensureSessionReady();
  138. }
  139. const sessionId = apiEncrypt.getSessionId();
  140. if (sessionId) {
  141. config.headers['X-Session-Id'] = sessionId;
  142. }
  143. // 协商密文只在需要时带一次
  144. const wrapped = apiEncrypt.takePendingWrappedKey();
  145. if (wrapped) {
  146. config.headers['X-Session-Key'] = wrapped;
  147. }
  148. // 仅当「会话密钥就绪」且「请求体可加密」时才加密并声明加密头
  149. if (
  150. apiEncrypt.isEnabled() &&
  151. config.data &&
  152. !isNonJsonBody(config.data)
  153. ) {
  154. config.data = apiEncrypt.encryptRequest(config.data);
  155. config.headers[apiEncrypt.getEncryptHeader()] = 'true';
  156. }
  157. } catch (error) {
  158. console.error('请求数据加密失败:', error);
  159. throw error;
  160. } finally {
  161. // 剥离业务侧私有标记头
  162. removeHeader(config.headers, 'isEncrypt');
  163. }
  164. }
  165. return config;
  166. },
  167. });
  168. // API 解密响应拦截器
  169. client.addResponseInterceptor({
  170. fulfilled: (response) => {
  171. if (
  172. isEncryptFlaggedHeader(
  173. response.headers,
  174. apiEncrypt.getEncryptHeader(),
  175. ) &&
  176. typeof response.data === 'string'
  177. ) {
  178. try {
  179. response.data = apiEncrypt.decryptResponse(response.data);
  180. } catch (error) {
  181. console.error('响应数据解密失败:', error);
  182. throw new Error(`响应数据解密失败: ${(error as Error).message}`);
  183. }
  184. }
  185. return response;
  186. },
  187. rejected: (error) => {
  188. // 错误响应(4xx/5xx)也可能被后端加密
  189. const response = error?.response;
  190. if (
  191. response &&
  192. isEncryptFlaggedHeader(
  193. response.headers,
  194. apiEncrypt.getEncryptHeader(),
  195. ) &&
  196. typeof response.data === 'string'
  197. ) {
  198. try {
  199. response.data = apiEncrypt.decryptResponse(response.data);
  200. } catch (decryptError) {
  201. console.error('错误响应数据解密失败:', decryptError);
  202. }
  203. }
  204. // 会话密钥失效:清空本地会话,下次请求自动重新协商
  205. const msg = response?.data?.msg ?? response?.data?.message ?? '';
  206. if (typeof msg === 'string' && msg.includes('会话密钥')) {
  207. apiEncrypt.clearSession();
  208. }
  209. return Promise.reject(error);
  210. },
  211. });
  212. // 处理返回的响应数据格式
  213. client.addResponseInterceptor(
  214. defaultResponseInterceptor({
  215. codeField: 'code',
  216. dataField: 'data',
  217. // add by dy: 兼容宇擎后端部分接口实际返回 code=200
  218. successCode: (code: any) => code === 0 || code === 200,
  219. }),
  220. );
  221. // token 过期的处理
  222. client.addResponseInterceptor(
  223. authenticateResponseInterceptor({
  224. client,
  225. doReAuthenticate,
  226. doRefreshToken,
  227. enableRefreshToken: preferences.app.enableRefreshToken,
  228. formatToken,
  229. }),
  230. );
  231. // 通用错误处理
  232. client.addResponseInterceptor(
  233. errorMessageResponseInterceptor((msg: string, error) => {
  234. const responseData = error?.response?.data ?? {};
  235. const errorMessage =
  236. responseData?.error ?? responseData?.message ?? responseData.msg ?? '';
  237. // add by dy:避免 401 “账号未登录” 重复提示
  238. if (error?.data?.code === 401) {
  239. return;
  240. }
  241. message.error(errorMessage || msg);
  242. }),
  243. );
  244. return client;
  245. }
  246. export const requestClient = createRequestClient(apiURL, {
  247. responseReturn: 'data',
  248. timeout: 60_000,
  249. });
  250. export const baseRequestClient = new RequestClient({ baseURL: apiURL });
  251. baseRequestClient.addRequestInterceptor({
  252. fulfilled: (config) => {
  253. const accessStore = useAccessStore();
  254. config.headers['tenant-id'] = tenantEnable
  255. ? accessStore.tenantId
  256. : undefined;
  257. config.headers['visit-tenant-id'] = tenantEnable
  258. ? accessStore.visitTenantId
  259. : undefined;
  260. return config;
  261. },
  262. });

⚠️ ensureSessionReady 内部引用了下方 baseRequestClient。ES 模块下函数体在调用时才求值, 只要不在模块顶层直接调用 ensureSessionReady() 就不会有 TDZ 问题。 若你的 lint 规则报 no-use-before-define,把 baseRequestClient 的声明上移到函数定义之前即可。

2.7 F7 apps/web-antd/src/api/core/auth.ts

  1. import { apiEncrypt, ensureSessionReady, prepareSession, requestClient } from '#/api/request';
  2. /** 登录 */
  3. export async function loginApi(data: AuthApi.LoginParams) {
  4. // 登录前生成会话密钥;RSA 密文会由本次登录请求的 X-Session-Key 带出去
  5. prepareSession();
  6. return requestClient.post<AuthApi.LoginResult>('/system/auth/login', data, {
  7. headers: {
  8. isEncrypt: true,
  9. },
  10. });
  11. }
  12. /** 退出登录后清理本地会话密钥(由 store/logout 调用) */
  13. export function clearEncryptSession() {
  14. apiEncrypt.clearSession();
  15. }
  16. export { ensureSessionReady };

其余函数(refreshTokenApi、logoutApi、…)保持原样,不需要 isEncrypt。

2.8 F8 apps/web-antd/src/store/auth.ts — logout 清理

  1. // 在 logout() 的 finally 之后、resetAllStores() 之前插入
  2. import { apiEncrypt } from '#/api/request';
  3. async function logout(redirect: boolean = true) {
  4. try {
  5. const accessToken = accessStore.accessToken as string;
  6. if (accessToken) {
  7. await logoutApi(accessToken);
  8. }
  9. } catch {
  10. // 不做任何处理
  11. }
  12. // ★ 新增:销毁本地会话密钥
  13. apiEncrypt.clearSession();
  14. resetAllStores();
  15. // ...其余保持原样
  16. }

2.9 F9 apps/web-antd/src/bootstrap.ts — 页面刷新后重新协商

在 await initStores(app, { namespace }); 之后插入:

  1. // 配置 pinia-store
  2. await initStores(app, { namespace });
  3. // ★ 新增:若已有登录态(页面刷新),重新协商会话密钥
  4. // 失败不阻塞启动:登录页/后续请求会再触发
  5. void import('#/api/request').then(({ ensureSessionReady }) =>
  6. ensureSessionReady().catch(() => undefined),
  7. );

2.10 F10 其余 4 个 app

web-ele / web-naive / web-tdesign / web-antdv-next 各自都有独立的 src/api/request.ts、src/api/core/auth.ts、src/store/auth.ts、src/bootstrap.ts, 需要做完全相同的四处改动(F6–F9)。packages/@core/base/shared 是共享包,改一次即可。

这是”多客户端同步”成本的主要来源。如果短时间无法全改完,可先在 web-antd 单点灰度 —— 后端双轨期旧 app 仍能用固定密钥(见第 6 节阶段 1)。

2.11 F11 apps/web-antd/Jenkinsfile

  1. environment {
  2. // ...原有配置保持不变...
  3. VITE_APP_RSA_PUBLIC_KEY = credentials('web-antd-rsa-public-key')
  4. VITE_APP_STORE_SECURE_KEY = credentials('web-antd-store-secure-key')
  5. VITE_BAIDU_MAP_KEY = credentials('web-antd-baidu-map-ak')
  6. VITE_AMAP_KEY = credentials('web-antd-amap-key')
  7. }

构建后追加产物扫描(放在 stage('5. 构建应用') 之后):

  1. stage('5.1 产物密钥扫描') {
  2. steps {
  3. sh '''
  4. if grep -rqE "(VITE_APP_API_ENCRYPT_(REQUEST|RESPONSE)_KEY|BEGIN (RSA )?PRIVATE KEY)" apps/web-antd/dist/ ; then
  5. echo "检测到密钥被内联进前端产物,已阻断发布"; exit 1
  6. fi
  7. echo "产物密钥扫描通过"
  8. '''
  9. }
  10. }

3. 前端 · 小程序端改造 dy-ui-admin-uniapp

3.1 M1 env/.env

  1. - VITE_APP_API_ENCRYPT_REQUEST_KEY = ysh8E58Kf5g4CxnrjZB6gTxx73Vt9kCt
  2. - VITE_APP_API_ENCRYPT_RESPONSE_KEY = mk4T42hh98YHDGS47YdVfWD75Hk2bwZr
  3. + VITE_APP_RSA_PUBLIC_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...替换为真实公钥
  4. VITE_APP_API_ENCRYPT_ENABLE = true
  5. VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt
  6. VITE_APP_API_ENCRYPT_ALGORITHM = AES

3.2 M2 src/utils/encrypt.ts(整文件替换)

小程序端没有 Web Crypto,随机源用 CryptoJS.lib.WordArray.random。 同时该文件原先是静态类风格,本方案保留该风格以免改动面扩大。

  1. import CryptoJS from 'crypto-js'
  2. import { JSEncrypt } from 'jsencrypt'
  3. /**
  4. * API 加解密工具(方案二:会话密钥)
  5. * 小程序端无 Web Crypto,随机源使用 crypto-js。
  6. * 会话密钥只存内存,退出登录即销毁。
  7. */
  8. const API_ENCRYPT_ENABLE = import.meta.env.VITE_APP_API_ENCRYPT_ENABLE === 'true'
  9. const API_ENCRYPT_HEADER = import.meta.env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt'
  10. const API_ENCRYPT_ALGORITHM = import.meta.env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES'
  11. const RSA_PUBLIC_KEY = import.meta.env.VITE_APP_RSA_PUBLIC_KEY || ''
  12. /** 会话密钥字符集:只允许 ASCII,保证字符数 === UTF-8 字节数 */
  13. const SESSION_KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  14. // ---------- 会话状态(模块级,仅存内存) ----------
  15. let currentSessionId = ''
  16. let currentSessionKey = ''
  17. let pendingWrappedKey = ''
  18. /** AES 加密工具类 */
  19. export class AES {
  20. static encrypt(data: string, key: string): string {
  21. if (!key) throw new Error('AES 加密密钥不能为空')
  22. if (![16, 24, 32].includes(key.length)) {
  23. throw new Error(`AES 加密密钥长度必须为 16/24/32 位,当前长度: ${key.length}`)
  24. }
  25. const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
  26. return CryptoJS.AES.encrypt(data, keyUtf8, {
  27. mode: CryptoJS.mode.ECB,
  28. padding: CryptoJS.pad.Pkcs7,
  29. }).toString()
  30. }
  31. static decrypt(encryptedData: string, key: string): string {
  32. if (!key) throw new Error('AES 解密密钥不能为空')
  33. if (![16, 24, 32].includes(key.length)) {
  34. throw new Error(`AES 解密密钥长度必须为 16/24/32 位,当前长度: ${key.length}`)
  35. }
  36. if (!encryptedData) throw new Error('AES 解密数据不能为空')
  37. const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
  38. const result = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
  39. mode: CryptoJS.mode.ECB,
  40. padding: CryptoJS.pad.Pkcs7,
  41. }).toString(CryptoJS.enc.Utf8)
  42. if (!result) {
  43. throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏')
  44. }
  45. return result
  46. }
  47. }
  48. /** RSA 加密工具类(jsencrypt 仅支持 PKCS1v1.5) */
  49. export class RSA {
  50. static encrypt(data: string, publicKey: string): string | false {
  51. if (!publicKey) throw new Error('RSA 公钥不能为空')
  52. const encryptor = new JSEncrypt()
  53. encryptor.setPublicKey(publicKey)
  54. const result = encryptor.encrypt(data)
  55. if (result === false) {
  56. throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长')
  57. }
  58. return result
  59. }
  60. static decrypt(encryptedData: string, privateKey: string): string | false {
  61. if (!privateKey) throw new Error('RSA 私钥不能为空')
  62. if (!encryptedData) throw new Error('RSA 解密数据不能为空')
  63. const encryptor = new JSEncrypt()
  64. encryptor.setPrivateKey(privateKey)
  65. const result = encryptor.decrypt(encryptedData)
  66. if (result === false) {
  67. throw new Error('RSA 解密失败,可能是私钥错误或数据损坏')
  68. }
  69. return result
  70. }
  71. }
  72. /** 生成会话对称密钥(32 个 ASCII 字符) */
  73. export function generateSessionKey(length = 32): string {
  74. const n = SESSION_KEY_ALPHABET.length
  75. const limit = Math.floor(256 / n) * n
  76. let out = ''
  77. while (out.length < length) {
  78. const words = CryptoJS.lib.WordArray.random(length)
  79. for (let i = 0; i < length && out.length < length; i++) {
  80. const b = (words.words[i >>> 2] as number) >>> (24 - (i % 4) * 8) & 0xff
  81. if (b < limit) out += SESSION_KEY_ALPHABET[b % n]
  82. }
  83. }
  84. return out
  85. }
  86. /** 生成会话标识 */
  87. export function generateSessionId(): string {
  88. return `${Date.now().toString(36)}-${generateSessionKey(16)}`
  89. }
  90. /**
  91. * 生成新会话密钥并把 RSA 密文暂存,等待下一个加密请求带出去
  92. */
  93. export function prepareSession(): void {
  94. if (!API_ENCRYPT_ENABLE) return
  95. if (!RSA_PUBLIC_KEY) {
  96. console.error('[security] VITE_APP_RSA_PUBLIC_KEY 未配置,无法协商会话密钥')
  97. return
  98. }
  99. currentSessionId = generateSessionId()
  100. currentSessionKey = generateSessionKey()
  101. const wrapped = RSA.encrypt(currentSessionKey, RSA_PUBLIC_KEY)
  102. if (wrapped === false) {
  103. throw new Error('会话密钥包裹失败')
  104. }
  105. pendingWrappedKey = wrapped
  106. }
  107. /** API 加解密主类(静态风格,保持与旧调用点兼容) */
  108. export class ApiEncrypt {
  109. static getEncryptHeader(): string {
  110. return API_ENCRYPT_HEADER
  111. }
  112. static getSessionId(): string {
  113. return currentSessionId
  114. }
  115. static hasSession(): boolean {
  116. return Boolean(currentSessionId && currentSessionKey)
  117. }
  118. /** 取出待发送的 RSA 密文(取一次即清空) */
  119. static takePendingWrappedKey(): string {
  120. const v = pendingWrappedKey
  121. pendingWrappedKey = ''
  122. return v
  123. }
  124. static clearSession(): void {
  125. currentSessionId = ''
  126. currentSessionKey = ''
  127. pendingWrappedKey = ''
  128. }
  129. /** 关键:必须已协商出会话密钥才算启用 */
  130. static isEnabled(): boolean {
  131. return API_ENCRYPT_ENABLE && Boolean(currentSessionKey)
  132. }
  133. static encryptRequest(data: any): string {
  134. if (!ApiEncrypt.isEnabled()) return data
  135. const jsonData = typeof data === 'string' ? data : JSON.stringify(data)
  136. if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
  137. return AES.encrypt(jsonData, currentSessionKey)
  138. }
  139. const result = RSA.encrypt(jsonData, currentSessionKey)
  140. if (result === false) throw new Error('RSA 加密失败')
  141. return result
  142. }
  143. static decryptResponse(encryptedData: string): any {
  144. if (!API_ENCRYPT_ENABLE) return encryptedData
  145. if (!currentSessionKey) {
  146. throw new Error('会话密钥缺失,无法解密响应')
  147. }
  148. let decryptedData: string | false = ''
  149. if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
  150. decryptedData = AES.decrypt(encryptedData, currentSessionKey)
  151. } else {
  152. decryptedData = RSA.decrypt(encryptedData, currentSessionKey)
  153. if (decryptedData === false) throw new Error('RSA 解密失败')
  154. }
  155. if (!decryptedData) throw new Error('解密结果为空')
  156. try {
  157. return JSON.parse(decryptedData)
  158. } catch {
  159. return decryptedData
  160. }
  161. }
  162. }

3.3 M3 src/http/interceptor.ts — 挂会话头

把原第 78–91 行的加密分支替换为:

  1. // 5. API 加密(会话密钥)
  2. if (options.isEncrypt) {
  3. try {
  4. const sessionId = ApiEncrypt.getSessionId()
  5. if (sessionId) {
  6. options.header['X-Session-Id'] = sessionId
  7. }
  8. const wrapped = ApiEncrypt.takePendingWrappedKey()
  9. if (wrapped) {
  10. options.header['X-Session-Key'] = wrapped
  11. }
  12. if (options.data && ApiEncrypt.isEnabled()) {
  13. options.data = ApiEncrypt.encryptRequest(options.data)
  14. options.header[ApiEncrypt.getEncryptHeader()] = 'true'
  15. }
  16. } catch (error) {
  17. console.error('请求数据加密失败:', error)
  18. throw error
  19. }
  20. }

3.4 M4 src/api/login.ts — 登录前协商

  1. import { ApiEncrypt, prepareSession } from '@/utils/encrypt'
  2. export function login(data: IAuthLoginReq) {
  3. // ★ 登录前生成会话密钥;RSA 密文由本次登录请求带出
  4. prepareSession()
  5. // ★ 必须显式带 isEncrypt,否则密码明文进 body
  6. return http.post<IAuthLoginRes>('/system/auth/login', data, null, null, { isEncrypt: true })
  7. }

3.5 M5 登出时清理

在 token/user store 的登出方法里补:

  1. import { ApiEncrypt } from '@/utils/encrypt'
  2. // ...
  3. ApiEncrypt.clearSession()

小程序端还有一个”冷启动”场景:小程序被销毁后内存丢失,下次启动携带已持久化的 token。 建议在 app 启动(src/App.vue 的 onLaunch 或 http 初始化处)调用一次 prepareSession() + 协商请求,逻辑同 PC 端 ensureSessionReady()。


4. 后端改造

宇擎是 yudao / 若依系的平台化产品。动手前必须先确认一件事: 加解密过滤器(ApiEncryptFilter)的源码是否可改?

  • 可改 → 按 4.3 直接改造;
  • 不可改 → 只能在网关/自定义 Filter 里做前置解包,把结果透传给平台,工作量会显著上升。

下面 B2–B4 是新类,可直接新增;B5 给出改造要点(原类实现依赖平台内部,无法直接给全量文件)。

4.1 B1 application.yaml

  1. yudao:
  2. encrypt:
  3. enable: true
  4. algorithm: AES
  5. # ★ 关键:清空固定密钥,杜绝回退。保留任何固定密钥 = 整个方案无效
  6. request-key: ""
  7. response-key: ""
  8. # ★ 新增:RSA 私钥,从配置中心/环境变量注入,绝不入库
  9. rsa-private-key: ${ENCRYPT_RSA_PRIVATE_KEY:}

生成密钥对(注意 OpenSSL 3 默认输出 PKCS#8,需确认格式与后端读取方式一致):

  1. openssl genrsa -out encrypt_private.pem 2048
  2. # 后端用 PKCS8 读取时,转一次:
  3. openssl pkcs8 -topk8 -nocrypt -in encrypt_private.pem -out encrypt_private_pkcs8.pem
  4. # 公钥(给前端的 VITE_APP_RSA_PUBLIC_KEY,去掉头尾换行合并为一行)
  5. openssl rsa -in encrypt_private.pem -pubout -out encrypt_public.pem

4.2 B2 EncryptProperties.java

  1. @ConfigurationProperties(prefix = "yudao.encrypt")
  2. @Data
  3. public class EncryptProperties {
  4. /** 是否开启加解密 */
  5. private Boolean enable;
  6. /** 加密算法:AES / RSA */
  7. private String algorithm;
  8. /**
  9. * 固定请求密钥 —— 方案二要求必须为空
  10. * 保留字段仅为兼容旧配置,运行时会校验它为空
  11. */
  12. @Deprecated
  13. private String requestKey;
  14. /** 固定响应密钥 —— 方案二要求必须为空 */
  15. @Deprecated
  16. private String responseKey;
  17. /** RSA 私钥(Base64 编码的 PKCS#8,用于解开会话密钥) */
  18. private String rsaPrivateKey;
  19. @PostConstruct
  20. public void validate() {
  21. if (Boolean.TRUE.equals(enable) && StrUtil.isNotBlank(requestKey)) {
  22. throw new IllegalStateException(
  23. "[security] yudao.encrypt.request-key 必须为空:固定密钥会让会话密钥机制失效");
  24. }
  25. }
  26. }

@PostConstruct 里的校验是刻意加的 —— 它让”忘记清空固定密钥”变成启动即失败, 而不是一个安静的安全漏洞。

4.3 B3 SessionCryptoUtils.java

  1. public final class SessionCryptoUtils {
  2. /** 必须与前端 jsencrypt 对齐:PKCS1v1.5 */
  3. private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding";
  4. private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
  5. private static final String AES_ALGORITHM = "AES";
  6. private SessionCryptoUtils() {}
  7. /**
  8. * 用 RSA 私钥解出会话密钥
  9. * @param base64Cipher 前端上送的 base64 密文
  10. * @param base64PrivateKey PKCS#8 私钥(Base64)
  11. * @return 会话密钥(32 字符);失败返回 null
  12. */
  13. public static String rsaDecrypt(String base64Cipher, String base64PrivateKey) {
  14. if (StrUtil.isBlank(base64Cipher) || StrUtil.isBlank(base64PrivateKey)) {
  15. return null;
  16. }
  17. try {
  18. PrivateKey privateKey = KeyFactory.getInstance("RSA")
  19. .generatePrivate(new PKCS8EncodedKeySpec(
  20. Base64.getDecoder().decode(base64PrivateKey.trim())));
  21. Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
  22. cipher.init(Cipher.DECRYPT_MODE, privateKey);
  23. byte[] plain = cipher.doFinal(Base64.getDecoder().decode(base64Cipher));
  24. return new String(plain, StandardCharsets.UTF_8);
  25. } catch (Exception e) {
  26. log.warn("[security] 会话密钥 RSA 解密失败", e);
  27. return null;
  28. }
  29. }
  30. /** AES 解密(返回 UTF-8 明文) */
  31. public static String aesDecrypt(String base64Cipher, String key) {
  32. try {
  33. Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
  34. cipher.init(Cipher.DECRYPT_MODE,
  35. new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM));
  36. byte[] plain = cipher.doFinal(Base64.getDecoder().decode(base64Cipher));
  37. return new String(plain, StandardCharsets.UTF_8);
  38. } catch (Exception e) {
  39. throw new ServiceException("请求数据解密失败");
  40. }
  41. }
  42. /** AES 加密(入参 UTF-8 明文,返回 Base64 密文) */
  43. public static String aesEncrypt(String plainText, String key) {
  44. try {
  45. Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
  46. cipher.init(Cipher.ENCRYPT_MODE,
  47. new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM));
  48. byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
  49. return Base64.getEncoder().encodeToString(encrypted);
  50. } catch (Exception e) {
  51. throw new ServiceException("响应数据加密失败");
  52. }
  53. }
  54. }

注意:AES/ECB/PKCS5Padding 与前端 Pkcs7 在 16 字节块下完全等价,可以互通。 我实测用 Node 的 PKCS7 加密、后端解密成功,已确认这一点。

4.4 B4 SessionKeyStore.java

  1. @Component
  2. public class SessionKeyStore {
  3. private static final String KEY_PREFIX = "encrypt:session:key:";
  4. /** 归属校验:防止 A 拿 B 的 sessionId 复用密钥 */
  5. private static final String OWNER_PREFIX = "encrypt:session:owner:";
  6. @Resource
  7. private StringRedisTemplate stringRedisTemplate;
  8. public void bind(String sessionId, String aesKey, Long userId, Duration ttl) {
  9. stringRedisTemplate.opsForValue().set(KEY_PREFIX + sessionId, aesKey, ttl);
  10. if (userId != null) {
  11. stringRedisTemplate.opsForValue()
  12. .set(OWNER_PREFIX + sessionId, String.valueOf(userId), ttl);
  13. }
  14. }
  15. public String getKey(String sessionId) {
  16. return stringRedisTemplate.opsForValue().get(KEY_PREFIX + sessionId);
  17. }
  18. public Long getOwner(String sessionId) {
  19. String v = stringRedisTemplate.opsForValue().get(OWNER_PREFIX + sessionId);
  20. return StrUtil.isBlank(v) ? null : Long.valueOf(v);
  21. }
  22. public void remove(String sessionId) {
  23. if (StrUtil.isBlank(sessionId)) {
  24. return;
  25. }
  26. stringRedisTemplate.delete(Arrays.asList(
  27. KEY_PREFIX + sessionId, OWNER_PREFIX + sessionId));
  28. }
  29. }

TTL 取值建议:与 accessToken 有效期一致(宇擎默认 30 分钟),并在 token 续期时同步续期。

4.5 B5 ApiEncryptFilter 改造要点

核心是把”密钥从配置取”换成”从会话取”。改动集中在密钥解析这一段:

  1. /** 解析本次请求应使用的 AES 会话密钥 */
  2. private String resolveAesKey(HttpServletRequest request) {
  3. String sessionId = request.getHeader("X-Session-Id");
  4. String wrappedKey = request.getHeader("X-Session-Key");
  5. // ① 协商分支:带了 RSA 密文 → 解出会话密钥
  6. if (StrUtil.isNotBlank(wrappedKey)) {
  7. String aesKey = SessionCryptoUtils.rsaDecrypt(
  8. wrappedKey, encryptProperties.getRsaPrivateKey());
  9. if (StrUtil.isBlank(aesKey)) {
  10. throw new ServiceException("会话密钥协商失败");
  11. }
  12. // 暂存,供业务层(登录/协商接口)绑定
  13. request.setAttribute("ENCRYPT_SESSION_ID", sessionId);
  14. request.setAttribute("ENCRYPT_AES_KEY", aesKey);
  15. return aesKey;
  16. }
  17. // ② 已协商分支:按 sessionId 取
  18. if (StrUtil.isBlank(sessionId)) {
  19. throw new ServiceException("缺少会话标识 X-Session-Id");
  20. }
  21. String aesKey = sessionKeyStore.getKey(sessionId);
  22. if (StrUtil.isBlank(aesKey)) {
  23. // ★ 关键:不回退固定密钥。旧密钥在这里被彻底废掉
  24. throw new ServiceException("会话密钥不存在或已过期,请重新登录");
  25. }
  26. // ③ 归属校验
  27. Long owner = sessionKeyStore.getOwner(sessionId);
  28. Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
  29. if (owner != null && currentUserId != null && !owner.equals(currentUserId)) {
  30. throw new ServiceException("会话标识与当前用户不匹配");
  31. }
  32. return aesKey;
  33. }

原 filter 中的触发条件、body 包装、响应包装保持不变,只需把 properties.getRequestKey() / properties.getResponseKey() 的取值处换成 resolveAesKey(request) 的返回值。

触发条件需放宽一处:原逻辑可能只在 X-Api-Encrypt: true 时进入, 现在协商请求可能不带该头(/session-key 接口、或只做密钥注册),所以要改成:

  1. boolean shouldHandle = "true".equalsIgnoreCase(request.getHeader("X-Api-Encrypt"))
  2. || StrUtil.isNotBlank(request.getHeader("X-Session-Key"));
  3. if (!shouldHandle) {
  4. chain.doFilter(request, response);
  5. return;
  6. }

4.6 B6 AuthController — 绑定与协商接口

  1. @PostMapping("/login")
  2. public CommonResult<AuthLoginRespVO> login(@Valid @RequestBody AuthLoginReqVO reqVO,
  3. HttpServletRequest request) {
  4. AuthLoginRespVO resp = authService.login(reqVO);
  5. // ★ 把本次请求协商出的会话密钥绑定到会话
  6. bindSessionKey(request, resp.getUserId());
  7. return success(resp);
  8. }
  9. @PostMapping("/session-key")
  10. @Operation(summary = "重新协商会话密钥(页面刷新后调用)")
  11. public CommonResult<Boolean> renewSessionKey(HttpServletRequest request) {
  12. Long userId = SecurityFrameworkUtils.getLoginUserId();
  13. if (userId == null) {
  14. throw new ServiceException("账号未登录");
  15. }
  16. bindSessionKey(request, userId);
  17. return success(true);
  18. }
  19. private void bindSessionKey(HttpServletRequest request, Long userId) {
  20. String sessionId = (String) request.getAttribute("ENCRYPT_SESSION_ID");
  21. String aesKey = (String) request.getAttribute("ENCRYPT_AES_KEY");
  22. if (StrUtil.isBlank(sessionId) || StrUtil.isBlank(aesKey)) {
  23. return;
  24. }
  25. sessionKeyStore.bind(sessionId, aesKey, userId, Duration.ofSeconds(tokenExpireSeconds));
  26. }

/session-key 需要能被 SecurityFrameworkUtils 识别为已登录 —— 它靠的是 Authorization 头里的 token,前端已在 ensureSessionReady() 里显式带上。

4.7 B7 登出 / 强退时清理

  1. // AuthServiceImpl#logout 或 TokenServiceImpl#removeAccessToken 中
  2. String sessionId = request.getHeader("X-Session-Id");
  3. sessionKeyStore.remove(sessionId);

并在”踢下线 / 管理员强退”的路径里一并调用,避免悬挂密钥。


5. 后端单元测试(建议至少覆盖这 4 条)

  1. @SpringBootTest
  2. class SessionCryptoUtilsTest {
  3. /** 与前端 jsencrypt 产出的密文互通 */
  4. @Test
  5. void rsaDecrypt_shouldMatchFrontendJdk() { /* 用固定测试密钥对 + 前端生成的密文样本 */ }
  6. @Test
  7. void aesEncryptDecrypt_shouldRoundTrip() { }
  8. /** 旧固定密钥必须被拒绝 */
  9. @Test
  10. void fixedKeyRequest_shouldBeRejected() { }
  11. /** 会话密钥过期后必须被拒绝 */
  12. @Test
  13. void expiredSessionKey_shouldBeRejected() { }
  14. }

6. 上线顺序(必须按此执行)

  1. 阶段 0|准备
  2. - 后端生成 RSA 密钥对;公钥交付前端
  3. - 全部客户端代码改造完成、可发布
  4. 阶段 1|后端先上(双轨,无感)
  5. - 部署支持 X-Session-Key 的后端
  6. - 但 【暂不清空】 request-key / response-key
  7. - 结果:新客户端走会话密钥,旧客户端继续走固定密钥,零影响
  8. - ★ 埋点:日志记录每次加密请求命中「会话密钥」还是「固定密钥」
  9. 阶段 2|客户端逐个升级
  10. 顺序建议:web-antd → web-ele → web-naive → web-tdesign → web-antdv-next
  11. → dy-ui-admin-uniapp → yudao-ui-admin-uniapp
  12. 每升一个,观察阶段 1 的埋点指标:走固定密钥的请求数应逐步下降
  13. 阶段 3|关闭固定密钥(关键收官)
  14. - 确认"固定密钥"指标归零且稳定 ≥ 1 个工作日
  15. - 清空 yudao.encrypt.request-key / response-key
  16. - 重启后端(若有旧客户端残留,会立刻报"会话密钥不存在")
  17. - 用旧密钥构造请求应被拒绝
  18. 阶段 4|轮换与收尾
  19. - 轮换 AES 密钥、地图 ak、admin 默认口令
  20. - 清理 git 历史中的 .env 密钥;删除 env 里的旧值
  21. - 删除产物扫描的豁免项,让卡口正式生效

顺序不可颠倒的两个点:

  1. 后端双轨必须先于前端发布 —— 否则新前端连不上;
  2. 关闭固定密钥必须晚于全部客户端升级 —— 否则小程序/旧 app 立刻挂。

7. 验收清单

# 验收项 命令 / 方法 期望
1 产物无密钥 `grep -rE “ysh8E58K\ mk4T42hh\ BEGIN (RSA )?PRIVATE KEY” apps/web-antd/dist/` 无输出
2 产物只剩公钥 `grep -r “VITE_APP_RSA_PUBLIC_KEY\ BEGIN PUBLIC KEY” dist/` 仅公钥(可接受)
3 旧密钥失效 用旧固定密钥构造 X-Api-Encrypt: true 请求 被拒
4 会话密钥不可跨会话复用 会话 A 的 X-Session-Id + 会话 B 的密钥 解密失败
5 归属校验生效 用户 A 的 token + 用户 B 的 X-Session-Id 被拒(会话标识与当前用户不匹配)
6 退出即失效 登出后用原 X-Session-Id 发请求 被拒
7 页面刷新可恢复 PC 登录后 F5,继续调接口 正常
8 小程序冷启动可恢复 杀掉小程序重进,带 token 继续使用 正常
9 明文请求仍可用 不带 X-Api-Encrypt 访问普通接口 正常
10 配置防呆 故意在 yaml 填回 request-key 后端启动报错

8. 风险与注意事项

风险 说明 应对
后端过滤器不可改 宇擎是平台化产品,源码可能拿不到 先确认;拿不到则在网关层做前置解包
多客户端同步遗漏 7 个端共用一个后端协议 阶段 1 埋点指标是唯一可信的”还差谁”信号
RSA 填充不一致 小程序 jsencrypt 只支持 PKCS1v1.5 全端统一 PKCS1v1.5,写进接口契约
会话密钥字符集踩坑 非 ASCII 字符会让 UTF-8 字节数 != 字符数,AES 直接报 Invalid key size 代码中固定 ASCII 字母表(已在 F5/M2 落实)
刷新即失效 会话密钥只在内存 启动协商(F9)+ 错误码触发重协商(F6 的 rejected 分支)
协商风暴 并发请求同时触发协商 renewing 单例 Promise 去重(已在 F6 落实)
忘记清空固定密钥 最致命的低级失误 EncryptProperties#validate 启动即校验(B2)
会话密钥与 token 生命周期不一致 token 续期后密钥过期,出现随机失败 token 续期时同步续期 Redis TTL

9. 一句话结论

这套改造的核心只有一句:把”前端持有一把全站通用密钥”换成”每个会话现生成一把、用完即弃的密钥”。 所有代码改动都是围绕这一句展开的。算法、报文格式、过滤器骨架全部保持不变 —— 这是刻意的选择, 目的是让改动可灰度、可回滚,且在阶段 1 与旧客户端并存。