From d9c93285ade76676b6cfda81dcdc5acf5338ee20 Mon Sep 17 00:00:00 2001 From: dap <15891557205@163.com> Date: Fri, 22 Aug 2025 22:20:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=E5=8A=A0=E5=AF=86=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=B7=B2=E7=BB=8F=E5=AE=9E=E7=8E=B0RSA/SM2=20AES/SM4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + apps/web-antd/package.json | 3 - apps/web-antd/src/api/request.ts | 72 ++++++++++++----- apps/web-antd/src/utils/encryption/crypto.ts | 80 ------------------- .../src/utils/encryption/jsencrypt.ts | 31 ------- packages/utils/package.json | 7 ++ packages/utils/src/encryption/base.ts | 71 ++++++++++++++++ packages/utils/src/encryption/crypto.ts | 30 +++++++ packages/utils/src/encryption/impl/aes.ts | 28 +++++++ packages/utils/src/encryption/impl/rsa.ts | 30 +++++++ packages/utils/src/encryption/impl/sm2.ts | 33 ++++++++ packages/utils/src/encryption/impl/sm4.ts | 37 +++++++++ packages/utils/src/encryption/index.ts | 6 ++ packages/utils/src/index.ts | 1 + 14 files changed, 295 insertions(+), 136 deletions(-) delete mode 100644 apps/web-antd/src/utils/encryption/crypto.ts delete mode 100644 apps/web-antd/src/utils/encryption/jsencrypt.ts create mode 100644 packages/utils/src/encryption/base.ts create mode 100644 packages/utils/src/encryption/crypto.ts create mode 100644 packages/utils/src/encryption/impl/aes.ts create mode 100644 packages/utils/src/encryption/impl/rsa.ts create mode 100644 packages/utils/src/encryption/impl/sm2.ts create mode 100644 packages/utils/src/encryption/impl/sm4.ts create mode 100644 packages/utils/src/encryption/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67771dd0..4fb15188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Tinymce 保存图片id 提供`contentWithOssIdTransform`来显示私有桶图片 - Vxe/Antd可编辑表格适配器(样式等) 解决浮层元素点击与失焦冲突导致无法选中的问题 - 工作流 后端发起流程demo +- API加密 前端已经实现RSA/SM2 AES/SM4 **REFACTOR** @@ -22,6 +23,7 @@ - oss下载 重构为浏览器原生下载(非阻塞) - 流程相关样式更新 - 请假申请 表单更改为drawer方式 替换新页面打开 +- API加密 迁移到@vben/utils下 **OTHERS** diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index 4eb7f164..0dce84c4 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -45,10 +45,8 @@ "@vueuse/core": "catalog:", "ant-design-vue": "catalog:", "cropperjs": "^1.6.2", - "crypto-js": "^4.2.0", "dayjs": "catalog:", "echarts": "^5.5.1", - "jsencrypt": "^3.3.2", "lodash-es": "^4.17.21", "pinia": "catalog:", "tinymce": "^7.3.0", @@ -58,7 +56,6 @@ "vue3-colorpicker": "^2.3.0" }, "devDependencies": { - "@types/crypto-js": "^4.2.2", "@types/lodash-es": "^4.17.12" } } diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 6c60b608..da719815 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -3,6 +3,10 @@ */ import type { HttpResponse } from '@vben/request'; +import type { + BaseAsymmetricEncryption, + BaseSymmetricEncryption, +} from '@vben/utils'; import { BUSINESS_SUCCESS_CODE, UNAUTHORIZED_CODE } from '@vben/constants'; import { useAppConfig } from '@vben/hooks'; @@ -15,26 +19,44 @@ import { stringify, } from '@vben/request'; import { useAccessStore } from '@vben/stores'; +import { + AesEncryption, + decodeBase64, + encodeBase64, + randomStr, + RsaEncryption, +} from '@vben/utils'; import { message, Modal } from 'ant-design-vue'; import { isEmpty, isNull } from 'lodash-es'; import { useAuthStore } from '#/store'; -import { - decryptBase64, - decryptWithAes, - encryptBase64, - encryptWithAes, - generateAesKey, -} from '#/utils/encryption/crypto'; -import * as encryptUtil from '#/utils/encryption/jsencrypt'; import { handleUnauthorizedLogout } from './helper'; -const { apiURL, clientId, enableEncrypt } = useAppConfig( - import.meta.env, - import.meta.env.PROD, -); +const { apiURL, clientId, enableEncrypt, rsaPublicKey, rsaPrivateKey } = + useAppConfig(import.meta.env, import.meta.env.PROD); + +/** + * 使用非对称加密的实现 前端已经实现RSA/SM2 + * + * 你可以使用Sm2Encryption来替换 后端也需要同步替换公私钥对 + * + * 后端文件位置: ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java + * + * 注意前端sm-crypto库只能支持04开头的公钥! 否则加密会有问题 你可以使用前端的import { logSm2KeyPair } from '@vben/utils';方法来生成 + * 如果你生成的公钥开头不是04 那么不能正常加密 + * 或者使用这个网站来生成: https://tool.hiofd.com/sm2-key-gen/ + */ +const asymmetricEncryption: BaseAsymmetricEncryption = new RsaEncryption({ + publicKey: rsaPublicKey, + privateKey: rsaPrivateKey, +}); + +/** + * 对称加密的实现 AES/SM4 + */ +const symmetricEncryption: BaseSymmetricEncryption = new AesEncryption(); function createRequestClient(baseURL: string) { const client = new RequestClient({ @@ -120,15 +142,21 @@ function createRequestClient(baseURL: string) { encrypt && ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') ) { - const aesKey = generateAesKey(); - config.headers['encrypt-key'] = encryptUtil.encrypt( - encryptBase64(aesKey), - ); + // sm4这里改为randomStr(16) + const key = randomStr(32); + const keyWithBase64 = encodeBase64(key); + config.headers['encrypt-key'] = + asymmetricEncryption.encrypt(keyWithBase64); + /** + * axios会默认给字符串前后加上引号 RSA可以正常解密(加不加都能解密) 但是SM2不行(大坑!!!) + * 这里通过transformRequest强制返回原始内容 + */ + config.transformRequest = (data) => data; config.data = typeof config.data === 'object' - ? encryptWithAes(JSON.stringify(config.data), aesKey) - : encryptWithAes(config.data, aesKey); + ? symmetricEncryption.encrypt(JSON.stringify(config.data), key) + : symmetricEncryption.encrypt(config.data, key); } return config; }, @@ -145,13 +173,13 @@ function createRequestClient(baseURL: string) { const encryptKey = (response.headers ?? {})['encrypt-key']; if (encryptKey) { /** RSA私钥解密 拿到解密秘钥的base64 */ - const base64Str = encryptUtil.decrypt(encryptKey); + const base64Str = asymmetricEncryption.decrypt(encryptKey); /** base64 解码 得到请求头的 AES 秘钥 */ - const aesSecret = decryptBase64(base64Str.toString()); + const secret = decodeBase64(base64Str); /** 使用aesKey解密 responseData */ - const decryptData = decryptWithAes( + const decryptData = symmetricEncryption.decrypt( response.data as unknown as string, - aesSecret, + secret, ); /** 赋值 需要转为对象 */ response.data = JSON.parse(decryptData); diff --git a/apps/web-antd/src/utils/encryption/crypto.ts b/apps/web-antd/src/utils/encryption/crypto.ts deleted file mode 100644 index 34696d1a..00000000 --- a/apps/web-antd/src/utils/encryption/crypto.ts +++ /dev/null @@ -1,80 +0,0 @@ -import CryptoJS from 'crypto-js'; - -function randomUUID() { - const chars = [ - ...'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', - ]; - const uuid = Array.from({ length: 36 }); - let rnd = 0; - let r: number; - for (let i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) { - uuid[i] = '-'; - } else if (i === 14) { - uuid[i] = '4'; - } else { - if (rnd <= 0x02) - rnd = Math.trunc(0x2_00_00_00 + Math.random() * 0x1_00_00_00); - r = rnd & 16; - rnd = rnd >> 4; - uuid[i] = chars[i === 19 ? (r & 0x3) | 0x8 : r]; - } - } - return uuid.join('').replaceAll('-', '').toLowerCase(); -} - -/** - * 随机生成aes 密钥 - * - * @returns aes 密钥 - */ -export function generateAesKey() { - return CryptoJS.enc.Utf8.parse(randomUUID()); -} - -/** - * base64编码 - * @param str - * @returns base64编码 - */ -export function encryptBase64(str: CryptoJS.lib.WordArray) { - return CryptoJS.enc.Base64.stringify(str); -} - -/** - * 使用公钥加密 - * @param message 加密内容 - * @param aesKey aesKey - * @returns 使用公钥加密 - */ -export function encryptWithAes( - message: string, - aesKey: CryptoJS.lib.WordArray, -) { - const encrypted = CryptoJS.AES.encrypt(message, aesKey, { - mode: CryptoJS.mode.ECB, - padding: CryptoJS.pad.Pkcs7, - }); - return encrypted.toString(); -} - -/** - * 解密base64 - */ -export function decryptBase64(str: string) { - return CryptoJS.enc.Base64.parse(str); -} - -/** - * 使用密钥对数据进行解密 - */ -export function decryptWithAes( - message: string, - aesKey: CryptoJS.lib.WordArray, -) { - const decrypted = CryptoJS.AES.decrypt(message, aesKey, { - mode: CryptoJS.mode.ECB, - padding: CryptoJS.pad.Pkcs7, - }); - return decrypted.toString(CryptoJS.enc.Utf8); -} diff --git a/apps/web-antd/src/utils/encryption/jsencrypt.ts b/apps/web-antd/src/utils/encryption/jsencrypt.ts deleted file mode 100644 index 2e1e6a6b..00000000 --- a/apps/web-antd/src/utils/encryption/jsencrypt.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 密钥对生成 http://web.chacuo.net/netrsakeypair -import { useAppConfig } from '@vben/hooks'; - -import JSEncrypt from 'jsencrypt'; - -const { rsaPrivateKey, rsaPublicKey } = useAppConfig( - import.meta.env, - import.meta.env.PROD, -); - -/** - * 加密 - * @param txt 需要加密的数据 - * @returns 加密后的数据 - */ -export function encrypt(txt: string) { - const instance = new JSEncrypt(); - instance.setPublicKey(rsaPublicKey); - return instance.encrypt(txt); -} - -/** - * 解密 - * @param txt 需要解密的数据 - * @returns 解密后的数据 - */ -export function decrypt(txt: string) { - const instance = new JSEncrypt(); - instance.setPrivateKey(rsaPrivateKey); - return instance.decrypt(txt); -} diff --git a/packages/utils/package.json b/packages/utils/package.json index 811f64c8..b6f56cd0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,7 +22,14 @@ "dependencies": { "@vben-core/shared": "workspace:*", "@vben-core/typings": "workspace:*", + "crypto-js": "^4.2.0", "file-type": "^19.5.0", + "jsencrypt": "^3.5.4", + "sm-crypto": "^0.3.13", "vue-router": "catalog:" + }, + "devDependencies": { + "@types/crypto-js": "^4.2.2", + "@types/sm-crypto": "^0.3.4" } } diff --git a/packages/utils/src/encryption/base.ts b/packages/utils/src/encryption/base.ts new file mode 100644 index 00000000..723f34ef --- /dev/null +++ b/packages/utils/src/encryption/base.ts @@ -0,0 +1,71 @@ +export interface EncryptionOptions { + /** + * 私钥 + */ + privateKey: string; + + /** + * 公钥 + */ + publicKey: string; +} + +/** + * 非对称加解密 抽象类 + * 提供基本的加密和解密功能接口 + */ +export abstract class BaseAsymmetricEncryption { + /** + * 私钥 + */ + protected privateKey: string; + + /** + * 公钥 + */ + protected publicKey: string; + + /** + * 构造函数 + * @param options 加解密选项,包含公钥和私钥 + */ + constructor(options: EncryptionOptions) { + this.publicKey = options.publicKey; + this.privateKey = options.privateKey; + } + + /** + * 解密方法 + * @param encryptedData 解密后的数据 + * @returns 解密后的原始数据 + */ + abstract decrypt(encryptedData: string): string; + + /** + * 加密方法 + * @param data 需要加密的数据 + * @returns 加密后的数据 + */ + abstract encrypt(data: string): string; +} + +/** + * 对称加解密抽象类 + */ +export abstract class BaseSymmetricEncryption { + /** + * 解密方法 + * @param data 解密后的数据 + * @param key 密钥 + * @returns 解密后的原始数据 + */ + abstract decrypt(data: string, key: string): string; + + /** + * 加密方法 + * @param data 需要加密的数据 + * @param key 密钥 + * @returns 加密后的数据 + */ + abstract encrypt(data: string, key: string): string; +} diff --git a/packages/utils/src/encryption/crypto.ts b/packages/utils/src/encryption/crypto.ts new file mode 100644 index 00000000..5e938f0a --- /dev/null +++ b/packages/utils/src/encryption/crypto.ts @@ -0,0 +1,30 @@ +import CryptoJS from 'crypto-js'; + +/** + * 随机字符串 + * + * @returns str + */ +export function randomStr(length = 32) { + const str = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + let result = ''; + for (let i = length; i > 0; --i) + result += str[Math.floor(Math.random() * str.length)]; + return result; +} + +/** + * base64编码 + * @param str + * @returns base64编码 + */ +export function encodeBase64(str: string) { + return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(str)); +} + +/** + * 解码base64 + */ +export function decodeBase64(str: string) { + return CryptoJS.enc.Base64.parse(str).toString(); +} diff --git a/packages/utils/src/encryption/impl/aes.ts b/packages/utils/src/encryption/impl/aes.ts new file mode 100644 index 00000000..d71625b4 --- /dev/null +++ b/packages/utils/src/encryption/impl/aes.ts @@ -0,0 +1,28 @@ +import CryptoJS from 'crypto-js'; + +import { BaseSymmetricEncryption } from '../base'; + +/** + * AES 实现 + */ +export class AesEncryption extends BaseSymmetricEncryption { + override decrypt(data: string, key: string): string { + // 必须格式化字符串才能正常使用 + const aesKey = CryptoJS.enc.Utf8.parse(key); + const decrypted = CryptoJS.AES.decrypt(data, aesKey, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }); + return decrypted.toString(CryptoJS.enc.Utf8); + } + + override encrypt(data: string, key: string): string { + // 必须格式化字符串才能正常使用 + const aesKey = CryptoJS.enc.Utf8.parse(key); + const encrypted = CryptoJS.AES.encrypt(data, aesKey, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }); + return encrypted.toString(); + } +} diff --git a/packages/utils/src/encryption/impl/rsa.ts b/packages/utils/src/encryption/impl/rsa.ts new file mode 100644 index 00000000..f55f09e9 --- /dev/null +++ b/packages/utils/src/encryption/impl/rsa.ts @@ -0,0 +1,30 @@ +import JSEncrypt from 'jsencrypt'; + +import { BaseAsymmetricEncryption } from '../base'; + +/** + * RSA 实现 + */ +export class RsaEncryption extends BaseAsymmetricEncryption { + override decrypt(str: string): string { + const instance = new JSEncrypt(); + instance.setPrivateKey(this.privateKey); + const ret = instance.decrypt(str); + + if (ret === false) { + throw new Error('RsaEncryption decrypt error'); + } + + return ret; + } + + override encrypt(str: string): string { + const instance = new JSEncrypt(); + instance.setPublicKey(this.publicKey); + const ret = instance.encrypt(str); + if (ret === false) { + throw new Error('RsaEncryption encrypt error'); + } + return ret; + } +} diff --git a/packages/utils/src/encryption/impl/sm2.ts b/packages/utils/src/encryption/impl/sm2.ts new file mode 100644 index 00000000..3ebc49f7 --- /dev/null +++ b/packages/utils/src/encryption/impl/sm2.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-console */ +import { sm2 } from 'sm-crypto'; + +import { BaseAsymmetricEncryption } from '../base'; + +/** + * SM2 实现 + * 注意生成的公钥必须为04开头 或者使用下面的generateSm2KeyPair生成 + * @see https://tool.hiofd.com/sm2-key-gen/ 这里可以生成04开头的SM2密钥对 + */ +export class Sm2Encryption extends BaseAsymmetricEncryption { + override decrypt(str: string): string { + return sm2.doDecrypt(str, this.privateKey); + } + + override encrypt(str: string): string { + return sm2.doEncrypt(str, this.publicKey); + } +} + +export function generateSm2KeyPair() { + const { privateKey, publicKey } = sm2.generateKeyPairHex(); + return { + privateKey, + publicKey, + }; +} + +export function logSm2KeyPair() { + const { privateKey, publicKey } = generateSm2KeyPair(); + console.log('privateKey', privateKey); + console.log('publicKey', publicKey); +} diff --git a/packages/utils/src/encryption/impl/sm4.ts b/packages/utils/src/encryption/impl/sm4.ts new file mode 100644 index 00000000..6995c442 --- /dev/null +++ b/packages/utils/src/encryption/impl/sm4.ts @@ -0,0 +1,37 @@ +import CryptoJS from 'crypto-js'; +import { sm4 } from 'sm-crypto'; + +import { BaseSymmetricEncryption } from '../base'; + +/** + * SM4 实现 + */ +export class Sm4Encryption extends BaseSymmetricEncryption { + override decrypt(data: string, key: string): string { + this.checkKey(key); + const keyHex = CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(key)); + return sm4.decrypt(data, keyHex); + } + + override encrypt(data: string, key: string): string { + this.checkKey(key); + /** + * 转hex字符串 + * encrypt方法的key需要为`16进制字符串`而非`原始字符串` + * 比如字符串ab a为0x61 b为0x62 转字符串为 6162 + */ + const keyHex = CryptoJS.enc.Hex.stringify(CryptoJS.enc.Utf8.parse(key)); + + return sm4.encrypt(data, keyHex); + } + + /** + * key长度只能为16位字符串 + * @param key key + */ + private checkKey(key: string) { + if (key.length !== 16) { + throw new Error('SM4 key must be 16 bytes'); + } + } +} diff --git a/packages/utils/src/encryption/index.ts b/packages/utils/src/encryption/index.ts new file mode 100644 index 00000000..2075725b --- /dev/null +++ b/packages/utils/src/encryption/index.ts @@ -0,0 +1,6 @@ +export * from './base'; +export * from './crypto'; +export * from './impl/aes'; +export * from './impl/rsa'; +export * from './impl/sm2'; +export * from './impl/sm4'; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index dd84fa79..85faa218 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ +export * from './encryption'; export * from './helpers'; export * from '@vben-core/shared/cache'; export * from '@vben-core/shared/color';