- 方案二 · 会话密钥改造:前后端实施手册
- 0. 改动清单总览
- 1. 协议契约(前后端必须逐条对齐)
- 2. 前端 · PC 端改造
- 2.1 F1
apps/web-antd/.env(入库,全部非敏感) - 2.2 F2
apps/web-antd/.env.local(不入库,根.gitignore已忽略) - 2.3 F3
apps/web-antd/.env.example(入库) - 2.4 F4
packages/types/global.d.ts - 2.5 F5
packages/@core/base/shared/src/utils/encrypt.ts(整文件替换) - 2.6 F6
apps/web-antd/src/api/request.ts(整文件替换) - 2.7 F7
apps/web-antd/src/api/core/auth.ts - 2.8 F8
apps/web-antd/src/store/auth.ts— logout 清理 - 2.9 F9
apps/web-antd/src/bootstrap.ts— 页面刷新后重新协商 - 2.10 F10 其余 4 个 app
- 2.11 F11
apps/web-antd/Jenkinsfile
- 2.1 F1
- 3. 前端 · 小程序端改造
dy-ui-admin-uniapp - 4. 后端改造
- 5. 后端单元测试(建议至少覆盖这 4 条)
- 6. 上线顺序(必须按此执行)
- 7. 验收清单
- 8. 风险与注意事项
- 9. 一句话结论
方案二 · 会话密钥改造:前后端实施手册
目标:让前端产物中不再存在任何可用密钥,并让”拿到公开信息即可伪造加密请求”这条路失效。 涉及仓库:
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 过滤器触发规则(重要)
if (有 X-Session-Key) {// 协商分支:RSA 私钥解出 K,存入 request attribute,供业务层绑定// 若同时有 X-Api-Encrypt: true,则用 K 解 body} else if (有 X-Api-Encrypt: true) {// 已协商分支:按 X-Session-Id 查 Redis 取 K// 查不到 → 直接拒绝,不回退固定密钥// 查到 → 校验归属(owner),再解 body}
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(入库,全部非敏感)
# 应用标题VITE_APP_TITLE=信息化综合管理平台# 应用命名空间VITE_APP_NAMESPACE=yudao-vben-antd# 是否开启模拟数据VITE_NITRO_MOCK=false# 租户开关VITE_APP_TENANT_ENABLE=true# 验证码开关VITE_APP_CAPTCHA_ENABLE=false# 文档地址开关VITE_APP_DOCALERT_ENABLE=true# 百度统计 / GAVITE_APP_BAIDU_CODE=ede2dc454379d7def7b4363ff72f8824VITE_APP_GA_ID=# API 加解密——只保留开关与协议参数,密钥一律不在此文件VITE_APP_API_ENCRYPT_ENABLE=trueVITE_APP_API_ENCRYPT_HEADER=X-Api-EncryptVITE_APP_API_ENCRYPT_ALGORITHM=AES# 后端 RSA 公钥(公钥可公开,允许入库)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 已忽略)
# 本地开发专用,禁止提交VITE_APP_STORE_SECURE_KEY=<本地随机值>VITE_BAIDU_MAP_KEY=<你的 ak>VITE_AMAP_KEY=<你的 key>VITE_GOVIEW_URL='http://127.0.0.1:3000'
2.3 F3 apps/web-antd/.env.example(入库)
# 复制为 .env.local 后填写真实值;本文件禁止填任何真值VITE_APP_STORE_SECURE_KEY= # 本地随机值 / CI 凭据 web-antd-store-secure-keyVITE_BAIDU_MAP_KEY= # 百度地图控制台(须配域名白名单)VITE_AMAP_KEY= # 高德控制台(须配域名白名单)
2.4 F4 packages/types/global.d.ts
export interface VbenAdminProAppConfigRaw {VITE_GLOB_API_URL: string;VITE_GLOB_AUTH_DINGDING_CLIENT_ID: string;VITE_GLOB_AUTH_DINGDING_CORP_ID: string;// API 加解密——只保留开关与协议参数VITE_APP_API_ENCRYPT_ENABLE: string;VITE_APP_API_ENCRYPT_HEADER: string;VITE_APP_API_ENCRYPT_ALGORITHM: string;// 后端 RSA 公钥(用于包裹会话密钥)VITE_APP_RSA_PUBLIC_KEY: string;}
删除
VITE_APP_API_ENCRYPT_REQUEST_KEY/VITE_APP_API_ENCRYPT_RESPONSE_KEY两行。
2.5 F5 packages/@core/base/shared/src/utils/encrypt.ts(整文件替换)
import CryptoJS from 'crypto-js';import { JSEncrypt } from 'jsencrypt';/*** API 加解密工具(方案二:会话密钥)** 与旧实现的区别:* - 不再从环境变量读取固定 AES 密钥;* - 每次会话生成一把随机对称密钥 K,仅存内存,退出登录即销毁;* - K 用后端 RSA 公钥包裹后通过 X-Session-Key 上送,完成协商;* - 对称算法 / 填充 / 报文编码保持不变,复用后端既有实现。*//** AES 合法密钥长度(字节),对应 AES-128 / AES-192 / AES-256 */const AES_KEY_BYTES = [16, 24, 32];/** 会话密钥字符数 = UTF-8 字节数,必须落在 ASCII 区间 */const SESSION_KEY_LENGTH = 32;/*** 会话密钥字符集:**只允许 ASCII**。* 若混入非 ASCII 字符(含中文),CryptoJS.enc.Utf8.parse 后字节数会膨胀,* key.length 与 sigBytes 不再相等,AES 会直接抛 Invalid key size。*/const SESSION_KEY_ALPHABET ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';/*** 校验并返回 AES 密钥* @param key 原始密钥字符串* @param scene 场景名(加密 / 解密),用于错误提示*/function assertAesKey(key: string, scene: string): CryptoJS.lib.WordArray {if (!key) {throw new Error(`AES ${scene}密钥不能为空`);}const keyUtf8 = CryptoJS.enc.Utf8.parse(key);if (!AES_KEY_BYTES.includes(keyUtf8.sigBytes)) {throw new Error(`AES ${scene}密钥长度必须为 16/24/32 字节,当前 ${keyUtf8.sigBytes} 字节(字符数 ${key.length})`,);}return keyUtf8;}/** 取随机字节:优先 Web Crypto,回落到 crypto-js 随机源(小程序 / 老环境) */function randomBytes(len: number): Uint8Array {const g = globalThis as any;if (g.crypto?.getRandomValues) {const arr = new Uint8Array(len);g.crypto.getRandomValues(arr);return arr;}const words = CryptoJS.lib.WordArray.random(len);const out = new Uint8Array(len);for (let i = 0; i < len; i++) {out[i] = (words.words[i >>> 2] as number) >>> (24 - (i % 4) * 8) & 0xff;}return out;}/*** 生成会话对称密钥(32 个 ASCII 字符 ≈ 190 bit 熵)* 用拒绝采样消除取模偏置。*/export function generateSessionKey(length = SESSION_KEY_LENGTH): string {const n = SESSION_KEY_ALPHABET.length; // 62const limit = Math.floor(256 / n) * n; // 248:>= limit 的字节丢弃let out = '';while (out.length < length) {const bytes = randomBytes(length);for (let i = 0; i < bytes.length && out.length < length; i++) {const b = bytes[i] as number;if (b < limit) {out += SESSION_KEY_ALPHABET[b % n];}}}return out;}/** 生成会话标识 */export function generateSessionId(): string {const g = globalThis as any;if (g.crypto?.randomUUID) {return g.crypto.randomUUID();}return `${Date.now().toString(36)}-${generateSessionKey(16)}`;}/** 用后端 RSA 公钥包裹会话密钥 */export function wrapSessionKey(rawKey: string, publicKeyPem: string): string {if (!publicKeyPem) {throw new Error('VITE_APP_RSA_PUBLIC_KEY 未配置,无法协商会话密钥');}const wrapped = RSA.encrypt(rawKey, publicKeyPem);if (wrapped === false) {throw new Error('会话密钥包裹失败:RSA 公钥无效');}return wrapped;}/*** 判断响应头是否声明了「响应体已加密」* 必须用 headers.get(name):AxiosHeaders 下标访问大小写敏感,取不到值。*/export function isEncryptFlaggedHeader(headers: any,headerName: string,): boolean {if (!headers || !headerName) {return false;}const raw =typeof headers.get === 'function'? headers.get(headerName): (headers[headerName] ?? headers[headerName.toLowerCase()]);return String(raw) === 'true';}/** 判断请求体是否为「不可 JSON 加密」的原始类型 */export function isNonJsonBody(data: any): boolean {if (!data || typeof data !== 'object') {return false;}if (typeof FormData !== 'undefined' && data instanceof FormData) {return true;}if (typeof Blob !== 'undefined' && data instanceof Blob) {return true;}if (typeof ArrayBuffer !== 'undefined' &&(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {return true;}return (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams);}/** 删除请求头(兼容 AxiosHeaders 与普通对象) */export function removeHeader(headers: any, name: string): void {if (!headers) {return;}if (typeof headers.delete === 'function') {headers.delete(name);} else {delete headers[name];}}/** AES 工具类 */export const AES = {encrypt(data: string, key: string): string {try {const keyUtf8 = assertAesKey(key, '加密');return CryptoJS.AES.encrypt(data, keyUtf8, {mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7,}).toString();} catch (error) {console.error('AES 加密失败:', error);throw error;}},decrypt(encryptedData: string, key: string): string {const keyUtf8 = assertAesKey(key, '解密');if (!encryptedData) {throw new Error('AES 解密数据不能为空');}try {const result = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7,}).toString(CryptoJS.enc.Utf8);if (!result) {throw new Error('解密结果为空');}return result;} catch (error) {console.error('AES 解密失败:', error);throw new Error(`AES 解密失败,可能是密钥配置错误或数据损坏:${(error as Error).message}`,);}},};/** RSA 工具类(jsencrypt 仅支持 PKCS1v1.5,与后端保持一致) */export const RSA = {encrypt(data: string, publicKey: string): false | string {try {if (!publicKey) {throw new Error('RSA 公钥不能为空');}const encryptor = new JSEncrypt();encryptor.setPublicKey(publicKey);const result = encryptor.encrypt(data);if (result === false) {throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长');}return result;} catch (error) {console.error('RSA 加密失败:', error);throw error;}},decrypt(encryptedData: string, privateKey: string): false | string {try {if (!privateKey) {throw new Error('RSA 私钥不能为空');}if (!encryptedData) {throw new Error('RSA 解密数据不能为空');}const encryptor = new JSEncrypt();encryptor.setPrivateKey(privateKey);const result = encryptor.decrypt(encryptedData);if (result === false) {throw new Error('RSA 解密失败,可能是私钥错误或数据损坏');}return result;} catch (error) {console.error('RSA 解密失败:', error);throw error;}},};/** API 加解密配置(不再包含固定密钥) */export interface ApiEncryptConfig {algorithm: 'AES' | 'RSA';enable: boolean;header: string;}/*** API 加解密主类(会话密钥版)*/export class ApiEncrypt {private config: ApiEncryptConfig;/** 会话标识,所有加密请求都要带 */private sessionId = '';/** 会话对称密钥,仅存内存 */private sessionKey = '';/** 协商用 RSA 密文,只在密钥交换请求上带一次,带完即清 */private pendingWrappedKey = '';constructor(config: ApiEncryptConfig) {this.config = config;}// ---------- 会话生命周期 ----------setSession(sessionId: string, key: string): void {this.sessionId = sessionId;this.sessionKey = key;}clearSession(): void {this.sessionId = '';this.sessionKey = '';this.pendingWrappedKey = '';}getSessionId(): string {return this.sessionId;}hasSession(): boolean {return Boolean(this.sessionId && this.sessionKey);}setPendingWrappedKey(wrapped: string): void {this.pendingWrappedKey = wrapped;}/** 取出待发送的 RSA 密文(取一次即清空) */takePendingWrappedKey(): string {const v = this.pendingWrappedKey;this.pendingWrappedKey = '';return v;}/*** 加解密是否已就绪** 关键改动:必须「已协商出会话密钥」才算启用。* 否则会发出「明文 body + X-Api-Encrypt: true」,后端按声明解密必然失败。*/isEnabled(): boolean {return this.config.enable && Boolean(this.sessionKey);}getEncryptHeader(): string {return this.config.header;}/** 加密请求数据 */encryptRequest(data: any): any {if (!this.isEnabled()) {return data;}try {const jsonData = typeof data === 'string' ? data : JSON.stringify(data);if (this.config.algorithm.toUpperCase() === 'AES') {return AES.encrypt(jsonData, this.sessionKey);}if (this.config.algorithm.toUpperCase() === 'RSA') {const result = RSA.encrypt(jsonData, this.sessionKey);if (result === false) {throw new Error('RSA 加密失败');}return result;}throw new Error(`不支持的加密算法: ${this.config.algorithm}`);} catch (error) {console.error('请求数据加密失败:', error);throw error;}}/** 解密响应数据 */decryptResponse(encryptedData: string): any {if (!this.config.enable) {return encryptedData;}try {if (!this.sessionKey) {throw new Error('会话密钥缺失,无法解密响应');}let decryptedData: false | string = '';if (this.config.algorithm.toUpperCase() === 'AES') {decryptedData = AES.decrypt(encryptedData, this.sessionKey);} else if (this.config.algorithm.toUpperCase() === 'RSA') {decryptedData = RSA.decrypt(encryptedData, this.sessionKey);if (decryptedData === false) {throw new Error('RSA 解密失败');}} else {throw new Error(`不支持的解密算法: ${this.config.algorithm}`);}if (!decryptedData) {throw new Error('解密结果为空');}try {return JSON.parse(decryptedData);} catch {return decryptedData;}} catch (error) {console.error('响应数据解密失败:', error);throw error;}}}/*** 创建基于环境变量的 API 加解密实例* 注意:不再从 env 读取任何密钥。*/export function createApiEncrypt(env: Record<string, any>): ApiEncrypt {return new ApiEncrypt({algorithm: env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES',enable: env.VITE_APP_API_ENCRYPT_ENABLE === 'true',header: env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt',});}
2.6 F6 apps/web-antd/src/api/request.ts(整文件替换)
/*** 该文件可自行根据业务逻辑进行调整*/import type { RequestClientOptions } from '@vben/request';import { isTenantEnable, useAppConfig } from '@vben/hooks';import { preferences } from '@vben/preferences';import {authenticateResponseInterceptor,defaultResponseInterceptor,errorMessageResponseInterceptor,RequestClient,} from '@vben/request';import { useAccessStore } from '@vben/stores';import {createApiEncrypt,generateSessionId,generateSessionKey,isEncryptFlaggedHeader,isNonJsonBody,removeHeader,wrapSessionKey,} from '@vben/utils';import { message } from 'ant-design-vue';import { useAuthStore } from '#/store';import { refreshTokenApi } from './core';const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);const tenantEnable = isTenantEnable();/** 会话密钥实例:供 auth.ts / store 复用 */export const apiEncrypt = createApiEncrypt(import.meta.env);/** 防止并发重复协商 */let renewing: null | Promise<void> = null;function formatToken(token: null | string) {return token ? `Bearer ${token}` : null;}/*** 生成新的会话密钥,并把 RSA 密文暂存待发* 不发起任何请求 —— 密文会由「下一个加密请求」带出去(登录请求或协商请求)。*/export function prepareSession(): void {const sessionId = generateSessionId();const rawKey = generateSessionKey();apiEncrypt.setSession(sessionId, rawKey);apiEncrypt.setPendingWrappedKey(wrapSessionKey(rawKey, import.meta.env.VITE_APP_RSA_PUBLIC_KEY),);}/*** 已登录场景下(页面刷新后内存丢失)把新会话密钥注册到后端* 幂等:同一时刻只会发起一次*/export function ensureSessionReady(): Promise<void> {const accessStore = useAccessStore();if (!accessStore.accessToken) {return Promise.resolve();}if (apiEncrypt.hasSession()) {return Promise.resolve();}if (!renewing) {renewing = (async () => {prepareSession();const wrapped = apiEncrypt.takePendingWrappedKey();await baseRequestClient.post('/system/auth/session-key', null, {headers: {Authorization: formatToken(accessStore.accessToken as string),'X-Session-Id': apiEncrypt.getSessionId(),'X-Session-Key': wrapped,},});})().catch((error) => {// 协商失败时清空,避免用一把后端不认识的密钥继续发加密请求apiEncrypt.clearSession();throw error;}).finally(() => {renewing = null;});}return renewing;}function createRequestClient(baseURL: string, options?: RequestClientOptions) {const client = new RequestClient({...options,baseURL,});/** 重新认证逻辑 */async function doReAuthenticate() {console.warn('Access token or refresh token is invalid or expired. ');const accessStore = useAccessStore();const authStore = useAuthStore();accessStore.setAccessToken(null);if (preferences.app.loginExpiredMode === 'modal' &&accessStore.isAccessChecked) {accessStore.setLoginExpired(true);} else {await authStore.logout();}}/** 刷新 token 逻辑 */async function doRefreshToken() {const accessStore = useAccessStore();const refreshToken = accessStore.refreshToken as string;if (!refreshToken) {throw new Error('Refresh token is null!');}const resp = await refreshTokenApi(refreshToken);const newToken = resp?.data?.data?.accessToken;// add by dy:这里一定要抛出 resp.data,从而触发刷新令牌失败!!!if (!newToken) {throw resp.data;}accessStore.setAccessToken(newToken);return newToken;}// 请求头处理client.addRequestInterceptor({fulfilled: async (config) => {const accessStore = useAccessStore();config.headers.Authorization = formatToken(accessStore.accessToken);config.headers['Accept-Language'] = preferences.app.locale;// 添加租户编号config.headers['tenant-id'] = tenantEnable? accessStore.tenantId: undefined;// 只有登录时,才设置 visit-tenant-id 访问租户config.headers['visit-tenant-id'] = tenantEnable? accessStore.visitTenantId: undefined;// 是否 API 加密if ((config.headers || {}).isEncrypt) {try {// 兜底:会话缺失(页面刷新)时先协商,避免发不出加密请求if (!apiEncrypt.hasSession()) {await ensureSessionReady();}const sessionId = apiEncrypt.getSessionId();if (sessionId) {config.headers['X-Session-Id'] = sessionId;}// 协商密文只在需要时带一次const wrapped = apiEncrypt.takePendingWrappedKey();if (wrapped) {config.headers['X-Session-Key'] = wrapped;}// 仅当「会话密钥就绪」且「请求体可加密」时才加密并声明加密头if (apiEncrypt.isEnabled() &&config.data &&!isNonJsonBody(config.data)) {config.data = apiEncrypt.encryptRequest(config.data);config.headers[apiEncrypt.getEncryptHeader()] = 'true';}} catch (error) {console.error('请求数据加密失败:', error);throw error;} finally {// 剥离业务侧私有标记头removeHeader(config.headers, 'isEncrypt');}}return config;},});// API 解密响应拦截器client.addResponseInterceptor({fulfilled: (response) => {if (isEncryptFlaggedHeader(response.headers,apiEncrypt.getEncryptHeader(),) &&typeof response.data === 'string') {try {response.data = apiEncrypt.decryptResponse(response.data);} catch (error) {console.error('响应数据解密失败:', error);throw new Error(`响应数据解密失败: ${(error as Error).message}`);}}return response;},rejected: (error) => {// 错误响应(4xx/5xx)也可能被后端加密const response = error?.response;if (response &&isEncryptFlaggedHeader(response.headers,apiEncrypt.getEncryptHeader(),) &&typeof response.data === 'string') {try {response.data = apiEncrypt.decryptResponse(response.data);} catch (decryptError) {console.error('错误响应数据解密失败:', decryptError);}}// 会话密钥失效:清空本地会话,下次请求自动重新协商const msg = response?.data?.msg ?? response?.data?.message ?? '';if (typeof msg === 'string' && msg.includes('会话密钥')) {apiEncrypt.clearSession();}return Promise.reject(error);},});// 处理返回的响应数据格式client.addResponseInterceptor(defaultResponseInterceptor({codeField: 'code',dataField: 'data',// add by dy: 兼容宇擎后端部分接口实际返回 code=200successCode: (code: any) => code === 0 || code === 200,}),);// token 过期的处理client.addResponseInterceptor(authenticateResponseInterceptor({client,doReAuthenticate,doRefreshToken,enableRefreshToken: preferences.app.enableRefreshToken,formatToken,}),);// 通用错误处理client.addResponseInterceptor(errorMessageResponseInterceptor((msg: string, error) => {const responseData = error?.response?.data ?? {};const errorMessage =responseData?.error ?? responseData?.message ?? responseData.msg ?? '';// add by dy:避免 401 “账号未登录” 重复提示if (error?.data?.code === 401) {return;}message.error(errorMessage || msg);}),);return client;}export const requestClient = createRequestClient(apiURL, {responseReturn: 'data',timeout: 60_000,});export const baseRequestClient = new RequestClient({ baseURL: apiURL });baseRequestClient.addRequestInterceptor({fulfilled: (config) => {const accessStore = useAccessStore();config.headers['tenant-id'] = tenantEnable? accessStore.tenantId: undefined;config.headers['visit-tenant-id'] = tenantEnable? accessStore.visitTenantId: undefined;return config;},});
⚠️
ensureSessionReady内部引用了下方baseRequestClient。ES 模块下函数体在调用时才求值, 只要不在模块顶层直接调用ensureSessionReady()就不会有 TDZ 问题。 若你的 lint 规则报no-use-before-define,把baseRequestClient的声明上移到函数定义之前即可。
2.7 F7 apps/web-antd/src/api/core/auth.ts
import { apiEncrypt, ensureSessionReady, prepareSession, requestClient } from '#/api/request';/** 登录 */export async function loginApi(data: AuthApi.LoginParams) {// 登录前生成会话密钥;RSA 密文会由本次登录请求的 X-Session-Key 带出去prepareSession();return requestClient.post<AuthApi.LoginResult>('/system/auth/login', data, {headers: {isEncrypt: true,},});}/** 退出登录后清理本地会话密钥(由 store/logout 调用) */export function clearEncryptSession() {apiEncrypt.clearSession();}export { ensureSessionReady };
其余函数(
refreshTokenApi、logoutApi、…)保持原样,不需要isEncrypt。
2.8 F8 apps/web-antd/src/store/auth.ts — logout 清理
// 在 logout() 的 finally 之后、resetAllStores() 之前插入import { apiEncrypt } from '#/api/request';async function logout(redirect: boolean = true) {try {const accessToken = accessStore.accessToken as string;if (accessToken) {await logoutApi(accessToken);}} catch {// 不做任何处理}// ★ 新增:销毁本地会话密钥apiEncrypt.clearSession();resetAllStores();// ...其余保持原样}
2.9 F9 apps/web-antd/src/bootstrap.ts — 页面刷新后重新协商
在 await initStores(app, { namespace }); 之后插入:
// 配置 pinia-storeawait initStores(app, { namespace });// ★ 新增:若已有登录态(页面刷新),重新协商会话密钥// 失败不阻塞启动:登录页/后续请求会再触发void import('#/api/request').then(({ ensureSessionReady }) =>ensureSessionReady().catch(() => undefined),);
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
environment {// ...原有配置保持不变...VITE_APP_RSA_PUBLIC_KEY = credentials('web-antd-rsa-public-key')VITE_APP_STORE_SECURE_KEY = credentials('web-antd-store-secure-key')VITE_BAIDU_MAP_KEY = credentials('web-antd-baidu-map-ak')VITE_AMAP_KEY = credentials('web-antd-amap-key')}
构建后追加产物扫描(放在 stage('5. 构建应用') 之后):
stage('5.1 产物密钥扫描') {steps {sh '''if grep -rqE "(VITE_APP_API_ENCRYPT_(REQUEST|RESPONSE)_KEY|BEGIN (RSA )?PRIVATE KEY)" apps/web-antd/dist/ ; thenecho "检测到密钥被内联进前端产物,已阻断发布"; exit 1fiecho "产物密钥扫描通过"'''}}
3. 前端 · 小程序端改造 dy-ui-admin-uniapp
3.1 M1 env/.env
- VITE_APP_API_ENCRYPT_REQUEST_KEY = ysh8E58Kf5g4CxnrjZB6gTxx73Vt9kCt- VITE_APP_API_ENCRYPT_RESPONSE_KEY = mk4T42hh98YHDGS47YdVfWD75Hk2bwZr+ VITE_APP_RSA_PUBLIC_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...替换为真实公钥VITE_APP_API_ENCRYPT_ENABLE = trueVITE_APP_API_ENCRYPT_HEADER = X-Api-EncryptVITE_APP_API_ENCRYPT_ALGORITHM = AES
3.2 M2 src/utils/encrypt.ts(整文件替换)
小程序端没有 Web Crypto,随机源用 CryptoJS.lib.WordArray.random。
同时该文件原先是静态类风格,本方案保留该风格以免改动面扩大。
import CryptoJS from 'crypto-js'import { JSEncrypt } from 'jsencrypt'/*** API 加解密工具(方案二:会话密钥)* 小程序端无 Web Crypto,随机源使用 crypto-js。* 会话密钥只存内存,退出登录即销毁。*/const API_ENCRYPT_ENABLE = import.meta.env.VITE_APP_API_ENCRYPT_ENABLE === 'true'const API_ENCRYPT_HEADER = import.meta.env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt'const API_ENCRYPT_ALGORITHM = import.meta.env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES'const RSA_PUBLIC_KEY = import.meta.env.VITE_APP_RSA_PUBLIC_KEY || ''/** 会话密钥字符集:只允许 ASCII,保证字符数 === UTF-8 字节数 */const SESSION_KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'// ---------- 会话状态(模块级,仅存内存) ----------let currentSessionId = ''let currentSessionKey = ''let pendingWrappedKey = ''/** AES 加密工具类 */export class AES {static encrypt(data: string, key: string): string {if (!key) throw new Error('AES 加密密钥不能为空')if (![16, 24, 32].includes(key.length)) {throw new Error(`AES 加密密钥长度必须为 16/24/32 位,当前长度: ${key.length}`)}const keyUtf8 = CryptoJS.enc.Utf8.parse(key)return CryptoJS.AES.encrypt(data, keyUtf8, {mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7,}).toString()}static decrypt(encryptedData: string, key: string): string {if (!key) throw new Error('AES 解密密钥不能为空')if (![16, 24, 32].includes(key.length)) {throw new Error(`AES 解密密钥长度必须为 16/24/32 位,当前长度: ${key.length}`)}if (!encryptedData) throw new Error('AES 解密数据不能为空')const keyUtf8 = CryptoJS.enc.Utf8.parse(key)const result = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {mode: CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7,}).toString(CryptoJS.enc.Utf8)if (!result) {throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏')}return result}}/** RSA 加密工具类(jsencrypt 仅支持 PKCS1v1.5) */export class RSA {static encrypt(data: string, publicKey: string): string | false {if (!publicKey) throw new Error('RSA 公钥不能为空')const encryptor = new JSEncrypt()encryptor.setPublicKey(publicKey)const result = encryptor.encrypt(data)if (result === false) {throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长')}return result}static decrypt(encryptedData: string, privateKey: string): string | false {if (!privateKey) throw new Error('RSA 私钥不能为空')if (!encryptedData) throw new Error('RSA 解密数据不能为空')const encryptor = new JSEncrypt()encryptor.setPrivateKey(privateKey)const result = encryptor.decrypt(encryptedData)if (result === false) {throw new Error('RSA 解密失败,可能是私钥错误或数据损坏')}return result}}/** 生成会话对称密钥(32 个 ASCII 字符) */export function generateSessionKey(length = 32): string {const n = SESSION_KEY_ALPHABET.lengthconst limit = Math.floor(256 / n) * nlet out = ''while (out.length < length) {const words = CryptoJS.lib.WordArray.random(length)for (let i = 0; i < length && out.length < length; i++) {const b = (words.words[i >>> 2] as number) >>> (24 - (i % 4) * 8) & 0xffif (b < limit) out += SESSION_KEY_ALPHABET[b % n]}}return out}/** 生成会话标识 */export function generateSessionId(): string {return `${Date.now().toString(36)}-${generateSessionKey(16)}`}/*** 生成新会话密钥并把 RSA 密文暂存,等待下一个加密请求带出去*/export function prepareSession(): void {if (!API_ENCRYPT_ENABLE) returnif (!RSA_PUBLIC_KEY) {console.error('[security] VITE_APP_RSA_PUBLIC_KEY 未配置,无法协商会话密钥')return}currentSessionId = generateSessionId()currentSessionKey = generateSessionKey()const wrapped = RSA.encrypt(currentSessionKey, RSA_PUBLIC_KEY)if (wrapped === false) {throw new Error('会话密钥包裹失败')}pendingWrappedKey = wrapped}/** API 加解密主类(静态风格,保持与旧调用点兼容) */export class ApiEncrypt {static getEncryptHeader(): string {return API_ENCRYPT_HEADER}static getSessionId(): string {return currentSessionId}static hasSession(): boolean {return Boolean(currentSessionId && currentSessionKey)}/** 取出待发送的 RSA 密文(取一次即清空) */static takePendingWrappedKey(): string {const v = pendingWrappedKeypendingWrappedKey = ''return v}static clearSession(): void {currentSessionId = ''currentSessionKey = ''pendingWrappedKey = ''}/** 关键:必须已协商出会话密钥才算启用 */static isEnabled(): boolean {return API_ENCRYPT_ENABLE && Boolean(currentSessionKey)}static encryptRequest(data: any): string {if (!ApiEncrypt.isEnabled()) return dataconst jsonData = typeof data === 'string' ? data : JSON.stringify(data)if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {return AES.encrypt(jsonData, currentSessionKey)}const result = RSA.encrypt(jsonData, currentSessionKey)if (result === false) throw new Error('RSA 加密失败')return result}static decryptResponse(encryptedData: string): any {if (!API_ENCRYPT_ENABLE) return encryptedDataif (!currentSessionKey) {throw new Error('会话密钥缺失,无法解密响应')}let decryptedData: string | false = ''if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {decryptedData = AES.decrypt(encryptedData, currentSessionKey)} else {decryptedData = RSA.decrypt(encryptedData, currentSessionKey)if (decryptedData === false) throw new Error('RSA 解密失败')}if (!decryptedData) throw new Error('解密结果为空')try {return JSON.parse(decryptedData)} catch {return decryptedData}}}
3.3 M3 src/http/interceptor.ts — 挂会话头
把原第 78–91 行的加密分支替换为:
// 5. API 加密(会话密钥)if (options.isEncrypt) {try {const sessionId = ApiEncrypt.getSessionId()if (sessionId) {options.header['X-Session-Id'] = sessionId}const wrapped = ApiEncrypt.takePendingWrappedKey()if (wrapped) {options.header['X-Session-Key'] = wrapped}if (options.data && ApiEncrypt.isEnabled()) {options.data = ApiEncrypt.encryptRequest(options.data)options.header[ApiEncrypt.getEncryptHeader()] = 'true'}} catch (error) {console.error('请求数据加密失败:', error)throw error}}
3.4 M4 src/api/login.ts — 登录前协商
import { ApiEncrypt, prepareSession } from '@/utils/encrypt'export function login(data: IAuthLoginReq) {// ★ 登录前生成会话密钥;RSA 密文由本次登录请求带出prepareSession()// ★ 必须显式带 isEncrypt,否则密码明文进 bodyreturn http.post<IAuthLoginRes>('/system/auth/login', data, null, null, { isEncrypt: true })}
3.5 M5 登出时清理
在 token/user store 的登出方法里补:
import { ApiEncrypt } from '@/utils/encrypt'// ...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
yudao:encrypt:enable: truealgorithm: AES# ★ 关键:清空固定密钥,杜绝回退。保留任何固定密钥 = 整个方案无效request-key: ""response-key: ""# ★ 新增:RSA 私钥,从配置中心/环境变量注入,绝不入库rsa-private-key: ${ENCRYPT_RSA_PRIVATE_KEY:}
生成密钥对(注意 OpenSSL 3 默认输出 PKCS#8,需确认格式与后端读取方式一致):
openssl genrsa -out encrypt_private.pem 2048# 后端用 PKCS8 读取时,转一次:openssl pkcs8 -topk8 -nocrypt -in encrypt_private.pem -out encrypt_private_pkcs8.pem# 公钥(给前端的 VITE_APP_RSA_PUBLIC_KEY,去掉头尾换行合并为一行)openssl rsa -in encrypt_private.pem -pubout -out encrypt_public.pem
4.2 B2 EncryptProperties.java
@ConfigurationProperties(prefix = "yudao.encrypt")@Datapublic class EncryptProperties {/** 是否开启加解密 */private Boolean enable;/** 加密算法:AES / RSA */private String algorithm;/*** 固定请求密钥 —— 方案二要求必须为空* 保留字段仅为兼容旧配置,运行时会校验它为空*/@Deprecatedprivate String requestKey;/** 固定响应密钥 —— 方案二要求必须为空 */@Deprecatedprivate String responseKey;/** RSA 私钥(Base64 编码的 PKCS#8,用于解开会话密钥) */private String rsaPrivateKey;@PostConstructpublic void validate() {if (Boolean.TRUE.equals(enable) && StrUtil.isNotBlank(requestKey)) {throw new IllegalStateException("[security] yudao.encrypt.request-key 必须为空:固定密钥会让会话密钥机制失效");}}}
@PostConstruct里的校验是刻意加的 —— 它让”忘记清空固定密钥”变成启动即失败, 而不是一个安静的安全漏洞。
4.3 B3 SessionCryptoUtils.java
public final class SessionCryptoUtils {/** 必须与前端 jsencrypt 对齐:PKCS1v1.5 */private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding";private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";private static final String AES_ALGORITHM = "AES";private SessionCryptoUtils() {}/*** 用 RSA 私钥解出会话密钥* @param base64Cipher 前端上送的 base64 密文* @param base64PrivateKey PKCS#8 私钥(Base64)* @return 会话密钥(32 字符);失败返回 null*/public static String rsaDecrypt(String base64Cipher, String base64PrivateKey) {if (StrUtil.isBlank(base64Cipher) || StrUtil.isBlank(base64PrivateKey)) {return null;}try {PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64PrivateKey.trim())));Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);cipher.init(Cipher.DECRYPT_MODE, privateKey);byte[] plain = cipher.doFinal(Base64.getDecoder().decode(base64Cipher));return new String(plain, StandardCharsets.UTF_8);} catch (Exception e) {log.warn("[security] 会话密钥 RSA 解密失败", e);return null;}}/** AES 解密(返回 UTF-8 明文) */public static String aesDecrypt(String base64Cipher, String key) {try {Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);cipher.init(Cipher.DECRYPT_MODE,new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM));byte[] plain = cipher.doFinal(Base64.getDecoder().decode(base64Cipher));return new String(plain, StandardCharsets.UTF_8);} catch (Exception e) {throw new ServiceException("请求数据解密失败");}}/** AES 加密(入参 UTF-8 明文,返回 Base64 密文) */public static String aesEncrypt(String plainText, String key) {try {Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);cipher.init(Cipher.ENCRYPT_MODE,new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM));byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));return Base64.getEncoder().encodeToString(encrypted);} catch (Exception e) {throw new ServiceException("响应数据加密失败");}}}
注意:
AES/ECB/PKCS5Padding与前端Pkcs7在 16 字节块下完全等价,可以互通。 我实测用 Node 的 PKCS7 加密、后端解密成功,已确认这一点。
4.4 B4 SessionKeyStore.java
@Componentpublic class SessionKeyStore {private static final String KEY_PREFIX = "encrypt:session:key:";/** 归属校验:防止 A 拿 B 的 sessionId 复用密钥 */private static final String OWNER_PREFIX = "encrypt:session:owner:";@Resourceprivate StringRedisTemplate stringRedisTemplate;public void bind(String sessionId, String aesKey, Long userId, Duration ttl) {stringRedisTemplate.opsForValue().set(KEY_PREFIX + sessionId, aesKey, ttl);if (userId != null) {stringRedisTemplate.opsForValue().set(OWNER_PREFIX + sessionId, String.valueOf(userId), ttl);}}public String getKey(String sessionId) {return stringRedisTemplate.opsForValue().get(KEY_PREFIX + sessionId);}public Long getOwner(String sessionId) {String v = stringRedisTemplate.opsForValue().get(OWNER_PREFIX + sessionId);return StrUtil.isBlank(v) ? null : Long.valueOf(v);}public void remove(String sessionId) {if (StrUtil.isBlank(sessionId)) {return;}stringRedisTemplate.delete(Arrays.asList(KEY_PREFIX + sessionId, OWNER_PREFIX + sessionId));}}
TTL 取值建议:与 accessToken 有效期一致(宇擎默认 30 分钟),并在 token 续期时同步续期。
4.5 B5 ApiEncryptFilter 改造要点
核心是把”密钥从配置取”换成”从会话取”。改动集中在密钥解析这一段:
/** 解析本次请求应使用的 AES 会话密钥 */private String resolveAesKey(HttpServletRequest request) {String sessionId = request.getHeader("X-Session-Id");String wrappedKey = request.getHeader("X-Session-Key");// ① 协商分支:带了 RSA 密文 → 解出会话密钥if (StrUtil.isNotBlank(wrappedKey)) {String aesKey = SessionCryptoUtils.rsaDecrypt(wrappedKey, encryptProperties.getRsaPrivateKey());if (StrUtil.isBlank(aesKey)) {throw new ServiceException("会话密钥协商失败");}// 暂存,供业务层(登录/协商接口)绑定request.setAttribute("ENCRYPT_SESSION_ID", sessionId);request.setAttribute("ENCRYPT_AES_KEY", aesKey);return aesKey;}// ② 已协商分支:按 sessionId 取if (StrUtil.isBlank(sessionId)) {throw new ServiceException("缺少会话标识 X-Session-Id");}String aesKey = sessionKeyStore.getKey(sessionId);if (StrUtil.isBlank(aesKey)) {// ★ 关键:不回退固定密钥。旧密钥在这里被彻底废掉throw new ServiceException("会话密钥不存在或已过期,请重新登录");}// ③ 归属校验Long owner = sessionKeyStore.getOwner(sessionId);Long currentUserId = SecurityFrameworkUtils.getLoginUserId();if (owner != null && currentUserId != null && !owner.equals(currentUserId)) {throw new ServiceException("会话标识与当前用户不匹配");}return aesKey;}
原 filter 中的触发条件、body 包装、响应包装保持不变,只需把
properties.getRequestKey() / properties.getResponseKey() 的取值处换成
resolveAesKey(request) 的返回值。
触发条件需放宽一处:原逻辑可能只在 X-Api-Encrypt: true 时进入,
现在协商请求可能不带该头(/session-key 接口、或只做密钥注册),所以要改成:
boolean shouldHandle = "true".equalsIgnoreCase(request.getHeader("X-Api-Encrypt"))|| StrUtil.isNotBlank(request.getHeader("X-Session-Key"));if (!shouldHandle) {chain.doFilter(request, response);return;}
4.6 B6 AuthController — 绑定与协商接口
@PostMapping("/login")public CommonResult<AuthLoginRespVO> login(@Valid @RequestBody AuthLoginReqVO reqVO,HttpServletRequest request) {AuthLoginRespVO resp = authService.login(reqVO);// ★ 把本次请求协商出的会话密钥绑定到会话bindSessionKey(request, resp.getUserId());return success(resp);}@PostMapping("/session-key")@Operation(summary = "重新协商会话密钥(页面刷新后调用)")public CommonResult<Boolean> renewSessionKey(HttpServletRequest request) {Long userId = SecurityFrameworkUtils.getLoginUserId();if (userId == null) {throw new ServiceException("账号未登录");}bindSessionKey(request, userId);return success(true);}private void bindSessionKey(HttpServletRequest request, Long userId) {String sessionId = (String) request.getAttribute("ENCRYPT_SESSION_ID");String aesKey = (String) request.getAttribute("ENCRYPT_AES_KEY");if (StrUtil.isBlank(sessionId) || StrUtil.isBlank(aesKey)) {return;}sessionKeyStore.bind(sessionId, aesKey, userId, Duration.ofSeconds(tokenExpireSeconds));}
/session-key需要能被SecurityFrameworkUtils识别为已登录 —— 它靠的是Authorization头里的 token,前端已在ensureSessionReady()里显式带上。
4.7 B7 登出 / 强退时清理
// AuthServiceImpl#logout 或 TokenServiceImpl#removeAccessToken 中String sessionId = request.getHeader("X-Session-Id");sessionKeyStore.remove(sessionId);
并在”踢下线 / 管理员强退”的路径里一并调用,避免悬挂密钥。
5. 后端单元测试(建议至少覆盖这 4 条)
@SpringBootTestclass SessionCryptoUtilsTest {/** 与前端 jsencrypt 产出的密文互通 */@Testvoid rsaDecrypt_shouldMatchFrontendJdk() { /* 用固定测试密钥对 + 前端生成的密文样本 */ }@Testvoid aesEncryptDecrypt_shouldRoundTrip() { }/** 旧固定密钥必须被拒绝 */@Testvoid fixedKeyRequest_shouldBeRejected() { }/** 会话密钥过期后必须被拒绝 */@Testvoid expiredSessionKey_shouldBeRejected() { }}
6. 上线顺序(必须按此执行)
阶段 0|准备- 后端生成 RSA 密钥对;公钥交付前端- 全部客户端代码改造完成、可发布阶段 1|后端先上(双轨,无感)- 部署支持 X-Session-Key 的后端- 但 【暂不清空】 request-key / response-key- 结果:新客户端走会话密钥,旧客户端继续走固定密钥,零影响- ★ 埋点:日志记录每次加密请求命中「会话密钥」还是「固定密钥」阶段 2|客户端逐个升级顺序建议:web-antd → web-ele → web-naive → web-tdesign → web-antdv-next→ dy-ui-admin-uniapp → yudao-ui-admin-uniapp每升一个,观察阶段 1 的埋点指标:走固定密钥的请求数应逐步下降阶段 3|关闭固定密钥(关键收官)- 确认"固定密钥"指标归零且稳定 ≥ 1 个工作日- 清空 yudao.encrypt.request-key / response-key- 重启后端(若有旧客户端残留,会立刻报"会话密钥不存在")- 用旧密钥构造请求应被拒绝阶段 4|轮换与收尾- 轮换 AES 密钥、地图 ak、admin 默认口令- 清理 git 历史中的 .env 密钥;删除 env 里的旧值- 删除产物扫描的豁免项,让卡口正式生效
顺序不可颠倒的两个点:
- 后端双轨必须先于前端发布 —— 否则新前端连不上;
- 关闭固定密钥必须晚于全部客户端升级 —— 否则小程序/旧 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 与旧客户端并存。
