diff --git a/env/.env b/env/.env index a2e6684..b7fc154 100644 --- a/env/.env +++ b/env/.env @@ -40,4 +40,11 @@ VITE_APP_CAPTCHA_ENABLE=false # 默认账户密码 VITE_APP_DEFAULT_LOGIN_TENANT_ID = 1 VITE_APP_DEFAULT_LOGIN_USERNAME = admin -VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123 \ No newline at end of file +VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123 + +# API 加解密 +VITE_APP_API_ENCRYPT_ENABLE = true +VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt +VITE_APP_API_ENCRYPT_ALGORITHM = AES +VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395 +VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883 \ No newline at end of file diff --git a/package.json b/package.json index ea7a96e..697e3fb 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "abortcontroller-polyfill": "^1.7.8", "crypto-js": "^4.2.0", "dayjs": "1.11.10", + "jsencrypt": "^3.5.4", "pinia": "2.0.36", "pinia-plugin-persistedstate": "3.2.1", "vue": "^3.4.21", diff --git a/src/http/http.ts b/src/http/http.ts index f134449..7d4ca00 100644 --- a/src/http/http.ts +++ b/src/http/http.ts @@ -3,6 +3,7 @@ import type { CustomRequestOptions, IResponse } from '@/http/types' import { nextTick } from 'vue' import { useTokenStore } from '@/store/token' import { getLastPage, isDoubleTokenMode } from '@/utils' +import { ApiEncrypt } from '@/utils/encrypt' import { toLoginPage } from '@/utils/toLoginPage' import { ResultEnum } from './tools/enum' @@ -21,9 +22,21 @@ export function http(options: CustomRequestOptions) { // #endif // 响应成功 success: async (res) => { - const responseData = res.data as IResponse - const { code } = responseData + let responseData = res.data as IResponse + // 检查是否需要解密响应数据 + const encryptHeader = ApiEncrypt.getEncryptHeader() + const isEncryptResponse = res.header[encryptHeader] === 'true' || res.header[encryptHeader.toLowerCase()] === 'true' + if (isEncryptResponse && typeof responseData === 'string') { + try { + // 解密响应数据 + responseData = ApiEncrypt.decryptResponse(responseData) + } catch (error) { + console.error('响应数据解密失败:', error) + throw new Error(`响应数据解密失败: ${(error as Error).message}`) + } + } + const { code } = responseData // 检查是否是401错误(包括HTTP状态码401或业务码401) const isTokenExpired = res.statusCode === 401 || code === 401 diff --git a/src/http/interceptor.ts b/src/http/interceptor.ts index da9ec78..baa0e6a 100644 --- a/src/http/interceptor.ts +++ b/src/http/interceptor.ts @@ -2,6 +2,7 @@ import type { CustomRequestOptions } from '@/http/types' import { useTokenStore, useUserStore } from '@/store' import { getEnvBaseUrl } from '@/utils' +import { ApiEncrypt } from '@/utils/encrypt' import { stringifyQuery } from './tools/queryString' // 请求基准地址 @@ -55,11 +56,13 @@ const httpInterceptor = { const tokenStore = useTokenStore() const token = tokenStore.validToken let isToken = (options!.header || {}).isToken === false - whiteList.some((v) => { + + for (const v of whiteList) { if (options.url && options.url.includes(v)) { - return (isToken = false) + isToken = false + break } - }) + } if (!isToken && token) { options.header.Authorization = `Bearer ${token}` } @@ -71,6 +74,22 @@ const httpInterceptor = { options.header['tenant-id'] = tenantId } } + + // 5. 是否 API 加密 + if (options.isEncrypt) { + try { + // 加密请求数据 + if (options.data) { + options.data = ApiEncrypt.encryptRequest(options.data) + // 设置加密标识头 + options.header[ApiEncrypt.getEncryptHeader()] = 'true' + } + } catch (error) { + console.error('请求数据加密失败:', error) + throw error + } + } + return options }, } diff --git a/src/http/types.ts b/src/http/types.ts index c72f449..178812c 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -7,6 +7,8 @@ export type CustomRequestOptions = UniApp.RequestOptions & { hideErrorToast?: boolean /** 是否返回原始数据 add by panda 25.12.10 */ original?: boolean + /** 是否API加密 add by panda 25.12.24 */ + isEncrypt?: boolean } & IUniUploadFileOptions // 添加uni.uploadFile参数类型 // 通用响应格式(兼容 msg + message 字段) diff --git a/src/utils/encrypt.ts b/src/utils/encrypt.ts new file mode 100644 index 0000000..41f4d12 --- /dev/null +++ b/src/utils/encrypt.ts @@ -0,0 +1,231 @@ +import CryptoJS from 'crypto-js' +import { JSEncrypt } from 'jsencrypt' + +/** + * API 加解密工具类 + * 支持 AES 和 RSA 加密算法 + */ + +// 从环境变量获取配置 +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 API_ENCRYPT_REQUEST_KEY = import.meta.env.VITE_APP_API_ENCRYPT_REQUEST_KEY || '' // AES密钥 或 RSA公钥 +const API_ENCRYPT_RESPONSE_KEY = import.meta.env.VITE_APP_API_ENCRYPT_RESPONSE_KEY || '' // AES密钥 或 RSA私钥 + +/** + * AES 加密工具类 + */ +export class AES { + /** + * AES 加密 + * @param data 要加密的数据 + * @param key 加密密钥 + * @returns 加密后的字符串 + */ + static encrypt(data: string, key: string): string { + try { + if (!key) { + throw new Error('AES 加密密钥不能为空') + } + if (key.length !== 32) { + throw new Error(`AES 加密密钥长度必须为 32 位,当前长度: ${key.length}`) + } + + const keyUtf8 = CryptoJS.enc.Utf8.parse(key) + const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }) + return encrypted.toString() + } catch (error) { + console.error('AES 加密失败:', error) + throw error + } + } + + /** + * AES 解密 + * @param encryptedData 加密的数据 + * @param key 解密密钥 + * @returns 解密后的字符串 + */ + static decrypt(encryptedData: string, key: string): string { + try { + if (!key) { + throw new Error('AES 解密密钥不能为空') + } + if (key.length !== 32) { + throw new Error(`AES 解密密钥长度必须为 32 位,当前长度: ${key.length}`) + } + if (!encryptedData) { + throw new Error('AES 解密数据不能为空') + } + + const keyUtf8 = CryptoJS.enc.Utf8.parse(key) + const decrypted = CryptoJS.AES.decrypt(encryptedData, keyUtf8, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7, + }) + const result = decrypted.toString(CryptoJS.enc.Utf8) + if (!result) { + throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏') + } + return result + } catch (error) { + console.error('AES 解密失败:', error) + throw error + } + } +} + +/** + * RSA 加密工具类 + */ +export class RSA { + /** + * RSA 加密 + * @param data 要加密的数据 + * @param publicKey 公钥(必需) + * @returns 加密后的字符串 + */ + static encrypt(data: string, publicKey: string): string | false { + 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 + } + } + + /** + * RSA 解密 + * @param encryptedData 加密的数据 + * @param privateKey 私钥(必需) + * @returns 解密后的字符串 + */ + static decrypt(encryptedData: string, privateKey: string): string | false { + 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 class ApiEncrypt { + /** + * 获取加密头名称 + */ + static getEncryptHeader(): string { + return API_ENCRYPT_HEADER + } + + /** + * 加密请求数据 + * @param data 要加密的数据 + * @returns 加密后的数据 + */ + static encryptRequest(data: any): string { + if (!API_ENCRYPT_ENABLE) { + return data + } + + try { + const jsonData = typeof data === 'string' ? data : JSON.stringify(data) + + if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') { + if (!API_ENCRYPT_REQUEST_KEY) { + throw new Error('AES 请求加密密钥未配置') + } + return AES.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY) + } else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') { + if (!API_ENCRYPT_REQUEST_KEY) { + throw new Error('RSA 公钥未配置') + } + const result = RSA.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY) + if (result === false) { + throw new Error('RSA 加密失败') + } + return result + } else { + throw new Error(`不支持的加密算法: ${API_ENCRYPT_ALGORITHM}`) + } + } catch (error) { + console.error('请求数据加密失败:', error) + throw error + } + } + + /** + * 解密响应数据 + * @param encryptedData 加密的响应数据 + * @returns 解密后的数据 + */ + static decryptResponse(encryptedData: string): any { + if (!API_ENCRYPT_ENABLE) { + return encryptedData + } + + try { + let decryptedData: string | false = '' + if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') { + if (!API_ENCRYPT_RESPONSE_KEY) { + throw new Error('AES 响应解密密钥未配置') + } + decryptedData = AES.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY) + } else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') { + if (!API_ENCRYPT_RESPONSE_KEY) { + throw new Error('RSA 私钥未配置') + } + decryptedData = RSA.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY) + if (decryptedData === false) { + throw new Error('RSA 解密失败') + } + } else { + throw new Error(`不支持的解密算法: ${API_ENCRYPT_ALGORITHM}`) + } + + if (!decryptedData) { + throw new Error('解密结果为空') + } + + // 尝试解析为 JSON,如果失败则返回原字符串 + try { + return JSON.parse(decryptedData) + } catch { + return decryptedData + } + } catch (error) { + console.error('响应数据解密失败:', error) + throw error + } + } +}