Bläddra i källkod

feat: 增加API请求可配置加密解支持

chenjiarong 2 månader sedan
förälder
incheckning
160be7ab29
9 ändrade filer med 340 tillägg och 8 borttagningar
  1. 10 0
      .env
  2. 11 1
      .env.development
  3. 10 0
      .env.production
  4. 10 0
      .env.test
  5. 2 0
      package.json
  6. 21 0
      pnpm-lock.yaml
  7. 58 0
      src/api/ENCRYPTION_CONFIG.md
  8. 68 7
      src/api/index.ts
  9. 150 0
      src/utils/crypto.ts

+ 10 - 0
.env

@@ -15,3 +15,13 @@ VITE_REPORT = false
 
 # 开启 CodeInspector 调试
 VITE_CODEINSPECTOR = false
+
+# 接口加密配置
+# 加密功能总开关
+VITE_API_ENCRYPTION_ENABLED = false
+
+# AES 密钥(16字节)
+VITE_API_ENCRYPTION_KEY = alien67890982316
+
+# AES 向量(16字节)
+VITE_API_ENCRYPTION_IV = 1234560405060708

+ 11 - 1
.env.development

@@ -23,5 +23,15 @@ VITE_API_URL_SECOND = /api/alienSecond
 VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
 # 开发环境跨域代理,支持配置多个
-VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
+# VITE_PROXY = [["/api","https://api.ailien.shop"]] #生产环境
 # VITE_PROXY = [["/api","http://192.168.2.57:7000"]] # 邹建宇
+
+# 接口加密配置
+# 加密功能总开关
+VITE_API_ENCRYPTION_ENABLED = false
+
+# AES 密钥(16字节)
+VITE_API_ENCRYPTION_KEY = alien67890982316
+
+# AES 向量(16字节)
+VITE_API_ENCRYPTION_IV = 1234560405060708

+ 10 - 0
.env.production

@@ -36,3 +36,13 @@ VITE_PROXY = [["/alienStore","http://120.26.186.130:8000/alienStore"]]
 
 # AI接口
 VITE_PROXY_AI = [["/ai-api","http://124.93.18.180:9000"]]
+
+# 接口加密配置
+# 加密功能总开关
+VITE_API_ENCRYPTION_ENABLED = false
+
+# AES 密钥(16字节)
+VITE_API_ENCRYPTION_KEY = alien67890982316
+
+# AES 向量(16字节)
+VITE_API_ENCRYPTION_IV = 1234560405060708

+ 10 - 0
.env.test

@@ -29,3 +29,13 @@ VITE_API_URL = /api
 
 # 生产环境跨域代理,支持配置多个
 VITE_PROXY = [["/api","http://localhost:8888"]]
+
+# 接口加密配置
+# 加密功能总开关
+VITE_API_ENCRYPTION_ENABLED = false
+
+# AES 密钥(16字节)
+VITE_API_ENCRYPTION_KEY = alien67890982316
+
+# AES 向量(16字节)
+VITE_API_ENCRYPTION_IV = 1234560405060708

+ 2 - 0
package.json

@@ -55,6 +55,7 @@
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.12",
     "axios": "^1.7.2",
+    "crypto-js": "^4.2.0",
     "dayjs": "^1.11.11",
     "driver.js": "^1.3.1",
     "echarts": "^5.5.1",
@@ -78,6 +79,7 @@
     "@commitlint/cli": "^18.4.3",
     "@commitlint/config-conventional": "^18.4.3",
     "@tailwindcss/vite": "^4.0.4",
+    "@types/crypto-js": "^4.2.2",
     "@types/md5": "^2.3.5",
     "@types/nprogress": "^0.2.3",
     "@types/qs": "^6.9.15",

+ 21 - 0
pnpm-lock.yaml

@@ -26,6 +26,9 @@ importers:
       axios:
         specifier: ^1.7.2
         version: 1.8.3
+      crypto-js:
+        specifier: ^4.2.0
+        version: 4.2.0
       dayjs:
         specifier: ^1.11.11
         version: 1.11.13
@@ -90,6 +93,9 @@ importers:
       '@tailwindcss/vite':
         specifier: ^4.0.4
         version: 4.0.14(vite@5.4.14(@types/node@22.13.10)(lightningcss@1.29.2)(sass@1.85.1)(terser@5.39.0))
+      '@types/crypto-js':
+        specifier: ^4.2.2
+        version: 4.2.2
       '@types/md5':
         specifier: ^2.3.5
         version: 2.3.5
@@ -216,6 +222,10 @@ importers:
       vue-tsc:
         specifier: ^2.0.22
         version: 2.2.8(typescript@5.8.2)
+    optionalDependencies:
+      '@rollup/rollup-linux-x64-gnu':
+        specifier: '*'
+        version: 4.35.0
 
 packages:
 
@@ -1428,6 +1438,9 @@ packages:
     resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
     engines: {node: '>=10.13.0'}
 
+  '@types/crypto-js@4.2.2':
+    resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
+
   '@types/eslint@8.56.12':
     resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==}
 
@@ -2273,6 +2286,9 @@ packages:
   crypt@0.0.2:
     resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
 
+  crypto-js@4.2.0:
+    resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
   crypto-random-string@2.0.0:
     resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
     engines: {node: '>=8'}
@@ -4655,6 +4671,7 @@ packages:
   source-map@0.8.0-beta.0:
     resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
     engines: {node: '>= 8'}
+    deprecated: The work that was done in this beta branch won't be included in future versions
 
   sourcemap-codec@1.4.8:
     resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@@ -6709,6 +6726,8 @@ snapshots:
 
   '@trysound/sax@0.2.0': {}
 
+  '@types/crypto-js@4.2.2': {}
+
   '@types/eslint@8.56.12':
     dependencies:
       '@types/estree': 1.0.6
@@ -7755,6 +7774,8 @@ snapshots:
 
   crypt@0.0.2: {}
 
+  crypto-js@4.2.0: {}
+
   crypto-random-string@2.0.0: {}
 
   css-functions-list@3.2.3: {}

+ 58 - 0
src/api/ENCRYPTION_CONFIG.md

@@ -0,0 +1,58 @@
+# 接口加密配置说明
+
+## 功能概述
+
+项目支持对接口请求参数进行 AES-128-CBC 加密,可根据全局配置或单个接口配置灵活控制加密开关。
+
+## 技术规格
+
+- **加密算法**: AES-128-CBC
+- **填充模式**: PKCS7
+- **密钥**: 16 字节 UTF-8 字符串
+- **向量**: 16 字节 UTF-8 字符串
+- **输出格式**: Base64 编码
+- **Content-Type**: application/json
+
+## 环境配置
+
+在 `.env`、`.env.development`、`.env.test`、`.env.production` 中配置:
+
+```bash
+# 接口加密功能总开关(默认 false)
+VITE_API_ENCRYPTION_ENABLED = false
+
+# AES 密钥(16 字节)
+VITE_API_ENCRYPTION_KEY = alien67890982316
+
+# AES 向量(16 字节)
+VITE_API_ENCRYPTION_IV = 1234560405060708
+```
+
+## 使用方式
+
+### 1. 全局控制
+
+设置 `VITE_API_ENCRYPTION_ENABLED = true`,所有接口自动加密。
+
+### 2. 接口级别控制
+
+在接口调用时通过第三个参数 `encrypt` 控制:
+
+```typescript
+// 强制加密(全局开关关闭时也生效)
+await api.post("/api/sensitive-endpoint", params, { encrypt: true });
+
+// 强制跳过加密(全局开关开启时也生效)
+await api.post("/api/public-endpoint", params, { encrypt: false });
+```
+
+## 配置优先级
+
+接口级别配置 > 全局配置
+
+## 安全建议
+
+1. 不同环境使用不同的密钥
+2. 生产环境密钥使用强随机生成
+3. 重要接口(登录、支付等)启用加密
+4. 加密会增加约 1-5ms 延迟

+ 68 - 7
src/api/index.ts

@@ -9,10 +9,12 @@ import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import { localGet } from "@/utils";
 import router from "@/routers";
+import { crypto } from "@/utils/crypto";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
+  encrypt?: boolean; // 是否加密请求参数
 }
 
 const config = {
@@ -26,6 +28,36 @@ const config = {
 
 const axiosCanceler = new AxiosCanceler();
 
+/**
+ * 判断是否应该加密请求
+ * @param config 请求配置
+ * @returns 是否加密
+ */
+function shouldEncrypt(config: CustomAxiosRequestConfig): boolean {
+  // 接口级别配置优先于全局配置
+  if (config.encrypt !== undefined) {
+    return config.encrypt;
+  }
+  // 检查全局加密开关
+  const globalEnabled = import.meta.env.VITE_API_ENCRYPTION_ENABLED === "true";
+  return globalEnabled && crypto.isConfigured();
+}
+
+/**
+ * 判断是否应该解密响应
+ * @param config 请求配置
+ * @param responseData 响应数据
+ * @returns 是否解密
+ */
+function shouldDecrypt(config: CustomAxiosRequestConfig, responseData: any): boolean {
+  // 只有在请求加密了,并且响应数据是字符串时才解密
+  if (!shouldEncrypt(config)) {
+    return false;
+  }
+  // 检查响应数据是否为字符串(Base64 编码的密文)
+  return typeof responseData === "string" && crypto.isConfigured();
+}
+
 class RequestHttp {
   service: AxiosInstance;
   public constructor(config: AxiosRequestConfig) {
@@ -49,6 +81,21 @@ class RequestHttp {
         if (config.headers && typeof config.headers.set === "function") {
           config.headers.set("Authorization", userStore.token);
         }
+        // 加密处理
+        if (shouldEncrypt(config) && config.data) {
+          try {
+            config.data = crypto.encrypt(config.data);
+            // 设置 Content-Type 为 application/json
+            if (config.headers && typeof config.headers.set === "function") {
+              config.headers.set("Content-Type", "application/json");
+            }
+          } catch (error) {
+            console.error("加密失败:", error);
+            // 加密失败时可以选择降级为明文传输或抛出错误
+            // 这里选择降级为明文传输,以避免影响业务
+            return Promise.reject(new Error("请求参数加密失败"));
+          }
+        }
         return config;
       },
       (error: AxiosError) => {
@@ -67,20 +114,34 @@ class RequestHttp {
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+        // 解密处理
+        if (shouldDecrypt(config, data)) {
+          try {
+            const decryptedData = crypto.decrypt(data);
+            // 将解密后的数据赋值给 data
+            // @ts-expect-error - 解密后的数据类型可能与原始类型不同
+            response.data = decryptedData;
+          } catch (error) {
+            console.error("解密失败:", error);
+            ElMessage.error("响应数据解密失败");
+            return Promise.reject(new Error("响应数据解密失败"));
+          }
+        }
+        const processedData = response.data;
         // 登录失效
-        if (data.code == ResultEnum.OVERDUE) {
+        if (processedData.code == ResultEnum.OVERDUE) {
           userStore.setToken("");
           router.replace(LOGIN_URL);
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
-        if (data.code && data.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+        if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
-        return data;
+        return processedData;
       },
       async (error: AxiosError) => {
         const { response } = error;

+ 150 - 0
src/utils/crypto.ts

@@ -0,0 +1,150 @@
+import CryptoJS from "crypto-js";
+
+/**
+ * 加密配置接口
+ */
+export interface CryptoConfig {
+  key: string;
+  iv: string;
+}
+
+/**
+ * AES 加密工具类
+ * 使用 AES-128-CBC 算法进行加解密
+ */
+export class CryptoUtils {
+  private static readonly KEY_LENGTH = 16;
+  private static readonly IV_LENGTH = 16;
+
+  /**
+   * 验证密钥和向量的长度
+   * @param key AES 密钥(16字节)
+   * @param iv AES 向量(16字节)
+   * @returns 是否有效
+   */
+  static validateConfig(key: string, iv: string): boolean {
+    return key.length === this.KEY_LENGTH && iv.length === this.IV_LENGTH;
+  }
+
+  /**
+   * 加密数据
+   * @param data 待加密的数据(对象或字符串)
+   * @param key AES 密钥(16字节)
+   * @param iv AES 向量(16字节)
+   * @returns Base64 编码的加密字符串
+   */
+  static encrypt(data: any, key: string, iv: string): string {
+    if (!this.validateConfig(key, iv)) {
+      throw new Error(`Invalid crypto config: key must be ${this.KEY_LENGTH} bytes, iv must be ${this.IV_LENGTH} bytes`);
+    }
+
+    try {
+      // 将对象转换为 JSON 字符串
+      const plainText = typeof data === "object" ? JSON.stringify(data) : String(data);
+
+      // 将密钥和向量转换为 WordArray
+      const keyWordArray = CryptoJS.enc.Utf8.parse(key);
+      const ivWordArray = CryptoJS.enc.Utf8.parse(iv);
+
+      // 使用 AES-128-CBC 加密
+      const encrypted = CryptoJS.AES.encrypt(plainText, keyWordArray, {
+        iv: ivWordArray,
+        mode: CryptoJS.mode.CBC,
+        padding: CryptoJS.pad.Pkcs7
+      });
+
+      // 返回 Base64 编码的密文
+      return encrypted.toString();
+    } catch (error) {
+      throw new Error(`Encryption failed: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+
+  /**
+   * 解密数据
+   * @param ciphertext Base64 编码的密文
+   * @param key AES 密钥(16字节)
+   * @param iv AES 向量(16字节)
+   * @returns 解密后的数据(对象或字符串)
+   */
+  static decrypt(ciphertext: string, key: string, iv: string): any {
+    if (!this.validateConfig(key, iv)) {
+      throw new Error(`Invalid crypto config: key must be ${this.KEY_LENGTH} bytes, iv must be ${this.IV_LENGTH} bytes`);
+    }
+
+    try {
+      // 将密钥和向量转换为 WordArray
+      const keyWordArray = CryptoJS.enc.Utf8.parse(key);
+      const ivWordArray = CryptoJS.enc.Utf8.parse(iv);
+
+      // 使用 AES-128-CBC 解密
+      const decrypted = CryptoJS.AES.decrypt(ciphertext, keyWordArray, {
+        iv: ivWordArray,
+        mode: CryptoJS.mode.CBC,
+        padding: CryptoJS.pad.Pkcs7
+      });
+
+      // 将解密结果转换为 UTF-8 字符串
+      const result = decrypted.toString(CryptoJS.enc.Utf8);
+
+      // 如果解密结果为空,返回空字符串
+      if (!result) {
+        return "";
+      }
+
+      // 尝试解析为 JSON 对象
+      try {
+        return JSON.parse(result);
+      } catch (e) {
+        // 如果不是 JSON,则直接返回字符串
+        return result;
+      }
+    } catch (error) {
+      throw new Error(`Decryption failed: ${error instanceof Error ? error.message : String(error)}`);
+    }
+  }
+}
+
+/**
+ * 默认加密工具实例
+ */
+export const crypto = {
+  /**
+   * 加密数据(使用默认配置)
+   */
+  encrypt(data: any, config?: Partial<CryptoConfig>): string {
+    const key = config?.key || import.meta.env.VITE_API_ENCRYPTION_KEY || "";
+    const iv = config?.iv || import.meta.env.VITE_API_ENCRYPTION_IV || "";
+
+    if (!key || !iv) {
+      console.warn("Encryption keys not configured, returning plaintext");
+      return typeof data === "object" ? JSON.stringify(data) : String(data);
+    }
+
+    return CryptoUtils.encrypt(data, key, iv);
+  },
+
+  /**
+   * 解密数据(使用默认配置)
+   */
+  decrypt(ciphertext: string, config?: Partial<CryptoConfig>): any {
+    const key = config?.key || import.meta.env.VITE_API_ENCRYPTION_KEY || "";
+    const iv = config?.iv || import.meta.env.VITE_API_ENCRYPTION_IV || "";
+
+    if (!key || !iv) {
+      console.warn("Decryption keys not configured, returning ciphertext");
+      return ciphertext;
+    }
+
+    return CryptoUtils.decrypt(ciphertext, key, iv);
+  },
+
+  /**
+   * 检查是否启用了加密配置
+   */
+  isConfigured(): boolean {
+    const key = import.meta.env.VITE_API_ENCRYPTION_KEY || "";
+    const iv = import.meta.env.VITE_API_ENCRYPTION_IV || "";
+    return CryptoUtils.validateConfig(key, iv);
+  }
+};