diff --git a/README.md b/README.md index ffe74ee..7aeb7ac 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,11 @@ RuoYi-Vue-FastAPI是一套全部开源的快速开发平台,毫无保留给个 12. 定时任务:在线(添加、修改、删除)任务调度包含执行结果日志。 13. 服务监控:监视当前系统CPU、内存、磁盘、堆栈等相关信息。 14. 缓存监控:对系统的缓存信息查询,命令统计等。 -15. 在线构建器:拖动表单元素生成相应的HTML代码。 -16. 系统接口:根据业务代码自动生成相关的api接口文档。 -17. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。 -18. AI管理:提供AI模型管理和AI对话功能。 +15. 传输加密:支持前后端请求加密、响应解密、公钥轮换、运行策略下发与监控统计。 +16. 在线构建器:拖动表单元素生成相应的HTML代码。 +17. 系统接口:根据业务代码自动生成相关的api接口文档。 +18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。 +19. AI管理:提供AI模型管理和AI对话功能。 ## 演示图 @@ -183,6 +184,10 @@ RuoYi-Vue-FastAPI是一套全部开源的快速开发平台,毫无保留给个 ## 项目开发及发布相关 +### 传输层加解密配置说明 + +后端密钥配置与轮换说明:[ruoyi-fastapi-backend/docs/transport_crypto_config.md](./ruoyi-fastapi-backend/docs/transport_crypto_config.md) + ### 开发 ```bash diff --git a/ruoyi-fastapi-app/package.json b/ruoyi-fastapi-app/package.json index 7a9eb10..2c3ecdc 100644 --- a/ruoyi-fastapi-app/package.json +++ b/ruoyi-fastapi-app/package.json @@ -75,6 +75,7 @@ "@weapp-tailwindcss/merge-v3": "^0.1.5", "core-js": "^3.42.0", "flyio": "^0.6.2", + "node-forge": "^1.4.0", "regenerator-runtime": "^0.14.1", "vue": "~2.6.14", "vuex": "^3.2.0" diff --git a/ruoyi-fastapi-app/src/utils/request.js b/ruoyi-fastapi-app/src/utils/request.js index 37616b0..c51db79 100644 --- a/ruoyi-fastapi-app/src/utils/request.js +++ b/ruoyi-fastapi-app/src/utils/request.js @@ -1,77 +1,138 @@ -import store from "@/store"; -import config from "@/config"; -import { getToken } from "@/utils/auth"; -import errorCode from "@/utils/errorCode"; -import { toast, showConfirm, tansParams } from "@/utils/common"; - -let timeout = 10000; -const baseUrl = config.baseUrl; - -const request = (config) => { - // 是否需要设置 token - const isToken = (config.headers || {}).isToken === false; - config.header = config.header || {}; - if (getToken() && !isToken) { - config.header["Authorization"] = "Bearer " + getToken(); - } - // get请求映射params参数 - if (config.params) { - let url = config.url + "?" + tansParams(config.params); - url = url.slice(0, -1); - config.url = url; - } - return new Promise((resolve, reject) => { - uni - .request({ - method: config.method || "get", - timeout: config.timeout || timeout, - url: config.baseUrl || baseUrl + config.url, - data: config.data, - header: config.header, - dataType: "json", - }) - .then((response) => { - let [error, res] = response; - if (error) { - toast("后端接口连接异常"); - reject("后端接口连接异常"); - return; - } - const code = res.data.code || 200; - const msg = errorCode[code] || res.data.msg || errorCode["default"]; - if (code === 401) { - showConfirm( - "登录状态已过期,您可以继续留在该页面,或者重新登录?", - ).then((res) => { - if (res.confirm) { - store.dispatch("LogOut").then((res) => { - uni.reLaunch({ url: "/pages/login" }); - }); - } - }); - reject("无效的会话,或者会话已过期,请重新登录。"); - } else if (code === 500) { - toast(msg); - reject("500"); - } else if (code !== 200) { - toast(msg); - reject(code); - } - resolve(res.data); - }) - .catch((error) => { - let { message } = error; - if (message === "Network Error") { - message = "后端接口连接异常"; - } else if (message.includes("timeout")) { - message = "系统接口请求超时"; - } else if (message.includes("Request failed with status code")) { - message = "系统接口" + message.substr(message.length - 3) + "异常"; - } - toast(message); - reject(error); - }); - }); -}; - -export default request; +import store from "@/store"; +import config from "@/config"; +import { getToken } from "@/utils/auth"; +import errorCode from "@/utils/errorCode"; +import { toast, showConfirm, tansParams } from "@/utils/common"; +import { + decryptTransportErrorResponse, + decryptTransportResponse, + encryptTransportRequest, + invalidateTransportKeyMeta, + resetTransportRequestConfig, + shouldRetryTransportWithFreshKey, +} from "@/utils/transportCrypto"; + +let timeout = 10000; +const baseUrl = config.baseUrl; + +/** + * 对外暴露的统一请求方法。 + * + * @param {Object} config 请求配置 + * @returns {Promise} 业务响应数据 + */ +const request = async (config) => { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false; + config.header = config.header || {}; + config.headers = config.headers || {}; + if (getToken() && !isToken) { + config.header["Authorization"] = "Bearer " + getToken(); + } + + try { + // 在参数拼接前完成传输层加密,避免明文查询串提前写入 URL。 + config = await encryptTransportRequest(config); + + // get请求映射params参数 + if (config.params) { + let url = config.url + "?" + tansParams(config.params); + url = url.slice(0, -1); + config.url = url; + } + + return await new Promise((resolve, reject) => { + uni.request({ + method: config.method || "get", + timeout: config.timeout || timeout, + url: config.baseUrl || baseUrl + config.url, + data: config.data, + header: config.header, + dataType: "json", + success: async (response) => { + try { + // 命中传输层加密时,先解密成标准业务响应结构。 + const res = await decryptTransportResponse(response, config); + + // 若当前响应提示密钥失效,则刷新公钥缓存并基于原始请求重试一次。 + if (shouldRetryTransportWithFreshKey(res) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + resolve(await request(config)); + return; + } + + const code = res.data.code || 200; + const msg = errorCode[code] || res.data.msg || errorCode["default"]; + if (code === 401) { + showConfirm( + "登录状态已过期,您可以继续留在该页面,或者重新登录?", + ).then((res) => { + if (res.confirm) { + store.dispatch("LogOut").then(() => { + uni.reLaunch({ url: "/pages/login" }); + }); + } + }); + const error = new Error("无效的会话,或者会话已过期,请重新登录。"); + error.response = res; + reject(error); + } else if (code === 500) { + const error = new Error(msg); + error.response = res; + reject(error); + } else if (code !== 200) { + const error = new Error(msg); + error.response = res; + reject(error); + } else { + resolve(res.data); + } + } catch (error) { + reject(error); + } + }, + fail: reject, + }); + }); + } catch (error) { + error = await decryptTransportErrorResponse(error, config); + if (shouldRetryTransportWithFreshKey(error) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + return request(config); + } + + const response = error.response; + const responseStatus = response?.status ?? response?.statusCode; + const responseCode = response?.data?.code; + const responseMsg = response?.data?.msg; + if (responseMsg) { + uni.showToast({ + title: responseMsg, + icon: "none", + duration: responseStatus === 429 || responseCode === 429 ? 5000 : 3000, + }); + throw error; + } + + let { message } = error; + if (message === "Network Error") { + message = "后端接口连接异常"; + } else if (message && message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message && message.includes("Request failed with status code")) { + message = "系统接口" + message.substr(message.length - 3) + "异常"; + } + if (message) { + toast(message); + } + throw error; + } +}; + +export default request; diff --git a/ruoyi-fastapi-app/src/utils/transportCrypto.js b/ruoyi-fastapi-app/src/utils/transportCrypto.js new file mode 100644 index 0000000..0251aa6 --- /dev/null +++ b/ruoyi-fastapi-app/src/utils/transportCrypto.js @@ -0,0 +1,1236 @@ +import config from "@/config"; +import forge, { primeForgeRandomBytes } from "@/utils/transportForge"; +import { + ensureTransportCryptoPolicyLoaded, + getTransportCryptoPolicy, + getTransportRequestPath, + shouldEncryptQuery, + shouldEncryptRequest, + shouldEncryptResponse, +} from "@/utils/transportCryptoPolicy"; + +const TRANSPORT_BASE_URL = config.baseUrl; +const TRANSPORT_ENABLE_HEADER = "X-Transport-Encrypt"; +const TRANSPORT_KEY_ID_HEADER = "X-Key-Id"; +const ENCRYPTED_RESPONSE_HEADER = "x-body-encrypted"; +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = "1"; +const KEY_REFRESH_BUFFER_MIN_SECONDS = 30; +const KEY_REFRESH_BUFFER_MAX_SECONDS = 300; +const TRANSPORT_KEY_META_CACHE_KEY = "transportCryptoKeyMeta"; +const TRANSPORT_RETRYABLE_ERROR_MESSAGES = new Set([ + "Decryption failed", + "密钥版本不存在", +]); +const AES_GCM_TAG_LENGTH_BYTES = 16; +const FORGE_RANDOM_POOL_BYTES = 4096; + +let cachedKeyMeta = null; +let inflightKeyMetaPromise = null; + +/** + * 获取当前运行时的全局对象引用。 + * + * @returns {Object|undefined} 全局对象 + */ +function getRuntimeGlobal() { + if (typeof globalThis !== "undefined") { + return globalThis; + } + if (typeof self !== "undefined") { + return self; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof global !== "undefined") { + return global; + } + return undefined; +} + +/** + * 为 Forge 补齐运行时依赖的全局对象别名。 + * + * @returns {void} + */ +function ensureForgeRuntimeGlobal() { + const runtimeGlobal = getRuntimeGlobal(); + if (!runtimeGlobal) { + return; + } + if (typeof runtimeGlobal.self === "undefined") { + runtimeGlobal.self = runtimeGlobal; + } + if (typeof runtimeGlobal.window === "undefined") { + runtimeGlobal.window = runtimeGlobal; + } + if (typeof runtimeGlobal.global === "undefined") { + runtimeGlobal.global = runtimeGlobal; + } +} + +/** + * 获取已完成运行时适配的 Forge 实例。 + * + * @returns {Promise} Forge 实例 + */ +async function getForge() { + ensureForgeRuntimeGlobal(); + return forge; +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined; + } + return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()]; +} + +/** + * 为请求头对象设置指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @param {*} value 请求头值 + * @returns {void} + */ +function setHeaderValue(headers, name, value) { + if (!headers) { + return; + } + headers[name] = value; +} + +/** + * 获取 Uni 响应对象中的响应头映射,兼容 H5 与其它平台字段差异。 + * + * @param {Object} response Uni 响应对象 + * @returns {Object|undefined} 响应头对象 + */ +function getResponseHeaders(response) { + return response?.header || response?.headers; +} + +/** + * 将文本编码为 UTF-8 二进制字符串。 + * + * @param {string} text 原始文本 + * @returns {string} UTF-8 二进制字符串 + */ +function encodeUtf8(text) { + if (typeof TextEncoder !== "undefined") { + return uint8ArrayToBytes(new TextEncoder().encode(String(text || ""))); + } + return unescape(encodeURIComponent(String(text || ""))); +} + +/** + * 将 UTF-8 二进制字符串解码为文本。 + * + * @param {string} bytes UTF-8 二进制字符串 + * @returns {string} 解码后的文本 + */ +function decodeUtf8(bytes) { + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(bytesToUint8Array(bytes)); + } + return decodeURIComponent(escape(bytes)); +} + +/** + * 将 Uint8Array 转为 Forge 兼容的二进制字符串。 + * + * @param {Uint8Array} uint8Array 字节数组 + * @returns {string} 二进制字符串 + */ +function uint8ArrayToBytes(uint8Array) { + return Array.from(uint8Array, (item) => String.fromCharCode(item)).join(""); +} + +/** + * 将二进制字符串还原为 Uint8Array。 + * + * @param {string} bytes 二进制字符串 + * @returns {Uint8Array} 字节数组 + */ +function bytesToUint8Array(bytes) { + return Uint8Array.from(String(bytes || ""), (item) => item.charCodeAt(0)); +} + +/** + * 获取对象的内部类型标签。 + * + * @param {*} value 待检测值 + * @returns {string} 内部类型标签 + */ +function getObjectTag(value) { + return Object.prototype.toString.call(value); +} + +/** + * 判断对象是否为 ArrayBuffer 视图。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否为视图对象 + */ +function isArrayBufferView(value) { + return typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(value); +} + +/** + * 判断对象是否表现为 ArrayBuffer。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否可按 ArrayBuffer 处理 + */ +function isArrayBufferLike(value) { + return ( + value instanceof ArrayBuffer || + getObjectTag(value) === "[object ArrayBuffer]" || + (value && + typeof value === "object" && + typeof value.byteLength === "number" && + typeof value.slice === "function" && + !("length" in value)) + ); +} + +/** + * 判断对象是否为数组风格的字节容器。 + * + * @param {*} value 待检测值 + * @returns {boolean} 是否可按字节数组处理 + */ +function isByteArrayLikeObject(value) { + return ( + value && + typeof value === "object" && + typeof value.length === "number" && + value.length >= 0 + ); +} + +/** + * 尝试通过平台 Base64 API 归一化原生 ArrayBuffer 对象。 + * + * @param {*} randomValues 平台返回的随机数字节对象 + * @returns {string|null} 归一化后的字节串,失败时返回 null + */ +function tryNormalizeArrayBufferLikeResult(randomValues) { + const arrayBufferToBase64Api = + (typeof uni !== "undefined" && uni.arrayBufferToBase64) || + (typeof wx !== "undefined" && wx.arrayBufferToBase64); + const base64ToArrayBufferApi = + (typeof uni !== "undefined" && uni.base64ToArrayBuffer) || + (typeof wx !== "undefined" && wx.base64ToArrayBuffer); + + if (!arrayBufferToBase64Api || !base64ToArrayBufferApi) { + return null; + } + + try { + const base64Text = arrayBufferToBase64Api(randomValues); + const arrayBuffer = base64ToArrayBufferApi(base64Text); + return uint8ArrayToBytes(new Uint8Array(arrayBuffer)); + } catch (error) { + return null; + } +} + +/** + * 将平台返回的随机数字节结果统一转换为二进制字符串。 + * + * @param {*} randomValues 平台返回结果 + * @param {number} expectedLength 期望字节长度 + * @returns {string} 归一化后的二进制字符串 + */ +function normalizeRandomBytesResult(randomValues, expectedLength) { + if (randomValues instanceof Uint8Array) { + return uint8ArrayToBytes(randomValues); + } + if (isArrayBufferView(randomValues)) { + return uint8ArrayToBytes( + new Uint8Array( + randomValues.buffer, + randomValues.byteOffset || 0, + randomValues.byteLength, + ), + ); + } + if (randomValues instanceof ArrayBuffer) { + return uint8ArrayToBytes(new Uint8Array(randomValues)); + } + if (Array.isArray(randomValues)) { + return uint8ArrayToBytes(Uint8Array.from(randomValues)); + } + if (randomValues && typeof randomValues === "object") { + if (randomValues.randomValues) { + return normalizeRandomBytesResult( + randomValues.randomValues, + expectedLength, + ); + } + if (randomValues.value) { + return normalizeRandomBytesResult(randomValues.value, expectedLength); + } + if (randomValues.data) { + return normalizeRandomBytesResult(randomValues.data, expectedLength); + } + if (randomValues.buffer instanceof ArrayBuffer) { + return normalizeRandomBytesResult( + new Uint8Array( + randomValues.buffer, + randomValues.byteOffset || 0, + randomValues.byteLength || expectedLength, + ), + expectedLength, + ); + } + if (isArrayBufferLike(randomValues)) { + const normalizedBytes = tryNormalizeArrayBufferLikeResult(randomValues); + if (normalizedBytes !== null) { + return normalizedBytes; + } + } + if (isByteArrayLikeObject(randomValues)) { + return uint8ArrayToBytes(Uint8Array.from(randomValues)); + } + } + if (typeof randomValues === "string" && randomValues.length === expectedLength) { + return randomValues; + } + throw new Error( + `平台随机数返回结果格式不受支持: tag=${getObjectTag(randomValues)}, keys=${Object.keys( + randomValues || {}, + ).join(",")}`, + ); +} + +/** + * 请求平台随机数字节并统一为二进制字符串。 + * + * @param {Function} api 平台随机数 API + * @param {number} length 需要的字节长度 + * @returns {Promise} 随机数字节串 + */ +function requestPlatformRandomBytes(api, length) { + return new Promise((resolve, reject) => { + let settled = false; + const resolveOnce = (result) => { + if (settled) { + return; + } + settled = true; + resolve(normalizeRandomBytesResult(result, length)); + }; + const rejectOnce = (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + try { + const maybeResult = api({ + length, + success: resolveOnce, + fail: rejectOnce, + }); + if (maybeResult && typeof maybeResult.then === "function") { + maybeResult.then(resolveOnce).catch(rejectOnce); + return; + } + if ( + maybeResult && + (maybeResult.randomValues || maybeResult.value || maybeResult.data) + ) { + resolveOnce(maybeResult); + } + } catch (error) { + rejectOnce(error); + } + }); +} + +/** + * 在 app-plus iOS 端通过原生 NSUUID 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机数字节串,当前平台不支持时返回 null + */ +function getAppPlusIosRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "ios" || typeof plus.ios?.importClass !== "function") { + return null; + } + + try { + const uuidClass = plus.ios.importClass("NSUUID"); + let randomBytes = ""; + while (randomBytes.length < length) { + const uuidObject = plus.ios.invoke(uuidClass, "UUID"); + const uuidText = String(plus.ios.invoke(uuidObject, "UUIDString") || "") + .replace(/-/g, "") + .toLowerCase(); + plus.ios.deleteObject(uuidObject); + if (!uuidText) { + return null; + } + for (let index = 0; index < uuidText.length && randomBytes.length < length; index += 2) { + const byteValue = parseInt(uuidText.slice(index, index + 2), 16); + if (isNaN(byteValue)) { + return null; + } + randomBytes += String.fromCharCode(byteValue); + } + } + return randomBytes; + } catch (error) { + console.warn("iOS NSUUID 初始化失败,继续尝试其它随机数能力", error); + } + // #endif + return null; +} + +/** + * 在 app-plus Android 端通过原生 SecureRandom 获取随机字节。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机数字节串,当前平台不支持时返回 null + */ +function getAppPlusAndroidRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "android" || typeof plus.android?.importClass !== "function") { + return null; + } + + try { + plus.android.importClass("java.security.SecureRandom"); + const secureRandom = + typeof plus.android.newObject === "function" + ? plus.android.newObject("java.security.SecureRandom") + : null; + if (!secureRandom) { + return null; + } + plus.android.importClass(secureRandom); + let randomBytes = ""; + for (let index = 0; index < length; index += 1) { + randomBytes += String.fromCharCode(secureRandom.nextInt(256)); + } + if (typeof plus.android.autoCollection === "function") { + plus.android.autoCollection(secureRandom); + } + return randomBytes; + } catch (error) { + console.warn("Android SecureRandom 初始化失败,继续尝试其它随机数能力", error); + } + // #endif + return null; +} + +/** + * 获取当前运行环境下的安全随机数字节。 + * + * @param {number} length 需要的字节长度 + * @returns {Promise} 随机数字节串 + */ +async function getRandomBytes(length) { + const runtimeGlobal = getRuntimeGlobal(); + const runtimeCrypto = runtimeGlobal?.crypto; + if (runtimeCrypto?.getRandomValues) { + const bytes = new Uint8Array(length); + runtimeCrypto.getRandomValues(bytes); + return uint8ArrayToBytes(bytes); + } + if (typeof uni !== "undefined" && typeof uni.getRandomValues === "function") { + return requestPlatformRandomBytes(uni.getRandomValues, length); + } + if (typeof wx !== "undefined" && typeof wx.getRandomValues === "function") { + return requestPlatformRandomBytes(wx.getRandomValues, length); + } + const appPlusRandomBytes = + getAppPlusAndroidRandomBytes(length) || getAppPlusIosRandomBytes(length); + if (appPlusRandomBytes !== null) { + return appPlusRandomBytes; + } + throw new Error("当前运行环境缺少安全随机数能力"); +} + +/** + * 预填充 Forge 需要的随机数字节池。 + * + * @param {number} length 预填充长度 + * @returns {Promise} + */ +async function primeForgeRandomPool(length = FORGE_RANDOM_POOL_BYTES) { + primeForgeRandomBytes(await getRandomBytes(length)); +} + +/** + * 将二进制字符串编码为 Base64URL 文本。 + * + * @param {string} bytes 二进制字符串 + * @returns {string} Base64URL 文本 + */ +function toBase64Url(bytes) { + const base64Text = uni.arrayBufferToBase64(bytesToUint8Array(bytes).buffer); + return base64Text.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +/** + * 将 Base64URL 文本还原为二进制字符串。 + * + * @param {string} text Base64URL 文本 + * @returns {string} 二进制字符串 + */ +function fromBase64Url(text) { + const normalizedText = String(text || "").replace(/-/g, "+").replace(/_/g, "/"); + const paddingLength = (4 - (normalizedText.length % 4 || 4)) % 4; + const arrayBuffer = uni.base64ToArrayBuffer( + normalizedText + "=".repeat(paddingLength), + ); + return uint8ArrayToBytes(new Uint8Array(arrayBuffer)); +} + +/** + * 将查询信封编码为可放入 URL 的字符串。 + * + * @param {Object} envelope 查询信封 + * @returns {string} 编码后的查询参数值 + */ +function encodeQueryEnvelope(envelope) { + return toBase64Url(encodeUtf8(JSON.stringify(envelope))); +} + +/** + * 计算加密查询参数最终生成的 URL 长度。 + * + * @param {string} url 请求地址 + * @param {Object} params 查询参数 + * @returns {number} URL 长度 + */ +function buildQueryUrlLength(url = "", params = {}) { + const queryText = Object.keys(params) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join("&"); + if (!queryText) { + return String(url || "").length; + } + const normalizedUrl = String(url || ""); + const separator = normalizedUrl.includes("?") ? "&" : "?"; + return `${normalizedUrl}${separator}${queryText}`.length; +} + +/** + * 构建请求方向的 AAD 元数据。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 请求 AAD + */ +function buildRequestAad(requestConfig) { + return { + method: (requestConfig.method || "get").toUpperCase(), + path: getTransportRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ), + }; +} + +/** + * 构建响应方向的 AAD 元数据。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 响应 AAD + */ +function buildResponseAad(requestConfig) { + return { + method: (requestConfig.method || "get").toUpperCase(), + path: getTransportRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ), + direction: "response", + }; +} + +/** + * 将空值载荷规范化为可序列化对象。 + * + * @param {*} payload 原始载荷 + * @returns {*} 规范化后的载荷 + */ +function normalizePlainPayload(payload) { + if (payload === undefined || payload === null) { + return {}; + } + return payload; +} + +/** + * 将请求载荷序列化为 JSON 文本。 + * + * @param {*} payload 原始载荷 + * @returns {string} JSON 文本 + */ +function stringifyPayload(payload) { + return JSON.stringify(normalizePlainPayload(payload)); +} + +/** + * 克隆请求配置中的可变字段,便于失败重试恢复。 + * + * @param {*} value 待克隆值 + * @returns {*} 克隆结果 + */ +function cloneRequestValue(value) { + if (value === undefined || value === null) { + return value; + } + const runtimeGlobal = getRuntimeGlobal(); + if (typeof runtimeGlobal?.structuredClone === "function") { + return runtimeGlobal.structuredClone(value); + } + if (typeof value === "object") { + return JSON.parse(JSON.stringify(value)); + } + return value; +} + +/** + * 将信封字段转换为适合表单提交的字符串。 + * + * @param {*} value 字段值 + * @returns {string} 序列化文本 + */ +function stringifyEnvelopeField(value) { + if (value && typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +/** + * 将加密信封编码为 x-www-form-urlencoded 文本。 + * + * @param {Object} envelope 信封对象 + * @returns {string} 表单编码文本 + */ +function encodeFormEnvelope(envelope) { + return Object.entries(envelope) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent( + stringifyEnvelopeField(value), + )}`, + ) + .join("&"); +} + +/** + * 将输入解析为 JSON 对象并校验结构。 + * + * @param {*} payload 原始数据 + * @param {string} errorMessage 失败提示 + * @returns {Object} 解析后的对象 + */ +function parseJsonObject(payload, errorMessage) { + const parsedPayload = typeof payload === "string" ? JSON.parse(payload) : payload; + if (!parsedPayload || typeof parsedPayload !== "object" || Array.isArray(parsedPayload)) { + throw new Error(errorMessage); + } + return parsedPayload; +} + +/** + * 基于随机字节生成 UUID v4 字符串。 + * + * @returns {Promise} UUID 文本 + */ +async function createUuid() { + const bytes = (await getRandomBytes(16)) + .split("") + .map((item) => item.charCodeAt(0)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hexText = bytes + .map((item) => item.toString(16).padStart(2, "0")) + .join(""); + return `${hexText.slice(0, 8)}-${hexText.slice(8, 12)}-${hexText.slice( + 12, + 16, + )}-${hexText.slice(16, 20)}-${hexText.slice(20)}`; +} + +/** + * 校验公钥接口响应壳是否有效。 + * + * @param {Object} responsePayload 公钥接口原始响应 + * @returns {void} + */ +function validateTransportPublicKeyResponse(responsePayload) { + if ( + responsePayload?.code !== 200 || + !responsePayload?.data || + typeof responsePayload.data !== "object" + ) { + throw new Error(responsePayload?.msg || "获取传输层公钥失败"); + } +} + +/** + * 校验公钥业务载荷是否满足当前协议要求。 + * + * @param {Object} payload 公钥业务载荷 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateTransportPublicKeyPayload(payload, transportPolicy) { + if (!payload?.publicKey || !payload?.kid) { + throw new Error("获取传输层公钥失败"); + } + if ( + String(payload.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION) !== + transportPolicy.envelopeVersion + ) { + throw new Error("传输层公钥协议版本不受支持"); + } + if (payload.alg !== transportPolicy.requestEnvelopeAlgorithm) { + throw new Error("传输层公钥算法不受支持"); + } +} + +/** + * 校验响应信封与当前请求上下文是否一致。 + * + * @param {Object} envelope 响应信封 + * @param {Object} response 原始响应对象 + * @param {Object} requestConfig 请求配置 + * @param {Object} transportContext 请求加密上下文 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, +) { + const expectedAad = buildResponseAad(requestConfig); + const responseKid = getHeaderValue( + getResponseHeaders(response), + TRANSPORT_KEY_ID_HEADER, + ); + const aad = envelope.aad; + + if (String(envelope.v || "") !== transportPolicy.envelopeVersion) { + throw new Error("传输层响应协议版本不受支持"); + } + if (String(envelope.alg || "") !== transportPolicy.responseEnvelopeAlgorithm) { + throw new Error("传输层响应算法不受支持"); + } + if (String(envelope.kid || "") !== String(transportContext.kid)) { + throw new Error("传输层响应密钥版本不匹配"); + } + if (responseKid && String(envelope.kid) !== String(responseKid)) { + throw new Error("传输层响应头与响应体密钥版本不一致"); + } + if (!aad || typeof aad !== "object" || Array.isArray(aad)) { + throw new Error("传输层响应AAD不合法"); + } + if ( + String(aad.method || "").toUpperCase() !== expectedAad.method || + String(aad.path || "") !== expectedAad.path + ) { + throw new Error("传输层响应的method/path与当前请求不匹配"); + } + if (String(aad.direction || "") !== expectedAad.direction) { + throw new Error("传输层响应方向标识不合法"); + } +} + +/** + * 记录原始请求快照,供密钥刷新后重试恢复。 + * + * @param {Object} requestConfig 请求配置 + * @returns {void} + */ +function rememberOriginalRequestSnapshot(requestConfig) { + if (requestConfig.__transportOriginalSnapshot) { + return; + } + requestConfig.__transportOriginalSnapshot = { + url: requestConfig.url, + params: cloneRequestValue(requestConfig.params), + data: cloneRequestValue(requestConfig.data), + contentType: getHeaderValue(requestConfig.header, "Content-Type"), + }; +} + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * 根据公钥有效期计算本地提前刷新时间。 + * + * @param {number} expireAt 公钥过期时间 + * @param {number} fetchedAt 公钥获取时间 + * @returns {number} 建议刷新时间 + */ +function buildKeyRefreshAt(expireAt, fetchedAt = getNowTimestamp()) { + const normalizedExpireAt = Number(expireAt || 0); + const normalizedFetchedAt = Number(fetchedAt || 0); + const ttlSeconds = Math.max(normalizedExpireAt - normalizedFetchedAt, 0); + if (!normalizedExpireAt || !ttlSeconds) { + return 0; + } + const refreshBufferSeconds = Math.min( + KEY_REFRESH_BUFFER_MAX_SECONDS, + Math.max(KEY_REFRESH_BUFFER_MIN_SECONDS, Math.floor(ttlSeconds * 0.1)), + ); + return Math.max(normalizedFetchedAt, normalizedExpireAt - refreshBufferSeconds); +} + +/** + * 判断当前缓存的公钥元数据是否仍可使用。 + * + * @param {Object} keyMeta 公钥元数据 + * @param {number} nowTimestamp 当前时间戳 + * @returns {boolean} 是否仍可使用 + */ +function isUsableKeyMeta(keyMeta, nowTimestamp = getNowTimestamp()) { + if (!keyMeta?.publicKeyPem || !keyMeta?.kid || !keyMeta?.expireAt) { + return false; + } + const refreshAt = Number( + keyMeta.refreshAt || + buildKeyRefreshAt(keyMeta.expireAt, keyMeta.fetchedAt || nowTimestamp), + ); + return refreshAt > nowTimestamp; +} + +/** + * 请求后端公钥接口。 + * 这里故意直接使用原始 uni.request,避免在获取公钥前再次进入统一 request + * 包装器而形成“加密请求依赖公钥、公钥请求又依赖加密请求”的启动环路。 + * + * @param {string} publicKeyUrl 公钥接口地址 + * @returns {Promise} Uni 请求响应 + */ +function requestPublicKey(publicKeyUrl) { + return new Promise((resolve, reject) => { + uni.request({ + url: `${TRANSPORT_BASE_URL}${publicKeyUrl}`, + method: "GET", + timeout: 10000, + success: resolve, + fail: reject, + }); + }); +} + +/** + * 获取当前可用的后端公钥元信息。 + * + * @param {boolean} forceRefresh 是否强制刷新 + * @returns {Promise} 公钥元信息 + */ +async function getTransportKeyMeta(forceRefresh = false) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded(); + const nowTimestamp = getNowTimestamp(); + if (!forceRefresh && !cachedKeyMeta) { + const persistedKeyMeta = uni.getStorageSync(TRANSPORT_KEY_META_CACHE_KEY); + if (isUsableKeyMeta(persistedKeyMeta, nowTimestamp)) { + cachedKeyMeta = { + kid: persistedKeyMeta.kid, + alg: persistedKeyMeta.alg, + envelopeVersion: + persistedKeyMeta.envelopeVersion || transportPolicy.envelopeVersion, + publicKeyPem: persistedKeyMeta.publicKeyPem, + expireAt: persistedKeyMeta.expireAt, + fetchedAt: persistedKeyMeta.fetchedAt || nowTimestamp, + refreshAt: + persistedKeyMeta.refreshAt || + buildKeyRefreshAt( + persistedKeyMeta.expireAt, + persistedKeyMeta.fetchedAt || nowTimestamp, + ), + }; + } + } + if (!forceRefresh && isUsableKeyMeta(cachedKeyMeta, nowTimestamp)) { + return cachedKeyMeta; + } + if (inflightKeyMetaPromise) { + return inflightKeyMetaPromise; + } + inflightKeyMetaPromise = requestPublicKey( + transportPolicy.publicKeyUrl || "/transport/crypto/public-key", + ) + .then((response) => { + const responsePayload = response.data || {}; + const payload = responsePayload.data || {}; + const fetchedAt = getNowTimestamp(); + validateTransportPublicKeyResponse(responsePayload); + validateTransportPublicKeyPayload(payload, transportPolicy); + cachedKeyMeta = { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String( + payload.envelopeVersion || transportPolicy.envelopeVersion, + ), + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt), + }; + uni.setStorageSync(TRANSPORT_KEY_META_CACHE_KEY, cachedKeyMeta); + inflightKeyMetaPromise = null; + return cachedKeyMeta; + }) + .catch((error) => { + inflightKeyMetaPromise = null; + throw error; + }); + return inflightKeyMetaPromise; +} + +/** + * 使用 RSA-OAEP 加密当前请求的 AES 会话密钥。 + * + * @param {string} publicKeyPem PEM 格式公钥 + * @param {string} aesKeyBytes AES 会话密钥字节串 + * @returns {Promise} 加密后的会话密钥 + */ +async function rsaEncryptAesKey(publicKeyPem, aesKeyBytes) { + const cryptoForge = await getForge(); + await primeForgeRandomPool(); + const publicKey = cryptoForge.pki.publicKeyFromPem(String(publicKeyPem || "")); + return publicKey.encrypt(aesKeyBytes, "RSA-OAEP", { + md: cryptoForge.md.sha256.create(), + mgf1: { + md: cryptoForge.md.sha256.create(), + }, + }); +} + +/** + * 为当前请求构建一次性的传输加密上下文。 + * + * @returns {Promise} 请求级传输上下文 + */ +async function buildTransportContext() { + const keyMeta = await getTransportKeyMeta(); + const aesKey = await getRandomBytes(32); + const encryptedAesKey = await rsaEncryptAesKey(keyMeta.publicKeyPem, aesKey); + return { + kid: keyMeta.kid, + alg: keyMeta.alg, + envelopeVersion: keyMeta.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + aesKey, + ek: toBase64Url(encryptedAesKey), + }; +} + +/** + * 使用 AES-GCM 对明文载荷执行信封加密。 + * + * @param {Object} context 请求级传输上下文 + * @param {string} plainText 明文内容 + * @param {Object} aad AAD 元数据 + * @returns {Promise} 加密信封 + */ +async function encryptPayloadText(context, plainText, aad) { + const cryptoForge = await getForge(); + const iv = await getRandomBytes(12); + const cipher = cryptoForge.cipher.createCipher("AES-GCM", context.aesKey); + cipher.start({ + iv, + additionalData: encodeUtf8(JSON.stringify(aad)), + tagLength: AES_GCM_TAG_LENGTH_BYTES * 8, + }); + cipher.update(cryptoForge.util.createBuffer(encodeUtf8(plainText))); + if (!cipher.finish()) { + throw new Error("Encryption failed"); + } + const ciphertext = cipher.output.getBytes() + cipher.mode.tag.getBytes(); + return { + v: context.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + kid: context.kid, + alg: context.alg, + ts: getNowTimestamp(), + nonce: await createUuid(), + ek: context.ek, + aad, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext), + }; +} + +/** + * 使用请求上下文中的 AES 密钥解密响应信封。 + * + * @param {Object} envelope 响应信封 + * @param {Object} context 请求级传输上下文 + * @returns {Promise} 解密后的明文 + */ +async function decryptEnvelope(envelope, context) { + const cryptoForge = await getForge(); + const encryptedPayload = fromBase64Url(envelope.ct); + if (encryptedPayload.length <= AES_GCM_TAG_LENGTH_BYTES) { + throw new Error("Decryption failed"); + } + const ciphertext = encryptedPayload.slice(0, -AES_GCM_TAG_LENGTH_BYTES); + const tag = encryptedPayload.slice(-AES_GCM_TAG_LENGTH_BYTES); + const decipher = cryptoForge.cipher.createDecipher("AES-GCM", context.aesKey); + decipher.start({ + iv: fromBase64Url(envelope.iv), + additionalData: encodeUtf8(JSON.stringify(envelope.aad || {})), + tagLength: AES_GCM_TAG_LENGTH_BYTES * 8, + tag, + }); + decipher.update(cryptoForge.util.createBuffer(ciphertext)); + if (!decipher.finish()) { + throw new Error("Decryption failed"); + } + return decodeUtf8(decipher.output.getBytes()); +} + +/** + * 复用同一次请求内的传输上下文,避免重复生成密钥。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 请求级传输上下文 + */ +function getOrCreateTransportContext(requestConfig) { + if (requestConfig.__transportCryptoContextPromise) { + return requestConfig.__transportCryptoContextPromise; + } + requestConfig.__transportCryptoContextPromise = buildTransportContext(); + return requestConfig.__transportCryptoContextPromise; +} + +/** + * 对 Uni 请求配置执行传输层加密封装。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 加密后的请求配置 + */ +export async function encryptTransportRequest(requestConfig) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded(); + if (!shouldEncryptRequest(requestConfig, transportPolicy)) { + requestConfig.__transportCryptoEnabledForRequest = false; + return requestConfig; + } + + rememberOriginalRequestSnapshot(requestConfig); + const transportContext = await getOrCreateTransportContext(requestConfig); + const contentType = String( + getHeaderValue(requestConfig.header, "Content-Type") || "application/json", + ).toLowerCase(); + const method = (requestConfig.method || "get").toLowerCase(); + const requestAad = buildRequestAad(requestConfig); + + if ( + shouldEncryptQuery(requestConfig, transportPolicy) && + (requestConfig.params || method === "get" || method === "delete") + ) { + const queryEnvelope = await encryptPayloadText( + transportContext, + JSON.stringify(normalizePlainPayload(requestConfig.params)), + requestAad, + ); + requestConfig.params = { __enc: encodeQueryEnvelope(queryEnvelope) }; + if ( + buildQueryUrlLength(requestConfig.url, requestConfig.params) > + Number(transportPolicy.maxEncryptedGetUrlLength || 4096) + ) { + throw new Error( + "当前GET/DELETE请求参数加密后长度超限,请改用POST请求或精简查询条件", + ); + } + } + + if (["post", "put", "patch", "delete"].includes(method)) { + const plainText = stringifyPayload(requestConfig.data); + const bodyEnvelope = await encryptPayloadText( + transportContext, + plainText, + requestAad, + ); + if (contentType.includes("application/x-www-form-urlencoded")) { + requestConfig.data = encodeFormEnvelope(bodyEnvelope); + } else { + requestConfig.data = bodyEnvelope; + setHeaderValue( + requestConfig.header, + "Content-Type", + "application/json;charset=utf-8", + ); + } + } + + setHeaderValue(requestConfig.header, TRANSPORT_ENABLE_HEADER, "1"); + setHeaderValue(requestConfig.header, TRANSPORT_KEY_ID_HEADER, transportContext.kid); + requestConfig.__transportCryptoContext = transportContext; + requestConfig.__transportCryptoEnabledForRequest = true; + return requestConfig; +} + +/** + * 清空当前缓存的公钥元数据。 + * + * @returns {void} + */ +export function invalidateTransportKeyMeta() { + cachedKeyMeta = null; + inflightKeyMetaPromise = null; + uni.removeStorageSync(TRANSPORT_KEY_META_CACHE_KEY); +} + +/** + * 将被加密改写过的请求恢复为原始形态。 + * + * @param {Object} requestConfig 请求配置 + * @returns {Object} 恢复后的请求配置 + */ +export function resetTransportRequestConfig(requestConfig) { + const originalSnapshot = requestConfig?.__transportOriginalSnapshot; + if (!requestConfig || !originalSnapshot) { + return requestConfig; + } + + requestConfig.url = originalSnapshot.url; + requestConfig.params = cloneRequestValue(originalSnapshot.params); + requestConfig.data = cloneRequestValue(originalSnapshot.data); + if (originalSnapshot.contentType) { + setHeaderValue(requestConfig.header, "Content-Type", originalSnapshot.contentType); + } + delete requestConfig.__transportCryptoContext; + delete requestConfig.__transportCryptoContextPromise; + delete requestConfig.__transportCryptoEnabledForRequest; + return requestConfig; +} + +/** + * 判断错误是否属于可通过刷新公钥进行重试的场景。 + * + * @param {Object} responseOrError 响应对象或错误对象 + * @returns {boolean} 是否可刷新密钥重试 + */ +export function shouldRetryTransportWithFreshKey(responseOrError) { + const responseMsg = responseOrError?.data?.msg || responseOrError?.response?.data?.msg; + const errorMessage = responseOrError?.message; + return ( + TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(responseMsg) || + TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(errorMessage) + ); +} + +/** + * 解密成功响应中的传输层信封。 + * + * @param {Object} response 原始响应对象 + * @param {Object} requestConfig 请求配置 + * @returns {Promise} 解密后的响应对象 + */ +export async function decryptTransportResponse(response, requestConfig) { + const encryptedResponseFlag = String( + getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "", + ); + if (encryptedResponseFlag !== "1") { + return response; + } + if (!shouldEncryptResponse(requestConfig, getTransportCryptoPolicy())) { + return response; + } + const transportPolicy = getTransportCryptoPolicy(); + const transportContext = requestConfig.__transportCryptoContext; + if (!transportContext) { + throw new Error("缺少响应解密上下文"); + } + + const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法"); + validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, + ); + const plaintext = await decryptEnvelope(envelope, transportContext); + response.data = JSON.parse(plaintext); + return response; +} + +/** + * 尝试解密异常响应中的传输层信封。 + * + * @param {Object} error 错误对象 + * @param {Object} requestConfig 原始请求配置 + * @returns {Promise} 原始或已解密的错误对象 + */ +export async function decryptTransportErrorResponse(error, requestConfig) { + const response = + error?.response || + (error && (error.data !== undefined || error.header || error.headers || error.statusCode) + ? error + : null); + const encryptedResponseFlag = String( + getHeaderValue(getResponseHeaders(response), ENCRYPTED_RESPONSE_HEADER) || "", + ); + if (!response || encryptedResponseFlag !== "1") { + return error; + } + if (!shouldEncryptResponse(requestConfig || {}, getTransportCryptoPolicy())) { + return error; + } + const transportPolicy = getTransportCryptoPolicy(); + const transportContext = requestConfig?.__transportCryptoContext; + if (!transportContext) { + return error; + } + + try { + const envelope = parseJsonObject(response.data, "传输层响应信封格式不合法"); + validateResponseEnvelope( + envelope, + response, + requestConfig, + transportContext, + transportPolicy, + ); + const plaintext = await decryptEnvelope(envelope, transportContext); + response.data = JSON.parse(plaintext); + if (!error.response) { + error.response = response; + } + } catch (decryptError) { + console.error(decryptError); + } + return error; +} diff --git a/ruoyi-fastapi-app/src/utils/transportCryptoPolicy.js b/ruoyi-fastapi-app/src/utils/transportCryptoPolicy.js new file mode 100644 index 0000000..022d4ef --- /dev/null +++ b/ruoyi-fastapi-app/src/utils/transportCryptoPolicy.js @@ -0,0 +1,436 @@ +import config from "@/config"; + +const TRANSPORT_BASE_URL = config.baseUrl; +const EXCLUDED_URL_PATTERNS = [ + "/transport/crypto/frontend-config", + "/transport/crypto/public-key", + "/common/download", + "/common/download/resource", +]; +const TRANSPORT_FRONTEND_CONFIG_CACHE_KEY = "transportCryptoFrontendConfig"; +const TRANSPORT_FRONTEND_CONFIG_URL = "/transport/crypto/frontend-config"; +const TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS = 60; +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = "1"; +const DEFAULT_REQUEST_ENVELOPE_ALGORITHM = "RSA_OAEP_AES_256_GCM"; +const DEFAULT_RESPONSE_ENVELOPE_ALGORITHM = "AES_256_GCM"; +const DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH = 4096; + +let cachedTransportPolicy = null; +let inflightTransportPolicyPromise = null; + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * 判断请求地址是否命中固定排除名单。 + * + * @param {string} url 请求地址 + * @returns {boolean} 是否命中排除规则 + */ +function matchExcludedUrl(url = "") { + return EXCLUDED_URL_PATTERNS.some((pattern) => url.includes(pattern)); +} + +/** + * 判断请求路径是否命中路径前缀列表。 + * + * @param {string} path 待匹配路径 + * @param {string[]} pathPatterns 路径前缀集合 + * @returns {boolean} 是否匹配成功 + */ +function matchPathPrefix(path = "", pathPatterns = []) { + return pathPatterns.some( + (pattern) => path === pattern || path.startsWith(`${pattern}/`), + ); +} + +/** + * 从绝对地址中提取 pathname,或直接返回相对地址的路径部分。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化后的路径 + */ +function parseAbsoluteUrlPath(url = "") { + const normalizedUrl = String(url || ""); + if (!normalizedUrl) { + return "/"; + } + if ( + !normalizedUrl.startsWith("http://") && + !normalizedUrl.startsWith("https://") + ) { + return normalizedUrl.split("?")[0] || "/"; + } + const pathMatch = normalizedUrl.match(/^https?:\/\/[^/]+(\/[^?#]*)?/i); + return pathMatch?.[1] || "/"; +} + +/** + * 解析基础 API 地址对应的路径前缀。 + * + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath(baseUrl = TRANSPORT_BASE_URL) { + if (!baseUrl) { + return ""; + } + const baseApiPath = parseAbsoluteUrlPath(baseUrl); + return baseApiPath === "/" ? "" : baseApiPath; +} + +/** + * 计算后端用于 AAD 与策略匹配的标准请求路径。 + * + * @param {string} url 请求地址 + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = "", baseUrl = TRANSPORT_BASE_URL) { + const baseApiPath = getBaseApiPath(baseUrl); + const pathname = parseAbsoluteUrlPath(url); + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length); + return normalizedPath || "/"; + } + return pathname || "/"; +} + +/** + * 标准化后端下发的路径列表配置。 + * + * @param {Array} paths 原始路径集合 + * @returns {string[]} 标准化后的路径数组 + */ +function normalizePaths(paths) { + if (!Array.isArray(paths)) { + return []; + } + return paths.map((path) => String(path || "").trim()).filter(Boolean); +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined; + } + return headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()]; +} + +/** + * 将后端配置响应转换为前端统一使用的策略对象。 + * + * @param {Object} payload 后端返回的配置数据 + * @returns {Object} 标准化后的传输加密策略 + */ +function normalizeTransportPolicy(payload) { + return { + transportCryptoEnabled: Boolean(payload?.transportCryptoEnabled), + transportCryptoMode: String(payload?.transportCryptoMode || "off"), + transportCryptoActive: Boolean(payload?.transportCryptoActive), + envelopeVersion: String( + payload?.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + ), + publicKeyUrl: String(payload?.publicKeyUrl || "/transport/crypto/public-key"), + requestEnvelopeAlgorithm: String( + payload?.requestEnvelopeAlgorithm || DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + ), + responseEnvelopeAlgorithm: String( + payload?.responseEnvelopeAlgorithm || DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + ), + enabledPaths: normalizePaths(payload?.enabledPaths), + requiredPaths: normalizePaths(payload?.requiredPaths), + excludePaths: normalizePaths(payload?.excludePaths), + maxEncryptedGetUrlLength: Number( + payload?.maxEncryptedGetUrlLength || DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + ), + configExpireAt: Number(payload?.configExpireAt || 0), + retryAt: Number(payload?.retryAt || payload?.configExpireAt || 0), + }; +} + +/** + * 构建不可用场景下的本地兜底策略。 + * + * @returns {Object} 明文回退策略 + */ +function buildFallbackTransportPolicy() { + const nowTimestamp = getNowTimestamp(); + return { + transportCryptoEnabled: false, + transportCryptoMode: "off", + transportCryptoActive: false, + envelopeVersion: DEFAULT_TRANSPORT_ENVELOPE_VERSION, + publicKeyUrl: "/transport/crypto/public-key", + requestEnvelopeAlgorithm: DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + responseEnvelopeAlgorithm: DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + enabledPaths: [], + requiredPaths: [], + excludePaths: [...EXCLUDED_URL_PATTERNS], + maxEncryptedGetUrlLength: DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + configExpireAt: + nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + retryAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + }; +} + +/** + * 基于旧策略生成短期可重试的缓存策略。 + * + * @param {Object} policy 旧的策略对象 + * @returns {Object} 可重试策略 + */ +function buildRetryableTransportPolicy(policy) { + const normalizedPolicy = normalizeTransportPolicy(policy); + return { + ...normalizedPolicy, + retryAt: getNowTimestamp() + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + }; +} + +/** + * 判断当前策略是否仍在可用期内。 + * + * @param {Object} policy 待校验策略 + * @returns {boolean} 是否可继续使用 + */ +function isUsableTransportPolicy(policy) { + if (!policy || !policy.publicKeyUrl || !policy.retryAt) { + return false; + } + return policy.retryAt > getNowTimestamp(); +} + +/** + * 从本地缓存加载最近一次持久化的策略。 + * + * @returns {Object|null} 缓存策略 + */ +function loadPersistedTransportPolicy() { + const persistedTransportPolicy = uni.getStorageSync( + TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, + ); + if (!persistedTransportPolicy) { + return null; + } + return normalizeTransportPolicy(persistedTransportPolicy); +} + +/** + * 请求后端传输加密前端配置。 + * 这里直接使用原始 uni.request,避免策略初始化阶段反向依赖统一 request + * 包装器,导致“是否加密尚未判定时又要先走加密请求”的循环依赖。 + * + * @returns {Promise} Uni 请求响应结果 + */ +function requestFrontendConfig() { + return new Promise((resolve, reject) => { + uni.request({ + url: `${TRANSPORT_BASE_URL}${TRANSPORT_FRONTEND_CONFIG_URL}`, + method: "GET", + timeout: 10000, + success: resolve, + fail: reject, + }); + }); +} + +/** + * 获取请求加密使用的标准路径。 + * + * @param {string} url 请求地址 + * @param {string} baseUrl 基础 API 地址 + * @returns {string} 标准请求路径 + */ +export function getTransportRequestPath( + url = "", + baseUrl = TRANSPORT_BASE_URL, +) { + return getRequestPath(url, baseUrl); +} + +/** + * 获取当前生效的传输加密策略。 + * + * @returns {Object} 当前策略对象 + */ +export function getTransportCryptoPolicy() { + return cachedTransportPolicy || buildFallbackTransportPolicy(); +} + +/** + * 清空当前策略缓存与持久化数据。 + * + * @returns {void} + */ +export function invalidateTransportCryptoPolicy() { + cachedTransportPolicy = null; + inflightTransportPolicyPromise = null; + uni.removeStorageSync(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY); +} + +/** + * 确保本地已加载一份可用的传输加密策略。 + * + * @param {boolean} forceRefresh 是否强制从后端刷新 + * @returns {Promise} 当前可用策略 + */ +export async function ensureTransportCryptoPolicyLoaded(forceRefresh = false) { + if (!forceRefresh && !cachedTransportPolicy) { + const persistedTransportPolicy = loadPersistedTransportPolicy(); + if (isUsableTransportPolicy(persistedTransportPolicy)) { + cachedTransportPolicy = persistedTransportPolicy; + } + } + + if (!forceRefresh && isUsableTransportPolicy(cachedTransportPolicy)) { + return cachedTransportPolicy; + } + + if (inflightTransportPolicyPromise) { + return inflightTransportPolicyPromise; + } + + inflightTransportPolicyPromise = requestFrontendConfig() + .then((response) => { + const payload = normalizeTransportPolicy(response?.data?.data || {}); + cachedTransportPolicy = payload; + uni.setStorageSync(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, payload); + inflightTransportPolicyPromise = null; + return cachedTransportPolicy; + }) + .catch((error) => { + const staleTransportPolicy = + cachedTransportPolicy || loadPersistedTransportPolicy(); + inflightTransportPolicyPromise = null; + cachedTransportPolicy = staleTransportPolicy + ? buildRetryableTransportPolicy(staleTransportPolicy) + : buildFallbackTransportPolicy(); + uni.setStorageSync( + TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, + cachedTransportPolicy, + ); + if (staleTransportPolicy) { + console.warn( + "加载传输加密前端配置失败,当前继续沿用最近一次后端策略", + error, + ); + } else { + console.warn("加载传输加密前端配置失败,当前回退为明文请求策略", error); + } + return cachedTransportPolicy; + }); + + return inflightTransportPolicyPromise; +} + +/** + * 判断当前请求是否需要执行请求体加密。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用请求加密 + */ +export function shouldEncryptRequest( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + if (!transportPolicy.transportCryptoActive) { + return false; + } + const requestPath = getRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ); + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false; + } + if ( + (transportPolicy.enabledPaths || []).length && + !matchPathPrefix(requestPath, transportPolicy.enabledPaths || []) + ) { + return false; + } + if ((requestConfig.headers || {}).encrypt === false) { + return false; + } + if (matchExcludedUrl(requestConfig.url)) { + return false; + } + const contentType = + getHeaderValue(requestConfig.header, "Content-Type") || + getHeaderValue(requestConfig.headers, "Content-Type") || + ""; + if (String(contentType).includes("multipart/form-data")) { + return false; + } + return true; +} + +/** + * 判断当前响应是否需要执行自动解密。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用响应解密 + */ +export function shouldEncryptResponse( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + const requestPath = getRequestPath( + requestConfig.url, + requestConfig.baseUrl || TRANSPORT_BASE_URL, + ); + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false; + } + if ( + (transportPolicy.enabledPaths || []).length && + !matchPathPrefix(requestPath, transportPolicy.enabledPaths || []) + ) { + return false; + } + if ((requestConfig.headers || {}).encryptResponse === false) { + return false; + } + if (matchExcludedUrl(requestConfig.url)) { + return false; + } + if (requestConfig.__transportCryptoEnabledForRequest === true) { + return true; + } + if (requestConfig.__transportCryptoEnabledForRequest === false) { + return false; + } + return transportPolicy.transportCryptoActive; +} + +/** + * 判断查询参数是否需要封装为加密信封。 + * + * @param {Object} requestConfig 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用查询参数加密 + */ +export function shouldEncryptQuery( + requestConfig, + transportPolicy = getTransportCryptoPolicy(), +) { + if ((requestConfig.headers || {}).encryptQuery === false) { + return false; + } + return shouldEncryptRequest(requestConfig, transportPolicy); +} diff --git a/ruoyi-fastapi-app/src/utils/transportForge.js b/ruoyi-fastapi-app/src/utils/transportForge.js new file mode 100644 index 0000000..cac307f --- /dev/null +++ b/ruoyi-fastapi-app/src/utils/transportForge.js @@ -0,0 +1,214 @@ +import forge from "node-forge/lib/forge"; +import "node-forge/lib/util"; +import "node-forge/lib/asn1"; +import "node-forge/lib/oids"; +import "node-forge/lib/cipher"; +import "node-forge/lib/cipherModes"; +import "node-forge/lib/aes"; +import "node-forge/lib/jsbn"; +import "node-forge/lib/pkcs1"; +import "node-forge/lib/prime"; +import "node-forge/lib/random"; +import "node-forge/lib/md"; +import "node-forge/lib/sha256"; +import "node-forge/lib/mgf1"; +import "node-forge/lib/pem"; +import "node-forge/lib/pki"; + +let randomBytePool = ""; +const FORGE_RANDOM_REFILL_BYTES = 4096; + +/** + * 将 Uint8Array 转为 Forge 兼容的二进制字符串。 + * + * @param {Uint8Array} uint8Array 字节数组 + * @returns {string} 二进制字符串 + */ +function uint8ArrayToBytes(uint8Array) { + return Array.from(uint8Array, (item) => String.fromCharCode(item)).join(""); +} + +/** + * 获取当前运行时的全局对象引用。 + * + * @returns {Object|undefined} 全局对象 + */ +function getRuntimeGlobal() { + if (typeof globalThis !== "undefined") { + return globalThis; + } + if (typeof self !== "undefined") { + return self; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof global !== "undefined") { + return global; + } + return undefined; +} + +/** + * 通过 Web Crypto 同步获取随机字节。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getWebCryptoRandomBytes(length) { + const runtimeCrypto = getRuntimeGlobal()?.crypto; + if (!runtimeCrypto?.getRandomValues) { + return null; + } + const bytes = new Uint8Array(length); + runtimeCrypto.getRandomValues(bytes); + return uint8ArrayToBytes(bytes); +} + +/** + * 在 app-plus iOS 端通过原生 NSUUID 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getAppPlusIosRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "ios" || typeof plus.ios?.importClass !== "function") { + return null; + } + + try { + const uuidClass = plus.ios.importClass("NSUUID"); + let randomBytes = ""; + while (randomBytes.length < length) { + const uuidObject = plus.ios.invoke(uuidClass, "UUID"); + const uuidText = String(plus.ios.invoke(uuidObject, "UUIDString") || "") + .replace(/-/g, "") + .toLowerCase(); + plus.ios.deleteObject(uuidObject); + if (!uuidText) { + return null; + } + for ( + let index = 0; + index < uuidText.length && randomBytes.length < length; + index += 2 + ) { + const byteValue = parseInt(uuidText.slice(index, index + 2), 16); + if (isNaN(byteValue)) { + return null; + } + randomBytes += String.fromCharCode(byteValue); + } + } + return randomBytes; + } catch (error) { + return null; + } + // #endif + return null; +} + +/** + * 在 app-plus Android 端通过原生 SecureRandom 获取随机字节兜底。 + * + * @param {number} length 需要的字节长度 + * @returns {string|null} 随机字节串,当前运行环境不支持时返回 null + */ +function getAppPlusAndroidRandomBytes(length) { + // #ifdef APP-PLUS + if (typeof plus === "undefined") { + return null; + } + const osName = String(plus.os?.name || "").toLowerCase(); + if (osName !== "android" || typeof plus.android?.importClass !== "function") { + return null; + } + + try { + plus.android.importClass("java.security.SecureRandom"); + const secureRandom = + typeof plus.android.newObject === "function" + ? plus.android.newObject("java.security.SecureRandom") + : null; + if (!secureRandom) { + return null; + } + plus.android.importClass(secureRandom); + let randomBytes = ""; + for (let index = 0; index < length; index += 1) { + randomBytes += String.fromCharCode(secureRandom.nextInt(256)); + } + if (typeof plus.android.autoCollection === "function") { + plus.android.autoCollection(secureRandom); + } + return randomBytes; + } catch (error) { + return null; + } + // #endif + return null; +} + +/** + * 在随机池不足时尝试同步补充一段新的随机字节。 + * + * @param {number} length 当前至少需要的字节长度 + * @returns {void} + */ +function refillRandomBytePool(length) { + const refillLength = Math.max(length, FORGE_RANDOM_REFILL_BYTES); + const randomBytes = + getWebCryptoRandomBytes(refillLength) || + getAppPlusAndroidRandomBytes(refillLength) || + getAppPlusIosRandomBytes(refillLength); + if (randomBytes) { + randomBytePool += randomBytes; + } +} + +/** + * 从随机数池中按需提取字节串。 + * + * @param {number} length 需要提取的字节长度 + * @returns {string} Forge 使用的二进制字符串 + */ +function consumeRandomBytes(length) { + if (randomBytePool.length < length) { + refillRandomBytePool(length); + } + if (randomBytePool.length < length) { + throw new Error("传输加密随机数池不足,且当前运行环境无法同步补充安全随机数"); + } + const bytes = randomBytePool.slice(0, length); + randomBytePool = randomBytePool.slice(length); + return bytes; +} + +forge.random.getBytesSync = function getBytesSync(length) { + return consumeRandomBytes(length); +}; + +forge.random.getBytes = function getBytes(length, callback) { + const bytes = consumeRandomBytes(length); + if (typeof callback === "function") { + callback(null, bytes); + } + return bytes; +}; + +/** + * 向 Forge 随机数池预填充平台侧生成的随机字节。 + * + * @param {string} bytes 待注入的二进制字符串 + * @returns {void} + */ +export function primeForgeRandomBytes(bytes) { + randomBytePool += String(bytes || ""); +} + +export default forge; diff --git a/ruoyi-fastapi-app/vue.config.js b/ruoyi-fastapi-app/vue.config.js index 5c5e337..3d6acfe 100644 --- a/ruoyi-fastapi-app/vue.config.js +++ b/ruoyi-fastapi-app/vue.config.js @@ -1,3 +1,4 @@ +const path = require("path"); const { WeappTailwindcssDisabled } = require("./platform"); const { UnifiedWebpackPluginV5 } = require("weapp-tailwindcss/webpack"); @@ -7,7 +8,43 @@ const { UnifiedWebpackPluginV5 } = require("weapp-tailwindcss/webpack"); const config = { //.... configureWebpack: { + resolve: { + alias: { + "node-forge/lib/util": path.resolve( + __dirname, + "src/vendor/node-forge/lib/util.js", + ), + "node-forge/lib/random": path.resolve( + __dirname, + "src/vendor/node-forge/lib/random.js", + ), + "node-forge/lib/prng": path.resolve( + __dirname, + "src/vendor/node-forge/lib/prng.js", + ), + "node-forge/lib/rsa": path.resolve( + __dirname, + "src/vendor/node-forge/lib/rsa.js", + ), + }, + }, plugins: [ + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]util\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/util.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]random\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/random.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]prng\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/prng.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]rsa\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/rsa.js"), + ), new UnifiedWebpackPluginV5({ rem2rpx: true, disabled: WeappTailwindcssDisabled, diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index 8cf3c07..b33684a 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -122,3 +122,37 @@ LOG_INSTANCE_ID = 'dev' LOG_SERVICE_NAME = 'ruoyi-fastapi-backend' # Worker 标识(auto 自动生成) LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = false +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'off' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;团队使用时可按需替换 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式 +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index 9cca11b..71481c9 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -122,3 +122,37 @@ LOG_INSTANCE_ID = 'dockermy' LOG_SERVICE_NAME = 'ruoyi-fastapi-backend' # Worker 标识(auto 自动生成) LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index b09a9fe..c00be41 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -122,3 +122,37 @@ LOG_INSTANCE_ID = 'dockerpg' LOG_SERVICE_NAME = 'ruoyi-fastapi-backend' # Worker 标识(auto 自动生成) LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;Docker 部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index 7e32a6c..24d0a84 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -122,3 +122,37 @@ LOG_INSTANCE_ID = 'prod' LOG_SERVICE_NAME = 'ruoyi-fastapi-backend' # Worker 标识(auto 自动生成) LOG_WORKER_ID = 'auto' + +# -------- 传输层加解密配置 -------- +# 是否启用传输层加解密 +TRANSPORT_CRYPTO_ENABLED = true +# 传输层加解密模式:off=关闭,optional=明文/密文兼容,required=命中接口强制加密 +TRANSPORT_CRYPTO_MODE = 'optional' +# 传输层加解密算法标识 +TRANSPORT_CRYPTO_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +# 当前启用的密钥版本标识 +TRANSPORT_CRYPTO_KID = 'default' +# 传输层RSA位数,需与下方密钥对匹配 +TRANSPORT_CRYPTO_RSA_KEY_SIZE = 4096 +# 传输层公钥,默认提供一套可用示例值;生产部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwA2ooWpsxLzIVMJp7Wcv\nvR0Bu8paFn8NVPpzz+wGpUlwP5DGK6pBGItsglNMZx56MSYMp3zVyqB95XUVZ6Ub\nQVyHJ6BXXIMs/BpzcHCbyXR/wWG+pKxQ8UEwaHVhK8X21xW1R0kzzwhgMw51unEs\nA81kskVlDdGeNyaOJg5QuofEErCfR1y0e+iqd1PBpIwdEY5L+BzvbVtyhsPz6dBu\n9YbEbKs7JvNle5vjc72ebbMIeGejHFQRYxihuamPCAEylj1qqpHk8U+r+3icxQsj\n8F/TySLuOy37iVWfD+5ikLyou4ZDI3hOYnIYHl194ZM5xVyOGBD/xdqZadXTLqL6\nSrldwZfZDBl3EGgRby8yJqO6SqGgyvyGWXZAaHoPMmF+quu/nUooqnp0fpl+LCqX\nhK67kbYaA9xJeHRaP04cg16imB7sBIXBqzkyZtkhC2BtlL0h5X7dRlAddrQ23z6d\nWdJA3qe3iBSSM8pmedv+Jgfau/PFam2051HqFxvJEh/jnc6rq1aIjL/d4Kk5imIa\nQ6acv+v5N6QVFptlcx6I7j8yZJ7WUHZlB1IxVqfPb69+985eknZyLul60gyu0kPG\nYUecypUu2wiNDBAErZlUxEujMWgJDFYCSonHxtwr88gDInjP0lvwd/OgqjDQ7hSo\nRYxPhTbwo68RJDpImBjn+YsCAwEAAQ==\n-----END PUBLIC KEY-----' +# 传输层私钥,必须与上方公钥成对使用;生产部署前请替换为正式密钥 +TRANSPORT_CRYPTO_PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----' +# 历史密钥对配置,JSON数组格式,密钥轮换说明见 docs/transport_crypto_deployment.md +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS = '[]' +# 公钥缓存有效期(秒) +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS = 3600 +# 前端传输加密策略缓存有效期(秒) +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS = 300 +# GET/DELETE 请求参数加密后的最大 URL 长度 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH = 4096 +# 请求时间窗允许偏差(秒) +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS = 120 +# 防重放随机数有效期(秒) +TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS = 300 +# 启用传输层加密的路径列表,多个值使用逗号分隔,留空表示默认全部启用 +TRANSPORT_CRYPTO_ENABLED_PATHS = '' +# 强制要求传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_REQUIRED_PATHS = '' +# 排除传输层加密的路径列表,多个值使用逗号分隔 +TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' diff --git a/ruoyi-fastapi-backend/common/annotation/rate_limit_annotation.py b/ruoyi-fastapi-backend/common/annotation/rate_limit_annotation.py index 345c7bf..1d7e9b8 100644 --- a/ruoyi-fastapi-backend/common/annotation/rate_limit_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/rate_limit_annotation.py @@ -61,6 +61,7 @@ class ApiRateLimitPreset: ANON_AUTH_LOGIN: 匿名登录类接口限流预设 ANON_AUTH_REGISTER: 匿名注册类接口限流预设 ANON_AUTH_CAPTCHA: 匿名验证码类接口限流预设 + ANON_PUBLIC_METADATA: 匿名公开元数据接口限流预设 COMMON_UPLOAD: 通用上传接口限流预设 USER_INTERACTIVE_HIGH_FREQ: 用户高频交互接口限流预设 USER_RESOURCE_EXECUTION: 用户执行类接口限流预设 @@ -96,6 +97,13 @@ class ApiRateLimitPreset: algorithm='sliding_window', fail_strategy='local_fallback', ) + ANON_PUBLIC_METADATA = ApiRateLimitPresetConfig( + name='ANON_PUBLIC_METADATA', + limit=30, + window_seconds=60, + algorithm='sliding_window', + fail_strategy='local_fallback', + ) COMMON_UPLOAD = ApiRateLimitPresetConfig( name='COMMON_UPLOAD', limit=24, diff --git a/ruoyi-fastapi-backend/common/constant.py b/ruoyi-fastapi-backend/common/constant.py index 90cda58..1d8cf23 100644 --- a/ruoyi-fastapi-backend/common/constant.py +++ b/ruoyi-fastapi-backend/common/constant.py @@ -158,6 +158,8 @@ class ApiNamespace: LOGIN_USER_ROUTERS: 登录用户路由接口命名空间 CAPTCHA_IMAGE: 图片验证码接口命名空间 COMMON_UPLOAD: 通用上传接口命名空间 + TRANSPORT_CRYPTO_PUBLIC_KEY: 传输层加密公钥接口命名空间 + TRANSPORT_CRYPTO_FRONTEND_CONFIG: 传输层加密前端配置接口命名空间 MONITOR_SERVER_INFO: 服务监控信息接口命名空间 MONITOR_CACHE_CLEAR_NAME: 缓存名称清理接口命名空间 @@ -245,6 +247,8 @@ class ApiNamespace: LOGIN_USER_ROUTERS = 'login:user:routers' CAPTCHA_IMAGE = 'captcha:image' COMMON_UPLOAD = 'common:upload' + TRANSPORT_CRYPTO_PUBLIC_KEY = 'transport-crypto:public-key' + TRANSPORT_CRYPTO_FRONTEND_CONFIG = 'transport-crypto:frontend-config' MONITOR_SERVER_INFO = 'monitor:server:info' MONITOR_CACHE_CLEAR_NAME = 'monitor:cache:clear-name' diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index 7c89153..b631520 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -109,6 +109,32 @@ class LogSettings(BaseSettings): log_worker_id: str = 'auto' +class TransportCryptoSettings(BaseSettings): + """ + 传输层加解密配置 + """ + + transport_crypto_enabled: bool = True + transport_crypto_mode: Literal['off', 'optional', 'required'] = 'optional' + transport_crypto_algorithm: str = 'RSA_OAEP_AES_256_GCM' + transport_crypto_kid: str = 'default' + transport_crypto_public_key: str = '' + transport_crypto_private_key: str = '' + transport_crypto_legacy_key_pairs: str = '[]' + transport_crypto_rsa_key_size: int = 2048 + transport_crypto_public_key_ttl_seconds: int = 3600 + transport_crypto_frontend_config_ttl_seconds: int = 300 + transport_crypto_max_get_url_length: int = 4096 + transport_crypto_clock_skew_seconds: int = 120 + transport_crypto_replay_ttl_seconds: int = 300 + transport_crypto_enabled_paths: str = '' + transport_crypto_required_paths: str = '' + transport_crypto_exclude_paths: str = ( + '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,' + '/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource' + ) + + class GenSettings: """ 代码生成配置 @@ -224,6 +250,12 @@ def get_log_config(self) -> LogSettings: """ return LogSettings() + def get_transport_crypto_config(self) -> TransportCryptoSettings: + """ + 获取传输层加解密配置 + """ + return TransportCryptoSettings() + def get_gen_config(self) -> GenSettings: """ 获取代码生成配置 @@ -285,6 +317,8 @@ def parse_cli_args() -> None: RedisConfig = get_config.get_redis_config() # 日志配置 LogConfig = get_config.get_log_config() +# 传输层加解密配置 +TransportCryptoConfig = get_config.get_transport_crypto_config() # 代码生成配置 GenConfig = get_config.get_gen_config() # 上传配置 diff --git a/ruoyi-fastapi-backend/docs/transport_crypto_config.md b/ruoyi-fastapi-backend/docs/transport_crypto_config.md new file mode 100644 index 0000000..07ff5ca --- /dev/null +++ b/ruoyi-fastapi-backend/docs/transport_crypto_config.md @@ -0,0 +1,129 @@ +# 传输层加解密配置说明 + +## 模式说明 + +`TRANSPORT_CRYPTO_MODE` 共有三种模式: + +- `off` + 完全关闭传输层加解密。中间件不执行请求解密与响应加密,前端通过 `/transport/crypto/frontend-config` 获取到的策略也会同步关闭。 +- `optional` + 可选加密模式。命中的接口既接受明文请求,也接受加密请求;如果请求已加密,后端会解密后处理,并对命中的 JSON 响应自动加密。适合灰度接入和上线初期观察。 +- `required` + 强制加密模式。命中的接口必须携带合法加密信封,明文请求会被直接拒绝;同时防重放校验会按严格模式执行,Redis 不可用时也会拒绝请求。适合链路稳定后的正式强制启用阶段。 + +补充说明: + +- `TRANSPORT_CRYPTO_ENABLED=false` 时,整体效果等同于关闭,不再进入传输层加解密逻辑。 +- `TRANSPORT_CRYPTO_ENABLED_PATHS`、`TRANSPORT_CRYPTO_REQUIRED_PATHS` 和 `TRANSPORT_CRYPTO_EXCLUDE_PATHS` 会在上述模式基础上继续约束命中范围。 + +## 开发环境 + +开发环境直接使用 `.env.dev` 中默认提供的可用密钥对即可。 + +说明: + +- 传输层加解密启用后,后端启动时必须读到一对匹配的 `TRANSPORT_CRYPTO_PUBLIC_KEY` / `TRANSPORT_CRYPTO_PRIVATE_KEY`。 +- 前端会自动读取 `/transport/crypto/frontend-config`,并跟随后端配置完成请求加密、响应解密。 +- `/transport/crypto/frontend-config` 和 `/transport/crypto/public-key` 为公开接口,已配置匿名限流,前端会直接调用这两个接口完成初始化。 +- `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 用于控制前端多久重新拉取一次运行策略。 +- `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 用于控制前端多久重新拉取一次公钥,两者已经独立。 + +## 生产环境 + +生产环境使用后端 `.env.prod` 中的密钥配置;仓库默认提供一套可用示例值,正式部署前请替换为正式密钥。 + +推荐最小配置如下: + +```env +TRANSPORT_CRYPTO_ENABLED=true +TRANSPORT_CRYPTO_MODE='optional' +TRANSPORT_CRYPTO_KID='2026-prod-v1' +TRANSPORT_CRYPTO_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n' +TRANSPORT_CRYPTO_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n' +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS='[]' +TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS=300 +TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS=3600 +TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS=120 +TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH=4096 +``` + +说明: + +- `TRANSPORT_CRYPTO_PUBLIC_KEY` 和 `TRANSPORT_CRYPTO_PRIVATE_KEY` 必须是一对匹配密钥,缺一不可。 +- `TRANSPORT_CRYPTO_KID` 表示当前启用的密钥版本。 +- `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 控制 `/transport/crypto/frontend-config` 的前端缓存时长,适合在策略经常调整时适当缩短。 +- `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 控制 `/transport/crypto/public-key` 的前端缓存时长,主要服务于公钥缓存与密钥轮换。 +- `TRANSPORT_CRYPTO_CLOCK_SKEW_SECONDS` 建议控制在 `60-120` 秒,默认收紧为 `120` 秒。 +- `TRANSPORT_CRYPTO_REPLAY_TTL_SECONDS` 控制防重放随机数在 Redis 中的有效期;如果准备使用 `required` 模式,建议保证 Redis 稳定可用。 +- 初次上线建议先用 `TRANSPORT_CRYPTO_MODE='optional'`,确认链路稳定后再考虑切到 `required`。 +- `TRANSPORT_CRYPTO_MAX_GET_URL_LENGTH` 用于限制 GET/DELETE 请求加密后的 URL 长度,前端会通过 `/transport/crypto/frontend-config` 自动同步该值,超限时直接提示改用 POST 或精简查询条件。 +- 传输层加密主要面向查询参数、`application/json` 与 `application/x-www-form-urlencoded` 请求;`multipart/form-data` 上传和下载接口默认排除。 + +## Docker 环境 + +当前项目的 Docker 部署使用: + +- `docker-compose.my.yml` + `Dockerfile.my` +- `docker-compose.pg.yml` + `Dockerfile.pg` + +后端容器启动命令分别是: + +- `python app.py --env=dockermy` +- `python app.py --env=dockerpg` + +所以 Docker 环境需要直接在以下文件中配置传输层密钥: + +- `ruoyi-fastapi-backend/.env.dockermy` +- `ruoyi-fastapi-backend/.env.dockerpg` + +配置方式与生产环境相同;`.env.dockermy` / `.env.dockerpg` 里也已经默认提供一套可用示例值,正式部署前请替换为正式密钥。 + +使用时只需要: + +1. 修改对应的 `.env.dockermy` 或 `.env.dockerpg` +2. 重新构建并启动 Docker 服务 + +## 密钥生成 + +使用 `openssl` 生成一套 RSA 密钥: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out transport_private.pem +openssl rsa -pubout -in transport_private.pem -out transport_public.pem +``` + +如果需要写入 `.env`,先转成单行带 `\n` 的格式: + +```bash +awk 'NF {sub(/\r/, ""); printf "%s\\\\n",$0;}' transport_private.pem +awk 'NF {sub(/\r/, ""); printf "%s\\\\n",$0;}' transport_public.pem +``` + +## 使用流程 + +1. 后端启动时读取当前 `TRANSPORT_CRYPTO_*` 配置,并校验公私钥是否同时存在且彼此匹配。 +2. 前端通过 `/transport/crypto/frontend-config` 获取当前运行策略,再通过 `/transport/crypto/public-key` 获取当前 `kid`、协议版本和公钥。 +3. `TRANSPORT_CRYPTO_FRONTEND_CONFIG_TTL_SECONDS` 和 `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 分别控制这两类缓存的刷新周期。 +4. 前端用公钥加密请求,后端用私钥解密请求。 +5. 后端会对命中的 JSON 响应自动加密,前端自动解密;下载、上传等排除场景保持明文。 + +## 密钥轮换 + +如果需要更换密钥: + +1. 生成新密钥对。 +2. 修改 `TRANSPORT_CRYPTO_KID` 为新版本,例如 `2026-prod-v2`。 +3. 配置新的 `TRANSPORT_CRYPTO_PUBLIC_KEY` 和 `TRANSPORT_CRYPTO_PRIVATE_KEY`。 +4. 把旧私钥放入 `TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS`。 + +补充说明: + +- `TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS` 主要用于兼容旧报文解密,最少提供 `kid` 和旧私钥即可;`publicKey` 可选,不填时后端会从私钥推导。 +- 轮换期间建议保留旧私钥直到旧公钥缓存全部过期,至少覆盖 `TRANSPORT_CRYPTO_PUBLIC_KEY_TTL_SECONDS` 对应的缓存窗口。 + +示例: + +```env +TRANSPORT_CRYPTO_KID='2026-prod-v2' +TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS='[{"kid":"2026-prod-v1","privateKey":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"}]' +``` diff --git a/ruoyi-fastapi-backend/middlewares/cors_middleware.py b/ruoyi-fastapi-backend/middlewares/cors_middleware.py index 7333051..df6a0ce 100644 --- a/ruoyi-fastapi-backend/middlewares/cors_middleware.py +++ b/ruoyi-fastapi-backend/middlewares/cors_middleware.py @@ -11,6 +11,11 @@ def add_cors_middleware(app: FastAPI) -> None: """ # 前端页面url origins = ['*'] + expose_headers = [ + 'x-body-encrypted', + 'x-key-id', + 'x-encrypt-alg', + ] # 后台api允许跨域 app.add_middleware( @@ -19,4 +24,5 @@ def add_cors_middleware(app: FastAPI) -> None: allow_credentials=True, allow_methods=['*'], allow_headers=['*'], + expose_headers=expose_headers, ) diff --git a/ruoyi-fastapi-backend/middlewares/handle.py b/ruoyi-fastapi-backend/middlewares/handle.py index d9cebaf..b56c0c0 100644 --- a/ruoyi-fastapi-backend/middlewares/handle.py +++ b/ruoyi-fastapi-backend/middlewares/handle.py @@ -7,6 +7,7 @@ from middlewares.demo_mode_middleware import add_demo_mode_middleware from middlewares.gzip_middleware import add_gzip_middleware from middlewares.trace_middleware import add_trace_middleware +from middlewares.transport_crypto_middleware import add_transport_crypto_middleware def handle_middleware(app: FastAPI) -> None: @@ -26,3 +27,5 @@ def handle_middleware(app: FastAPI) -> None: if AppConfig.app_demo_mode: # 加载演示模式中间件 add_demo_mode_middleware(app) + # 加载传输层请求解密/响应加密中间件 + add_transport_crypto_middleware(app) diff --git a/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py b/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py new file mode 100644 index 0000000..623ea1c --- /dev/null +++ b/ruoyi-fastapi-backend/middlewares/transport_crypto_middleware.py @@ -0,0 +1,757 @@ +import json +from collections.abc import Awaitable, Callable +from urllib.parse import parse_qs, urlencode + +from fastapi import FastAPI, Request +from fastapi.datastructures import Headers, QueryParams +from fastapi.responses import JSONResponse +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from common.constant import HttpStatusConstant +from config.env import AppConfig, TransportCryptoConfig +from utils.transport_crypto_util import ( + DecryptedTransportEnvelope, + TransportCryptoMonitorUtil, + TransportCryptoUtil, + TransportSecurityUtil, +) + + +class TransportCryptoMiddleware: + """ + 传输层请求解密与响应加密中间件 + """ + + _ENCRYPT_REQUEST_HEADER = 'x-transport-encrypt' + _ENCRYPT_RESPONSE_HEADER = 'x-body-encrypted' + _ENCRYPT_ALG_HEADER = 'x-encrypt-alg' + _ENCRYPT_KID_HEADER = 'x-key-id' + _MONITOR_REQUEST_MODE_HEADER = 'x-transport-request-mode' + _MONITOR_RESPONSE_MODE_HEADER = 'x-transport-response-mode' + _MONITOR_STATUS_HEADER = 'x-transport-crypto-status' + _MONITOR_KID_HEADER = 'x-transport-key-id' + + def __init__(self, app: ASGIApp) -> None: + """ + 初始化传输层加解密中间件 + + :param app: FastAPI/Starlette应用对象 + :return: None + """ + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """ + 拦截HTTP请求,按配置执行请求解密与响应加密 + + :param scope: 当前ASGI请求作用域 + :param receive: ASGI receive函数 + :param send: ASGI send函数 + :return: None + """ + if scope['type'] != 'http' or not TransportCryptoConfig.transport_crypto_enabled: + await self.app(scope, receive, send) + return + + current_app = scope.get('app') + path = self._normalize_path(str(scope.get('path', ''))) + if ( + self._is_excluded_path(path) + or TransportCryptoConfig.transport_crypto_mode == 'off' + or not self._is_enabled_path(path) + ): + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + request_encrypted = headers.get(self._ENCRYPT_REQUEST_HEADER) == '1' + request_required = TransportCryptoConfig.transport_crypto_mode == 'required' or self._is_required_path(path) + + if request_required and not request_encrypted: + await TransportCryptoMonitorUtil.record_plain_request(current_app) + await TransportCryptoMonitorUtil.record_required_rejected( + current_app, str(scope.get('method', '')).upper(), path + ) + await TransportCryptoMonitorUtil.record_plain_response(current_app) + await self._send_error_response( + scope, + receive, + send, + '当前接口要求使用加密传输', + headers=self._build_monitor_headers( + request_mode='plain', + response_mode='plain', + crypto_status='required_missing', + ), + ) + return + + if not request_encrypted: + await TransportCryptoMonitorUtil.record_plain_request(current_app) + response_observer = self._build_passthrough_response_observer( + app=current_app, + send=send, + request_mode='plain', + crypto_status='pass_through', + ) + await self.app(scope, receive, response_observer) + return + + body = await self._read_body(receive) + request = Request(scope, receive=self._build_receive(body)) + try: + decrypted_scope, decrypted_body, crypto_context = await self._decrypt_request(scope, request, headers, body) + await TransportCryptoMonitorUtil.record_encrypted_request(current_app, str(crypto_context['kid'])) + await TransportCryptoMonitorUtil.record_decrypt_success(current_app, str(crypto_context['kid'])) + except Exception as exc: + error_crypto_context = self._build_error_crypto_context(scope, headers, body) + error_kid = ( + str(error_crypto_context['kid']) + if error_crypto_context + else self._extract_request_kid(scope, headers, body) + ) + await TransportCryptoMonitorUtil.record_encrypted_request(current_app, error_kid) + failure_reason = self._classify_failure_reason(str(exc)) + await TransportCryptoMonitorUtil.record_decrypt_failure( + current_app, + method=str(scope.get('method', '')).upper(), + path=path, + reason=failure_reason, + kid=error_kid, + ) + if error_crypto_context: + await TransportCryptoMonitorUtil.record_encrypted_response(current_app, error_kid, is_error=True) + else: + await TransportCryptoMonitorUtil.record_plain_response(current_app) + await self._send_error_response( + scope, + receive, + send, + str(exc) or '加密请求解析失败', + error_crypto_context, + headers=self._build_monitor_headers( + request_mode='encrypted', + response_mode='encrypted' if error_crypto_context else 'plain', + crypto_status=failure_reason, + kid=error_kid, + ), + ) + return + + async def send_wrapper(message: Message) -> None: + await response_encryptor(message) + + response_encryptor = self._build_response_encryptor( + app=current_app, + scope=decrypted_scope, + send=send, + crypto_context=crypto_context, + ) + await self.app(decrypted_scope, self._build_receive(decrypted_body), send_wrapper) + + async def _decrypt_request( + self, + scope: Scope, + request: Request, + headers: Headers, + body: bytes, + ) -> tuple[Scope, bytes, dict[str, str | bytes | bool]]: + """ + 解密请求并回写解密后的headers、query和body + + :param scope: 当前ASGI请求作用域 + :param request: FastAPI请求对象 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 解密后的scope、请求体与加密上下文 + """ + new_scope = dict(scope) + new_scope['state'] = dict(scope.get('state', {})) + new_scope['headers'] = self._remove_header(new_scope.get('headers', []), b'accept-encoding') + content_type = headers.get('content-type', '') + + query_envelope = self._extract_query_envelope(new_scope) + body_envelope = self._extract_body_envelope(content_type, body) + + if query_envelope is None and body_envelope is None: + raise ValueError('未找到可解密的请求载荷') + + crypto_context: dict[str, str | bytes | bool] | None = None + if query_envelope is not None: + decrypted_query = await self._decrypt_envelope(request, scope, query_envelope) + query_payload = self._loads_json_mapping(decrypted_query.plaintext.decode('utf-8')) + new_scope['query_string'] = urlencode(query_payload, doseq=True).encode('utf-8') + crypto_context = self._build_crypto_context(decrypted_query) + + decrypted_body = body + if body_envelope is not None: + decrypted_body_payload = await self._decrypt_envelope(request, scope, body_envelope) + if crypto_context and crypto_context['kid'] != decrypted_body_payload.kid: + raise ValueError('请求中存在不一致的密钥版本') + if crypto_context and crypto_context['aes_key'] != decrypted_body_payload.aes_key: + raise ValueError('请求中存在不一致的会话密钥') + if crypto_context is None: + crypto_context = self._build_crypto_context(decrypted_body_payload) + + if 'application/x-www-form-urlencoded' in content_type: + form_payload = self._loads_json_mapping(decrypted_body_payload.plaintext.decode('utf-8')) + decrypted_body = urlencode(form_payload, doseq=True).encode('utf-8') + else: + decrypted_body = decrypted_body_payload.plaintext + new_scope['headers'] = self._replace_header( + new_scope.get('headers', []), b'content-length', str(len(decrypted_body)).encode('utf-8') + ) + + if crypto_context is None: + raise ValueError('加密请求缺少可用的密钥上下文') + + new_scope['state']['transport_crypto_context'] = crypto_context + return new_scope, decrypted_body, crypto_context + + async def _decrypt_envelope( + self, + request: Request, + scope: Scope, + envelope: dict[str, str], + ) -> DecryptedTransportEnvelope: + """ + 解密单个请求信封并执行时间窗、防重放校验 + + :param request: 当前请求对象 + :param scope: 当前ASGI请求作用域 + :param envelope: 请求信封字典 + :return: 解密后的请求信封对象 + """ + decrypted_payload = TransportCryptoUtil.decrypt_envelope( + envelope, + expected_method=str(scope.get('method', '')).upper(), + expected_path=self._normalize_path(str(scope.get('path', ''))), + ) + TransportSecurityUtil.validate_timestamp(decrypted_payload.timestamp) + await TransportSecurityUtil.validate_replay(request, decrypted_payload.kid, decrypted_payload.nonce) + return decrypted_payload + + def _extract_query_envelope(self, scope: Scope) -> dict[str, str] | None: + """ + 从查询参数中提取加密信封 + + :param scope: 当前ASGI请求作用域 + :return: 查询参数中的信封字典,不存在时返回None + """ + query_params = QueryParams(scope.get('query_string', b'').decode('utf-8')) + encrypted_query = query_params.get('__enc') + if not encrypted_query: + return None + return TransportCryptoUtil.decode_query_envelope(encrypted_query) + + def _extract_body_envelope(self, content_type: str, body: bytes) -> dict[str, str] | None: + """ + 根据内容类型从请求体中提取加密信封 + + :param content_type: 当前请求内容类型 + :param body: 原始请求体字节串 + :return: 请求体中的信封字典,不存在时返回None + """ + if not body or 'multipart/form-data' in content_type: + return None + + if 'application/json' in content_type: + body_payload = json.loads(body.decode('utf-8')) + if not isinstance(body_payload, dict): + raise ValueError('加密请求体格式不合法') + return body_payload + + if 'application/x-www-form-urlencoded' in content_type: + parsed_form = parse_qs(body.decode('utf-8'), keep_blank_values=True) + body_envelope = { + key: values[-1] if isinstance(values, list) else values for key, values in parsed_form.items() + } + aad = body_envelope.get('aad') + if isinstance(aad, str) and aad: + try: + parsed_aad = json.loads(aad) + if isinstance(parsed_aad, dict): + body_envelope['aad'] = parsed_aad + except json.JSONDecodeError: + pass + return body_envelope + + return None + + def _build_response_encryptor( + self, + app: FastAPI | None, + scope: Scope, + send: Send, + crypto_context: dict[str, str | bytes | bool], + ) -> Callable[[Message], Awaitable[None]]: + """ + 构建响应加密发送器,仅对JSON响应执行加密 + + :param scope: 当前ASGI请求作用域 + :param send: ASGI send函数 + :param crypto_context: 当前请求加密上下文 + :return: 包装后的ASGI send函数 + """ + response_start_message: Message | None = None + buffered_json_body: list[bytes] = [] + should_buffer_json = False + + async def _encrypt_response(message: Message) -> None: + nonlocal response_start_message, should_buffer_json + + if message['type'] == 'http.response.start': + response_start_message = message + headers = Headers(raw=message.get('headers', [])) + content_type = headers.get('content-type', '') + should_buffer_json = 'application/json' in content_type + if not should_buffer_json: + await TransportCryptoMonitorUtil.record_plain_response(app) + await send( + { + **message, + 'headers': self._merge_response_headers( + message.get('headers', []), + self._build_monitor_headers( + request_mode='encrypted', + response_mode='plain', + crypto_status='ok', + kid=str(crypto_context['kid']), + ), + ), + } + ) + return + + if message['type'] != 'http.response.body': + await send(message) + return + + if not should_buffer_json or response_start_message is None: + await send(message) + return + + buffered_json_body.append(message.get('body', b'')) + if message.get('more_body', False): + return + + encrypted_body = TransportCryptoUtil.encrypt_response_body( + aes_key=crypto_context['aes_key'], + payload=b''.join(buffered_json_body), + kid=str(crypto_context['kid']), + method=str(scope.get('method', '')), + path=self._normalize_path(str(scope.get('path', ''))), + ) + response_headers = self._replace_header( + response_start_message.get('headers', []), + b'content-length', + str(len(encrypted_body)).encode('utf-8'), + ) + response_headers = self._replace_header(response_headers, b'content-type', b'application/json') + response_headers = self._replace_header( + response_headers, self._ENCRYPT_RESPONSE_HEADER.encode('utf-8'), b'1' + ) + response_headers = self._replace_header( + response_headers, + self._ENCRYPT_ALG_HEADER.encode('utf-8'), + TransportCryptoUtil.get_response_envelope_algorithm().encode('utf-8'), + ) + response_headers = self._replace_header( + response_headers, + self._ENCRYPT_KID_HEADER.encode('utf-8'), + str(crypto_context['kid']).encode('utf-8'), + ) + response_headers = self._merge_response_headers( + response_headers, + self._build_monitor_headers( + request_mode='encrypted', + response_mode='encrypted', + crypto_status='ok', + kid=str(crypto_context['kid']), + ), + ) + await TransportCryptoMonitorUtil.record_encrypted_response(app, str(crypto_context['kid'])) + await send({**response_start_message, 'headers': response_headers}) + await send({'type': 'http.response.body', 'body': encrypted_body, 'more_body': False}) + + return _encrypt_response + + def _build_passthrough_response_observer( + self, + app: FastAPI | None, + send: Send, + request_mode: str, + crypto_status: str, + kid: str | None = None, + ) -> Callable[[Message], Awaitable[None]]: + """ + 构建明文响应观察器,为响应追加监控诊断头 + + :param send: ASGI send函数 + :param request_mode: 请求传输模式 + :param crypto_status: 当前传输层处理状态 + :param kid: 可选的密钥版本 + :return: 包装后的ASGI send函数 + """ + has_recorded_response = False + + async def _observe_response(message: Message) -> None: + nonlocal has_recorded_response + + if message['type'] == 'http.response.start': + if not has_recorded_response: + await TransportCryptoMonitorUtil.record_plain_response(app) + has_recorded_response = True + await send( + { + **message, + 'headers': self._merge_response_headers( + message.get('headers', []), + self._build_monitor_headers( + request_mode=request_mode, + response_mode='plain', + crypto_status=crypto_status, + kid=kid, + ), + ), + } + ) + return + + await send(message) + + return _observe_response + + def _build_crypto_context(self, decrypted_payload: DecryptedTransportEnvelope) -> dict[str, str | bytes | bool]: + """ + 从解密结果构建请求生命周期内的加密上下文 + + :param decrypted_payload: 解密后的请求信封对象 + :return: 请求加密上下文字典 + """ + return { + 'active': True, + 'kid': decrypted_payload.kid, + 'aes_key': decrypted_payload.aes_key, + } + + def _build_error_crypto_context( + self, + scope: Scope, + headers: Headers, + body: bytes, + ) -> dict[str, str | bytes | bool] | None: + """ + 尝试在解密失败场景下提取AES会话密钥,以便返回加密错误响应 + + :param scope: 当前ASGI请求作用域 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 可用于构造加密错误响应的上下文字典,失败时返回None + """ + content_type = headers.get('content-type', '') + try: + query_envelope = self._extract_query_envelope(scope) + body_envelope = self._extract_body_envelope(content_type, body) + envelope = body_envelope or query_envelope + if envelope is None: + return None + TransportCryptoUtil._extract_and_validate_aad( + envelope, + expected_method=str(scope.get('method', '')).upper(), + expected_path=self._normalize_path(str(scope.get('path', ''))), + ) + return { + 'active': True, + 'kid': str(envelope['kid']), + 'aes_key': TransportCryptoUtil.decrypt_request_key(envelope), + } + except Exception: + return None + + def _extract_request_kid(self, scope: Scope, headers: Headers, body: bytes) -> str | None: + """ + 尝试从原始请求信封中提取密钥版本 + + :param scope: 当前ASGI请求作用域 + :param headers: 当前请求头对象 + :param body: 原始请求体字节串 + :return: 密钥版本,不存在时返回None + """ + content_type = headers.get('content-type', '') + try: + query_envelope = self._extract_query_envelope(scope) + body_envelope = self._extract_body_envelope(content_type, body) + envelope = body_envelope or query_envelope + except Exception: + return None + if envelope is None or not envelope.get('kid'): + return None + return str(envelope['kid']) + + @staticmethod + def _loads_json_mapping(payload: str) -> dict: + """ + 将JSON字符串解析为字典,并限制结果必须为JSON对象 + + :param payload: JSON字符串 + :return: 解析后的字典对象 + """ + json_payload = json.loads(payload) + if not isinstance(json_payload, dict): + raise ValueError('解密后的请求载荷必须为JSON对象') + return json_payload + + @staticmethod + async def _read_body(receive: Receive) -> bytes: + """ + 从ASGI receive中读取完整请求体 + + :param receive: ASGI receive函数 + :return: 完整请求体字节串 + """ + body_chunks: list[bytes] = [] + more_body = True + while more_body: + message = await receive() + if message['type'] != 'http.request': + continue + body_chunks.append(message.get('body', b'')) + more_body = message.get('more_body', False) + return b''.join(body_chunks) + + @staticmethod + def _build_receive(body: bytes) -> Receive: + """ + 根据指定请求体重建一次性可消费的ASGI receive函数 + + :param body: 需要回放的请求体字节串 + :return: 重建后的ASGI receive函数 + """ + has_been_called = False + + async def _receive() -> Message: + nonlocal has_been_called + if has_been_called: + return {'type': 'http.request', 'body': b'', 'more_body': False} + has_been_called = True + return {'type': 'http.request', 'body': body, 'more_body': False} + + return _receive + + @staticmethod + def _replace_header(headers: list[tuple[bytes, bytes]], key: bytes, value: bytes) -> list[tuple[bytes, bytes]]: + """ + 替换或新增指定响应头 + + :param headers: 原始请求/响应头列表 + :param key: 头名称 + :param value: 头值 + :return: 替换后的头列表 + """ + normalized_key = key.lower() + filtered_headers = [ + (header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key + ] + filtered_headers.append((key, value)) + return filtered_headers + + @staticmethod + def _remove_header(headers: list[tuple[bytes, bytes]], key: bytes) -> list[tuple[bytes, bytes]]: + """ + 删除指定请求头 + + :param headers: 原始请求头列表 + :param key: 头名称 + :return: 删除后的头列表 + """ + normalized_key = key.lower() + return [ + (header_key, header_value) for header_key, header_value in headers if header_key.lower() != normalized_key + ] + + @staticmethod + def _normalize_path(path: str) -> str: + """ + 标准化请求路径,剥离应用根路径前缀 + + :param path: 原始请求路径 + :return: 标准化后的业务路径 + """ + app_root_path = AppConfig.app_root_path + if app_root_path and path.startswith(app_root_path): + normalized_path = path[len(app_root_path) :] + return normalized_path or '/' + return path or '/' + + @classmethod + def _merge_response_headers( + cls, + headers: list[tuple[bytes, bytes]], + extra_headers: dict[str, str], + ) -> list[tuple[bytes, bytes]]: + """ + 将字符串响应头批量写回到原始headers列表 + + :param headers: 原始请求/响应头列表 + :param extra_headers: 需要追加的响应头 + :return: 合并后的响应头列表 + """ + merged_headers = headers + for header_key, header_value in extra_headers.items(): + merged_headers = cls._replace_header( + merged_headers, header_key.encode('utf-8'), header_value.encode('utf-8') + ) + return merged_headers + + @classmethod + def _build_monitor_headers( + cls, + request_mode: str, + response_mode: str, + crypto_status: str, + kid: str | None = None, + ) -> dict[str, str]: + """ + 构建传输层加解密监控响应头 + + :param request_mode: 请求传输模式 + :param response_mode: 响应传输模式 + :param crypto_status: 当前传输层处理状态 + :param kid: 可选的密钥版本 + :return: 监控响应头字典 + """ + headers = { + cls._MONITOR_REQUEST_MODE_HEADER: request_mode, + cls._MONITOR_RESPONSE_MODE_HEADER: response_mode, + cls._MONITOR_STATUS_HEADER: crypto_status, + } + if kid: + headers[cls._MONITOR_KID_HEADER] = kid + return headers + + @classmethod + async def _send_error_response( + cls, + scope: Scope, + receive: Receive, + send: Send, + message: str, + crypto_context: dict[str, str | bytes | bool] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + """ + 发送错误响应,在存在AES会话密钥时优先返回加密错误响应 + + :param scope: 当前ASGI请求作用域 + :param receive: ASGI receive函数 + :param send: ASGI send函数 + :param message: 错误信息 + :param crypto_context: 可选的请求加密上下文 + :param headers: 需要追加的诊断响应头 + :return: None + """ + response_content = {'code': HttpStatusConstant.BAD_REQUEST, 'msg': message, 'success': False} + response = JSONResponse(status_code=HttpStatusConstant.BAD_REQUEST, content=response_content) + if crypto_context: + encrypted_body = TransportCryptoUtil.encrypt_response_body( + aes_key=crypto_context['aes_key'], + payload=json.dumps(response_content, ensure_ascii=False).encode('utf-8'), + kid=str(crypto_context['kid']), + method=str(scope.get('method', '')), + path=cls._normalize_path(str(scope.get('path', ''))), + ) + response.body = encrypted_body + response.init_headers() + response.headers[cls._ENCRYPT_RESPONSE_HEADER] = '1' + response.headers[cls._ENCRYPT_ALG_HEADER] = TransportCryptoUtil.get_response_envelope_algorithm() + response.headers[cls._ENCRYPT_KID_HEADER] = str(crypto_context['kid']) + if headers: + response.headers.update(headers) + await response(scope, receive, send) + + @staticmethod + def _classify_failure_reason(message: str) -> str: + """ + 根据异常信息归类传输层失败原因 + + :param message: 原始异常信息 + :return: 失败原因分类编码 + """ + if not message or message == '加密请求解析失败': + return 'decrypt_failed' + failure_reason_mapping = ( + ('method/path与当前接口不匹配', 'aad_mismatch'), + ('缺少合法的aad', 'aad_invalid'), + ('已过期', 'timestamp_expired'), + ('缺少必要字段', 'envelope_fields_missing'), + ('协议版本不受支持', 'protocol_version_invalid'), + ('算法不受支持', 'algorithm_invalid'), + ('未找到可解密的请求载荷', 'envelope_missing'), + ('密钥版本', 'kid_mismatch'), + ) + for reason_keyword, reason_code in failure_reason_mapping: + if reason_keyword in message: + return reason_code + if '重复请求' in message or '重放' in message: + return 'replay_detected' + return 'decrypt_failed' + + @classmethod + def _is_excluded_path(cls, path: str) -> bool: + """ + 判断当前路径是否在传输加密排除列表内 + + :param path: 当前请求路径 + :return: 是否命中排除列表 + """ + excluded_paths = [ + excluded_path.strip() + for excluded_path in TransportCryptoConfig.transport_crypto_exclude_paths.split(',') + if excluded_path.strip() + ] + return any(path == excluded_path or path.startswith(f'{excluded_path}/') for excluded_path in excluded_paths) + + @classmethod + def _is_required_path(cls, path: str) -> bool: + """ + 判断当前路径是否在强制加密列表内 + + :param path: 当前请求路径 + :return: 是否命中强制加密列表 + """ + required_paths = [ + required_path.strip() + for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',') + if required_path.strip() + ] + if not required_paths: + return False + return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths) + + @classmethod + def _is_enabled_path(cls, path: str) -> bool: + """ + 判断当前路径是否在启用传输加密的列表内 + + :param path: 当前请求路径 + :return: 当前路径是否启用传输加密 + """ + enabled_paths = [ + enabled_path.strip() + for enabled_path in TransportCryptoConfig.transport_crypto_enabled_paths.split(',') + if enabled_path.strip() + ] + if not enabled_paths: + return True + return any(path == enabled_path or path.startswith(f'{enabled_path}/') for enabled_path in enabled_paths) + + +def add_transport_crypto_middleware(app: ASGIApp) -> None: + """ + 添加传输层加解密中间件 + + :param app: FastAPI/Starlette应用对象 + :return: None + """ + app.add_middleware(TransportCryptoMiddleware) diff --git a/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py b/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py new file mode 100644 index 0000000..781d21c --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/controller/transport_crypto_controller.py @@ -0,0 +1,78 @@ +from fastapi import Request, Response + +from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import PreAuthDependency +from common.constant import ApiNamespace +from common.router import APIRouterPro +from common.vo import DataResponseModel +from module_admin.entity.vo.transport_crypto_vo import ( + TransportCryptoFrontendConfigModel, + TransportCryptoMonitorModel, + TransportCryptoPublicKeyModel, +) +from module_admin.service.transport_crypto_service import TransportCryptoService +from utils.log_util import logger +from utils.response_util import ResponseUtil + +transport_crypto_controller = APIRouterPro(prefix='/transport/crypto', order_num=15, tags=['传输加密模块']) + + +@transport_crypto_controller.get( + '/frontend-config', + summary='获取前端传输加密配置接口', + description='公开接口,用于向前端下发当前传输层加解密启用状态与运行模式,供前端统一跟随后端策略', + response_model=DataResponseModel[TransportCryptoFrontendConfigModel], +) +@ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_FRONTEND_CONFIG, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) +async def get_transport_frontend_config(request: Request) -> Response: + """ + 获取当前前端传输层加解密运行配置 + + :param request: 当前请求对象 + :return: 前端传输层加解密运行配置响应 + """ + transport_frontend_config = await TransportCryptoService.get_transport_frontend_config_services() + logger.info('获取成功') + + return ResponseUtil.success(data=transport_frontend_config) + + +@transport_crypto_controller.get( + '/public-key', + summary='获取传输加密公钥接口', + description='公开接口,用于向前端下发当前可用的传输层加密公钥,已配置匿名限流保护', + response_model=DataResponseModel[TransportCryptoPublicKeyModel], +) +@ApiRateLimit(namespace=ApiNamespace.TRANSPORT_CRYPTO_PUBLIC_KEY, preset=ApiRateLimitPreset.ANON_PUBLIC_METADATA) +async def get_transport_public_key(request: Request) -> Response: + """ + 获取当前传输层加密公钥 + + :param request: 当前请求对象 + :return: 公钥下发响应 + """ + transport_public_key = await TransportCryptoService.get_transport_public_key_services() + logger.info('获取成功') + + return ResponseUtil.success(data=transport_public_key) + + +@transport_crypto_controller.get( + '/monitor', + summary='获取传输层加解密监控信息接口', + description='用于获取基于Redis聚合的传输层加解密运行状态与统计信息', + response_model=DataResponseModel[TransportCryptoMonitorModel], + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('monitor:transportCrypto:list')], +) +async def get_transport_crypto_monitor_info(request: Request) -> Response: + """ + 获取基于Redis聚合的传输层加解密监控信息 + + :param request: 当前请求对象 + :return: 传输层加解密监控信息响应 + """ + transport_crypto_monitor_info = await TransportCryptoService.get_transport_crypto_monitor_info_services(request) + logger.info('获取成功') + + return ResponseUtil.success(data=transport_crypto_monitor_info) diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py new file mode 100644 index 0000000..d7511fd --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/transport_crypto_vo.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class TransportCryptoFrontendConfigModel(BaseModel): + """ + 传输层加解密前端运行配置模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + transport_crypto_enabled: bool = Field(description='后端是否启用传输层加解密') + transport_crypto_mode: str = Field(description='当前传输层加解密模式') + transport_crypto_active: bool = Field(description='前端当前是否应启用传输层加解密') + envelope_version: str = Field(description='当前传输层加密信封协议版本') + public_key_url: str = Field(description='传输层公钥接口路径') + request_envelope_algorithm: str = Field(description='前端请求信封算法标识') + response_envelope_algorithm: str = Field(description='前端响应信封算法标识') + enabled_paths: list[str] = Field(description='启用传输层加解密的路径列表') + required_paths: list[str] = Field(description='强制要求加密传输的路径列表') + exclude_paths: list[str] = Field(description='排除传输层加解密的路径列表') + max_encrypted_get_url_length: int = Field(description='前端执行加密GET/DELETE请求时允许的最大URL长度') + config_expire_at: int = Field(description='前端配置建议刷新时间戳') + + +class TransportCryptoPublicKeyModel(BaseModel): + """ + 传输层加解密公钥下发模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + kid: str = Field(description='当前启用的密钥版本标识') + envelope_version: str = Field(description='当前传输层加密信封协议版本') + alg: str = Field(description='当前传输层加密算法标识') + public_key: str = Field(description='当前可用的传输层公钥') + supported_kids: list[str] = Field(description='当前支持解密的密钥版本列表') + expire_at: int = Field(description='当前公钥建议刷新时间戳') + + +class TransportCryptoKidStatModel(BaseModel): + """ + 传输层加解密按密钥版本统计模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + kid: str | None = Field(default=None, description='密钥版本标识') + encrypted_requests: int | None = Field(default=0, description='加密请求次数') + decrypt_success: int | None = Field(default=0, description='请求解密成功次数') + decrypt_failure: int | None = Field(default=0, description='请求解密失败次数') + encrypted_responses: int | None = Field(default=0, description='加密响应次数') + + +class TransportCryptoFailureRecordModel(BaseModel): + """ + 传输层加解密最近失败记录模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + time: datetime | None = Field(default=None, description='失败时间') + method: str | None = Field(default=None, description='请求方法') + path: str | None = Field(default=None, description='请求路径') + reason: str | None = Field(default=None, description='失败原因分类') + kid: str | None = Field(default=None, description='密钥版本标识') + + +class TransportCryptoMonitorModel(BaseModel): + """ + 传输层加解密监控模型 + """ + + model_config = ConfigDict(alias_generator=to_camel) + + monitor_scope: str | None = Field(default=None, description='监控统计范围,默认基于Redis聚合') + started_at: datetime | None = Field(default=None, description='当前监控统计起始时间') + app_env: str | None = Field(default=None, description='当前应用环境') + transport_crypto_enabled: bool | None = Field(default=None, description='是否启用传输层加解密') + transport_crypto_mode: str | None = Field(default=None, description='当前传输层加解密模式') + current_kid: str | None = Field(default=None, description='当前启用的密钥版本') + supported_kids: list[str] | None = Field(default=[], description='当前支持的密钥版本列表') + enabled_paths: list[str] | None = Field(default=[], description='启用传输层加解密的路径列表') + required_paths: list[str] | None = Field(default=[], description='强制要求加密传输的路径列表') + exclude_paths: list[str] | None = Field(default=[], description='排除传输层加解密的路径列表') + requests_total: int | None = Field(default=0, description='命中传输层加解密规则的请求总数') + plain_requests_total: int | None = Field(default=0, description='明文请求总数') + encrypted_requests_total: int | None = Field(default=0, description='加密请求总数') + required_rejected_total: int | None = Field(default=0, description='强制加密接口被拒绝的次数') + decrypt_success_total: int | None = Field(default=0, description='请求解密成功次数') + decrypt_failure_total: int | None = Field(default=0, description='请求解密失败次数') + plain_responses_total: int | None = Field(default=0, description='明文响应次数') + encrypted_responses_total: int | None = Field(default=0, description='加密响应次数') + encrypted_error_responses_total: int | None = Field(default=0, description='加密错误响应次数') + failure_reasons: dict[str, int] | None = Field(default={}, description='按失败原因归类的次数统计') + kid_stats: list[TransportCryptoKidStatModel] | None = Field(default=[], description='按密钥版本归类的统计信息') + recent_failures: list[TransportCryptoFailureRecordModel] | None = Field(default=[], description='最近失败事件列表') diff --git a/ruoyi-fastapi-backend/module_admin/service/transport_crypto_service.py b/ruoyi-fastapi-backend/module_admin/service/transport_crypto_service.py new file mode 100644 index 0000000..f581314 --- /dev/null +++ b/ruoyi-fastapi-backend/module_admin/service/transport_crypto_service.py @@ -0,0 +1,44 @@ +from fastapi import Request + +from module_admin.entity.vo.transport_crypto_vo import ( + TransportCryptoFrontendConfigModel, + TransportCryptoMonitorModel, + TransportCryptoPublicKeyModel, +) +from utils.transport_crypto_util import TransportCryptoMonitorUtil, TransportCryptoUtil + + +class TransportCryptoService: + """ + 传输加密模块服务层 + """ + + @classmethod + async def get_transport_frontend_config_services(cls) -> TransportCryptoFrontendConfigModel: + """ + 获取前端传输加密运行配置service + + :return: 前端传输加密运行配置 + """ + return TransportCryptoFrontendConfigModel.model_validate(TransportCryptoUtil.build_frontend_config_payload()) + + @classmethod + async def get_transport_public_key_services(cls) -> TransportCryptoPublicKeyModel: + """ + 获取传输加密公钥service + + :return: 传输加密公钥信息 + """ + return TransportCryptoPublicKeyModel.model_validate(TransportCryptoUtil.build_public_key_payload()) + + @classmethod + async def get_transport_crypto_monitor_info_services(cls, request: Request) -> TransportCryptoMonitorModel: + """ + 获取传输加密监控信息service + + :param request: Request对象 + :return: 传输加密监控信息 + """ + transport_crypto_monitor_info = await TransportCryptoMonitorUtil.get_snapshot(request.app) + + return TransportCryptoMonitorModel.model_validate(transport_crypto_monitor_info) diff --git a/ruoyi-fastapi-backend/server.py b/ruoyi-fastapi-backend/server.py index d463d9d..c635503 100644 --- a/ruoyi-fastapi-backend/server.py +++ b/ruoyi-fastapi-backend/server.py @@ -17,6 +17,7 @@ from utils.common_util import worship from utils.log_util import logger from utils.server_util import APIDocsUtil, IPUtil, StartupUtil +from utils.transport_crypto_util import TransportKeyProvider async def _start_background_tasks(app: FastAPI) -> None: @@ -89,6 +90,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.info(f'⏰️ {AppConfig.app_name}开始启动') if startup_log_enabled: worship() + TransportKeyProvider.validate_runtime_configuration() await init_create_table() await RedisUtil.check_redis_connection(app.state.redis, log_enabled=startup_log_enabled) await RedisUtil.init_sys_dict(app.state.redis) diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql index de19e35..0e68fe9 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql @@ -248,26 +248,27 @@ insert into sys_menu values(3, '系统工具', 0, '3', 'tool', nul insert into sys_menu values(4, 'AI 管理', 0, '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'bug', 'admin', current_timestamp, '', null, 'AI 管理目录'); insert into sys_menu values(99, '若依官网', 0, '99', 'http://ruoyi.vip', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', current_timestamp, '', null, '若依官网地址'); -- 二级菜单 -insert into sys_menu values(100, '用户管理', 1, '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', current_timestamp, '', null, '用户管理菜单'); -insert into sys_menu values(101, '角色管理', 1, '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', current_timestamp, '', null, '角色管理菜单'); -insert into sys_menu values(102, '菜单管理', 1, '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', current_timestamp, '', null, '菜单管理菜单'); -insert into sys_menu values(103, '部门管理', 1, '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', current_timestamp, '', null, '部门管理菜单'); -insert into sys_menu values(104, '岗位管理', 1, '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', current_timestamp, '', null, '岗位管理菜单'); -insert into sys_menu values(105, '字典管理', 1, '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', current_timestamp, '', null, '字典管理菜单'); -insert into sys_menu values(106, '参数设置', 1, '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', current_timestamp, '', null, '参数设置菜单'); -insert into sys_menu values(107, '通知公告', 1, '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', current_timestamp, '', null, '通知公告菜单'); -insert into sys_menu values(108, '日志管理', 1, '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', current_timestamp, '', null, '日志管理菜单'); -insert into sys_menu values(109, '在线用户', 2, '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', current_timestamp, '', null, '在线用户菜单'); -insert into sys_menu values(110, '定时任务', 2, '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', current_timestamp, '', null, '定时任务菜单'); -insert into sys_menu values(111, '数据监控', 2, '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', current_timestamp, '', null, '数据监控菜单'); -insert into sys_menu values(112, '服务监控', 2, '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', current_timestamp, '', null, '服务监控菜单'); -insert into sys_menu values(113, '缓存监控', 2, '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', current_timestamp, '', null, '缓存监控菜单'); -insert into sys_menu values(114, '缓存列表', 2, '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', current_timestamp, '', null, '缓存列表菜单'); -insert into sys_menu values(115, '表单构建', 3, '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', current_timestamp, '', null, '表单构建菜单'); -insert into sys_menu values(116, '代码生成', 3, '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', current_timestamp, '', null, '代码生成菜单'); -insert into sys_menu values(117, '系统接口', 3, '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', current_timestamp, '', null, '系统接口菜单'); -insert into sys_menu values(118, '模型管理', 4, '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'form', 'admin', current_timestamp, '', null, '模型管理菜单'); -insert into sys_menu values(119, 'AI 对话', 4, '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'wechat', 'admin', current_timestamp, '', null, 'AI 对话菜单'); +insert into sys_menu values(100, '用户管理', 1, '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', current_timestamp, '', null, '用户管理菜单'); +insert into sys_menu values(101, '角色管理', 1, '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', current_timestamp, '', null, '角色管理菜单'); +insert into sys_menu values(102, '菜单管理', 1, '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', current_timestamp, '', null, '菜单管理菜单'); +insert into sys_menu values(103, '部门管理', 1, '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', current_timestamp, '', null, '部门管理菜单'); +insert into sys_menu values(104, '岗位管理', 1, '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', current_timestamp, '', null, '岗位管理菜单'); +insert into sys_menu values(105, '字典管理', 1, '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', current_timestamp, '', null, '字典管理菜单'); +insert into sys_menu values(106, '参数设置', 1, '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', current_timestamp, '', null, '参数设置菜单'); +insert into sys_menu values(107, '通知公告', 1, '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', current_timestamp, '', null, '通知公告菜单'); +insert into sys_menu values(108, '日志管理', 1, '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', current_timestamp, '', null, '日志管理菜单'); +insert into sys_menu values(109, '在线用户', 2, '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', current_timestamp, '', null, '在线用户菜单'); +insert into sys_menu values(110, '定时任务', 2, '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', current_timestamp, '', null, '定时任务菜单'); +insert into sys_menu values(111, '数据监控', 2, '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', current_timestamp, '', null, '数据监控菜单'); +insert into sys_menu values(112, '服务监控', 2, '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', current_timestamp, '', null, '服务监控菜单'); +insert into sys_menu values(113, '缓存监控', 2, '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', current_timestamp, '', null, '缓存监控菜单'); +insert into sys_menu values(114, '缓存列表', 2, '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', current_timestamp, '', null, '缓存列表菜单'); +insert into sys_menu values(120, '传输加密', 2, '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', current_timestamp, '', null, '传输加密监控菜单'); +insert into sys_menu values(115, '表单构建', 3, '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', current_timestamp, '', null, '表单构建菜单'); +insert into sys_menu values(116, '代码生成', 3, '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', current_timestamp, '', null, '代码生成菜单'); +insert into sys_menu values(117, '系统接口', 3, '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', current_timestamp, '', null, '系统接口菜单'); +insert into sys_menu values(118, '模型管理', 4, '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'form', 'admin', current_timestamp, '', null, '模型管理菜单'); +insert into sys_menu values(119, 'AI 对话', 4, '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'wechat', 'admin', current_timestamp, '', null, 'AI 对话菜单'); -- 三级菜单 insert into sys_menu values(500, '操作日志', 108, '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', current_timestamp, '', null, '操作日志菜单'); insert into sys_menu values(501, '登录日志', 108, '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', current_timestamp, '', null, '登录日志菜单'); @@ -405,6 +406,7 @@ insert into sys_role_menu values (2, 111); insert into sys_role_menu values (2, 112); insert into sys_role_menu values (2, 113); insert into sys_role_menu values (2, 114); +insert into sys_role_menu values (2, 120); insert into sys_role_menu values (2, 115); insert into sys_role_menu values (2, 116); insert into sys_role_menu values (2, 117); diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql index dafc02a..623b9bf 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql @@ -165,26 +165,27 @@ insert into sys_menu values('3', '系统工具', '0', '3', 'tool', insert into sys_menu values('4', 'AI 管理', '0', '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'ai-manage', 'admin', sysdate(), '', null, 'AI 管理目录'); insert into sys_menu values('99', '若依官网', '0', '99', 'http://ruoyi.vip', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', sysdate(), '', null, '若依官网地址'); -- 二级菜单 -insert into sys_menu values('100', '用户管理', '1', '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', sysdate(), '', null, '用户管理菜单'); -insert into sys_menu values('101', '角色管理', '1', '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', sysdate(), '', null, '角色管理菜单'); -insert into sys_menu values('102', '菜单管理', '1', '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', sysdate(), '', null, '菜单管理菜单'); -insert into sys_menu values('103', '部门管理', '1', '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', sysdate(), '', null, '部门管理菜单'); -insert into sys_menu values('104', '岗位管理', '1', '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', sysdate(), '', null, '岗位管理菜单'); -insert into sys_menu values('105', '字典管理', '1', '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', sysdate(), '', null, '字典管理菜单'); -insert into sys_menu values('106', '参数设置', '1', '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', sysdate(), '', null, '参数设置菜单'); -insert into sys_menu values('107', '通知公告', '1', '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', sysdate(), '', null, '通知公告菜单'); -insert into sys_menu values('108', '日志管理', '1', '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', sysdate(), '', null, '日志管理菜单'); -insert into sys_menu values('109', '在线用户', '2', '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', sysdate(), '', null, '在线用户菜单'); -insert into sys_menu values('110', '定时任务', '2', '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', sysdate(), '', null, '定时任务菜单'); -insert into sys_menu values('111', '数据监控', '2', '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', sysdate(), '', null, '数据监控菜单'); -insert into sys_menu values('112', '服务监控', '2', '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', sysdate(), '', null, '服务监控菜单'); -insert into sys_menu values('113', '缓存监控', '2', '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', sysdate(), '', null, '缓存监控菜单'); -insert into sys_menu values('114', '缓存列表', '2', '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', sysdate(), '', null, '缓存列表菜单'); -insert into sys_menu values('115', '表单构建', '3', '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', sysdate(), '', null, '表单构建菜单'); -insert into sys_menu values('116', '代码生成', '3', '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', sysdate(), '', null, '代码生成菜单'); -insert into sys_menu values('117', '系统接口', '3', '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', sysdate(), '', null, '系统接口菜单'); -insert into sys_menu values('118', '模型管理', '4', '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'ai-model', 'admin', sysdate(), '', null, '模型管理菜单'); -insert into sys_menu values('119', 'AI 对话', '4', '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'ai-chat', 'admin', sysdate(), '', null, 'AI 对话菜单'); +insert into sys_menu values('100', '用户管理', '1', '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', sysdate(), '', null, '用户管理菜单'); +insert into sys_menu values('101', '角色管理', '1', '2', 'role', 'system/role/index', '', '', 1, 0, 'C', '0', '0', 'system:role:list', 'peoples', 'admin', sysdate(), '', null, '角色管理菜单'); +insert into sys_menu values('102', '菜单管理', '1', '3', 'menu', 'system/menu/index', '', '', 1, 0, 'C', '0', '0', 'system:menu:list', 'tree-table', 'admin', sysdate(), '', null, '菜单管理菜单'); +insert into sys_menu values('103', '部门管理', '1', '4', 'dept', 'system/dept/index', '', '', 1, 0, 'C', '0', '0', 'system:dept:list', 'tree', 'admin', sysdate(), '', null, '部门管理菜单'); +insert into sys_menu values('104', '岗位管理', '1', '5', 'post', 'system/post/index', '', '', 1, 0, 'C', '0', '0', 'system:post:list', 'post', 'admin', sysdate(), '', null, '岗位管理菜单'); +insert into sys_menu values('105', '字典管理', '1', '6', 'dict', 'system/dict/index', '', '', 1, 0, 'C', '0', '0', 'system:dict:list', 'dict', 'admin', sysdate(), '', null, '字典管理菜单'); +insert into sys_menu values('106', '参数设置', '1', '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', sysdate(), '', null, '参数设置菜单'); +insert into sys_menu values('107', '通知公告', '1', '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', sysdate(), '', null, '通知公告菜单'); +insert into sys_menu values('108', '日志管理', '1', '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', sysdate(), '', null, '日志管理菜单'); +insert into sys_menu values('109', '在线用户', '2', '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', sysdate(), '', null, '在线用户菜单'); +insert into sys_menu values('110', '定时任务', '2', '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', sysdate(), '', null, '定时任务菜单'); +insert into sys_menu values('111', '数据监控', '2', '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', sysdate(), '', null, '数据监控菜单'); +insert into sys_menu values('112', '服务监控', '2', '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', sysdate(), '', null, '服务监控菜单'); +insert into sys_menu values('113', '缓存监控', '2', '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', sysdate(), '', null, '缓存监控菜单'); +insert into sys_menu values('114', '缓存列表', '2', '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', sysdate(), '', null, '缓存列表菜单'); +insert into sys_menu values('120', '传输加密', '2', '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', sysdate(), '', null, '传输加密监控菜单'); +insert into sys_menu values('115', '表单构建', '3', '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', sysdate(), '', null, '表单构建菜单'); +insert into sys_menu values('116', '代码生成', '3', '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', sysdate(), '', null, '代码生成菜单'); +insert into sys_menu values('117', '系统接口', '3', '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', sysdate(), '', null, '系统接口菜单'); +insert into sys_menu values('118', '模型管理', '4', '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'ai-model', 'admin', sysdate(), '', null, '模型管理菜单'); +insert into sys_menu values('119', 'AI 对话', '4', '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'ai-chat', 'admin', sysdate(), '', null, 'AI 对话菜单'); -- 三级菜单 insert into sys_menu values('500', '操作日志', '108', '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', sysdate(), '', null, '操作日志菜单'); insert into sys_menu values('501', '登录日志', '108', '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', sysdate(), '', null, '登录日志菜单'); @@ -318,6 +319,7 @@ insert into sys_role_menu values ('2', '111'); insert into sys_role_menu values ('2', '112'); insert into sys_role_menu values ('2', '113'); insert into sys_role_menu values ('2', '114'); +insert into sys_role_menu values ('2', '120'); insert into sys_role_menu values ('2', '115'); insert into sys_role_menu values ('2', '116'); insert into sys_role_menu values ('2', '117'); diff --git a/ruoyi-fastapi-backend/utils/transport_crypto_util.py b/ruoyi-fastapi-backend/utils/transport_crypto_util.py new file mode 100644 index 0000000..7b53197 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/transport_crypto_util.py @@ -0,0 +1,1284 @@ +import base64 +import json +import os +import time +from collections import Counter, defaultdict, deque +from dataclasses import dataclass +from datetime import datetime +from threading import Lock +from typing import Any + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from fastapi import FastAPI, Request +from redis import asyncio as aioredis + +from config.env import AppConfig, TransportCryptoConfig +from utils.log_util import logger + + +# 通用编码辅助 +def _urlsafe_b64encode(data: bytes) -> str: + """ + 将字节串编码为URL安全的Base64字符串 + + :param data: 原始字节串 + :return: URL安全的Base64字符串 + """ + return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=') + + +def _urlsafe_b64decode(data: str) -> bytes: + """ + 将URL安全的Base64字符串解码为字节串 + + :param data: URL安全的Base64字符串 + :return: 解码后的字节串 + """ + padding_length = (-len(data)) % 4 + return base64.urlsafe_b64decode(f'{data}{"=" * padding_length}'.encode()) + + +@dataclass(frozen=True) +class TransportKeyPair: + """ + 传输层密钥对载体 + + kid: 密钥版本标识 + private_key_pem: PEM格式私钥 + public_key_pem: PEM格式公钥 + """ + + kid: str + private_key_pem: str + public_key_pem: str + + +# 传输层数据载体 +@dataclass(frozen=True) +class DecryptedTransportEnvelope: + """ + 请求信封解密结果 + + kid: 请求使用的密钥版本标识 + nonce: 请求随机数 + timestamp: 请求时间戳 + aes_key: 当前请求协商出的AES会话密钥 + aad: 通过校验后的AAD上下文 + plaintext: 解密得到的原始请求载荷 + """ + + kid: str + nonce: str + timestamp: int + aes_key: bytes + aad: dict[str, str] + plaintext: bytes + + +# 传输层密钥管理 +class TransportKeyProvider: + """ + 传输层密钥提供者 + """ + + _lock = Lock() + _key_pairs: dict[str, TransportKeyPair] | None = None + _MIN_RSA_KEY_SIZE = 2048 + _RSA_KEY_SIZE_STEP = 256 + + @classmethod + def validate_runtime_configuration(cls) -> None: + """ + 校验传输层加解密运行配置,确保启用时显式配置密钥对 + + :return: None + """ + if not TransportCryptoConfig.transport_crypto_enabled or TransportCryptoConfig.transport_crypto_mode == 'off': + return + + configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key) + configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key) + rsa_key_size = TransportCryptoConfig.transport_crypto_rsa_key_size + + if rsa_key_size < cls._MIN_RSA_KEY_SIZE or rsa_key_size % cls._RSA_KEY_SIZE_STEP != 0: + raise ValueError('TRANSPORT_CRYPTO_RSA_KEY_SIZE必须大于等于2048,且为256的整数倍') + + if not configured_private_key or not configured_public_key: + raise ValueError( + '启用传输层加解密时,必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY' + ) + + private_key = serialization.load_pem_private_key(configured_private_key.encode('utf-8'), password=None) + derived_public_key = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) + if cls._normalize_pem(derived_public_key) != configured_public_key: + raise ValueError('TRANSPORT_CRYPTO_PUBLIC_KEY与TRANSPORT_CRYPTO_PRIVATE_KEY不匹配') + + if TransportCryptoConfig.transport_crypto_legacy_key_pairs: + cls._build_legacy_key_pairs() + + @classmethod + def get_current_key_pair(cls) -> TransportKeyPair: + """ + 获取当前启用的密钥对 + + :return: 当前启用的密钥对 + """ + if cls._key_pairs is None: + with cls._lock: + if cls._key_pairs is None: + cls._key_pairs = cls._build_key_pairs() + + return cls._key_pairs[TransportCryptoConfig.transport_crypto_kid] + + @classmethod + def get_current_kid(cls) -> str: + """ + 获取当前启用的密钥标识 + + :return: 当前启用的密钥标识 + """ + return cls.get_current_key_pair().kid + + @classmethod + def get_public_key_pem(cls, kid: str | None = None) -> str: + """ + 获取公钥PEM + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: PEM格式公钥字符串 + """ + return cls.get_key_pair(kid).public_key_pem + + @classmethod + def get_private_key_pem(cls, kid: str | None = None) -> str: + """ + 获取私钥PEM + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: PEM格式私钥字符串 + """ + return cls.get_key_pair(kid).private_key_pem + + @classmethod + def get_key_pair(cls, kid: str | None = None) -> TransportKeyPair: + """ + 根据kid获取密钥对,未传入时返回当前密钥对 + + :param kid: 密钥版本标识,未传入时默认使用当前版本 + :return: 匹配到的密钥对 + """ + target_kid = kid or cls.get_current_kid() + if cls._key_pairs is None: + with cls._lock: + if cls._key_pairs is None: + cls._key_pairs = cls._build_key_pairs() + key_pair = cls._key_pairs.get(target_kid) + if key_pair is None: + raise ValueError('密钥版本不存在') + return key_pair + + @classmethod + def get_supported_kids(cls) -> tuple[str, ...]: + """ + 获取当前支持解密的全部密钥版本 + + :return: 当前支持解密的密钥版本元组 + """ + if cls._key_pairs is None: + cls.get_current_key_pair() + return tuple(cls._key_pairs.keys()) + + @classmethod + def _build_key_pairs(cls) -> dict[str, TransportKeyPair]: + """ + 构建当前进程可用的全部密钥对映射 + + :return: 以kid为键的密钥对映射 + """ + configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key) + configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key) + kid = TransportCryptoConfig.transport_crypto_kid + + if not configured_private_key or not configured_public_key: + raise ValueError( + '启用传输层加解密时,必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY' + ) + + key_pairs = { + kid: TransportKeyPair(kid=kid, private_key_pem=configured_private_key, public_key_pem=configured_public_key) + } + key_pairs.update(cls._build_legacy_key_pairs()) + return key_pairs + + @classmethod + def _build_legacy_key_pairs(cls) -> dict[str, TransportKeyPair]: + """ + 构建历史密钥对映射,用于密钥轮换窗口内的兼容解密 + + :return: 以kid为键的历史密钥对映射 + """ + legacy_key_pairs: dict[str, TransportKeyPair] = {} + configured_legacy_key_pairs = TransportCryptoConfig.transport_crypto_legacy_key_pairs + if not configured_legacy_key_pairs: + return legacy_key_pairs + + try: + parsed_key_pairs = json.loads(configured_legacy_key_pairs) + except json.JSONDecodeError as exc: + raise ValueError('传输层历史密钥配置不是合法JSON') from exc + + if not isinstance(parsed_key_pairs, list): + raise ValueError('传输层历史密钥配置必须是JSON数组') + + for item in parsed_key_pairs: + if not isinstance(item, dict): + raise ValueError('传输层历史密钥项必须是JSON对象') + item_kid = item.get('kid') + private_key_pem = cls._normalize_pem(item.get('privateKey') or item.get('private_key') or '') + public_key_pem = cls._normalize_pem(item.get('publicKey') or item.get('public_key') or '') + if not item_kid or not private_key_pem: + raise ValueError('传输层历史密钥项必须包含kid和privateKey') + if not public_key_pem: + private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None) + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) + legacy_key_pairs[str(item_kid)] = TransportKeyPair( + kid=str(item_kid), + private_key_pem=private_key_pem, + public_key_pem=public_key_pem, + ) + + return legacy_key_pairs + + @staticmethod + def _normalize_pem(pem_value: str) -> str: + """ + 兼容环境变量中的换行转义 + + :param pem_value: 原始PEM字符串 + :return: 标准化后的PEM字符串 + """ + return pem_value.replace('\\n', '\n').strip() if pem_value else '' + + +# 传输层安全校验 +class TransportSecurityUtil: + """ + 传输层安全校验工具 + """ + + @classmethod + def validate_timestamp(cls, timestamp: int) -> None: + """ + 校验请求时间窗 + + :param timestamp: 请求信封中的时间戳 + :return: None + """ + now_timestamp = int(time.time()) + if abs(now_timestamp - timestamp) > TransportCryptoConfig.transport_crypto_clock_skew_seconds: + logger.warning( + '传输层加密请求时间窗校验失败,request_ts={}, now_ts={}, allowed_skew={}', + timestamp, + now_timestamp, + TransportCryptoConfig.transport_crypto_clock_skew_seconds, + ) + raise ValueError('加密请求已过期,请刷新页面后重试') + + @classmethod + async def validate_replay(cls, request: Request, kid: str, nonce: str) -> None: + """ + 使用Redis进行防重放校验 + + :param request: 当前请求对象 + :param kid: 当前密钥版本标识 + :param nonce: 当前请求随机数 + :return: None + """ + redis = getattr(request.app.state, 'redis', None) + if redis is None: + if cls._should_fail_closed_when_replay_check_unavailable(request): + logger.error('Redis未初始化,当前请求要求严格防重放校验,已拒绝请求') + raise ValueError('服务端防重放校验不可用,请稍后重试') + logger.warning('Redis未初始化,已跳过传输层防重放校验') + return + + replay_key = f'transport:replay:{kid}:{nonce}' + try: + is_success = await redis.set( + replay_key, '1', ex=TransportCryptoConfig.transport_crypto_replay_ttl_seconds, nx=True + ) + except Exception as exc: + if cls._should_fail_closed_when_replay_check_unavailable(request): + logger.error('Redis防重放校验执行失败,当前请求要求严格校验,error={}', exc) + raise ValueError('服务端防重放校验不可用,请稍后重试') from exc + logger.warning('Redis防重放校验执行失败,已跳过当前请求的防重放校验,error={}', exc) + return + if not is_success: + logger.warning('传输层加密请求检测到重放,kid={}, nonce={}', kid, nonce) + raise ValueError('检测到重复请求,请勿重放加密报文') + + @classmethod + def _should_fail_closed_when_replay_check_unavailable(cls, request: Request) -> bool: + """ + 判断当前请求在防重放能力不可用时是否需要直接拒绝 + + :param request: 当前请求对象 + :return: 是否需要失败关闭 + """ + if TransportCryptoConfig.transport_crypto_mode == 'required': + return True + current_path = cls._normalize_path(str(request.scope.get('path', ''))) + return cls._is_required_path(current_path) + + @staticmethod + def _normalize_path(path: str) -> str: + """ + 标准化请求路径,剥离应用根路径前缀 + + :param path: 原始请求路径 + :return: 标准化后的业务路径 + """ + app_root_path = AppConfig.app_root_path + if app_root_path and path.startswith(app_root_path): + normalized_path = path[len(app_root_path) :] + return normalized_path or '/' + return path or '/' + + @staticmethod + def _is_required_path(path: str) -> bool: + """ + 判断当前路径是否命中强制加密路径配置 + + :param path: 当前请求路径 + :return: 是否命中强制加密路径 + """ + required_paths = [ + required_path.strip() + for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',') + if required_path.strip() + ] + if not required_paths: + return False + return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths) + + +# 传输层加解密核心能力 +class TransportCryptoUtil: + """ + 传输层加解密工具 + """ + + _ENVELOPE_VERSION = '1' + _RESPONSE_ENVELOPE_ALGORITHM = 'AES_256_GCM' + _REQUIRED_ENVELOPE_FIELDS = ('kid', 'ts', 'nonce', 'ek', 'iv', 'ct', 'aad') + + @classmethod + def get_response_envelope_algorithm(cls) -> str: + """ + 获取响应信封算法标识 + + :return: 响应信封算法标识 + """ + return cls._RESPONSE_ENVELOPE_ALGORITHM + + @classmethod + def decrypt_envelope( + cls, + envelope: dict[str, Any], + expected_method: str, + expected_path: str, + ) -> DecryptedTransportEnvelope: + """ + 解密请求信封 + + :param envelope: 请求加密信封 + :param expected_method: 当前请求预期HTTP方法 + :param expected_path: 当前请求预期路径 + :return: 解密后的请求信封对象 + """ + cls._validate_envelope(envelope) + kid = str(envelope['kid']) + aad = cls._extract_and_validate_aad(envelope, expected_method, expected_path) + aes_key = cls.decrypt_request_key(envelope) + iv = _urlsafe_b64decode(str(envelope['iv'])) + ciphertext = _urlsafe_b64decode(str(envelope['ct'])) + plaintext = AESGCM(aes_key).decrypt(iv, ciphertext, cls._build_aad_bytes(aad)) + + return DecryptedTransportEnvelope( + kid=kid, + nonce=str(envelope['nonce']), + timestamp=int(envelope['ts']), + aes_key=aes_key, + aad=aad, + plaintext=plaintext, + ) + + @classmethod + def decrypt_request_key(cls, envelope: dict[str, Any]) -> bytes: + """ + 仅解出请求中的AES会话密钥,用于异常场景构造加密错误响应 + + :param envelope: 请求加密信封 + :return: 请求协商出的AES会话密钥 + """ + kid = str(envelope['kid']) + private_key_pem = TransportKeyProvider.get_private_key_pem(kid) + private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None) + encrypted_key = _urlsafe_b64decode(str(envelope['ek'])) + return private_key.decrypt( + encrypted_key, + padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + + @classmethod + def encrypt_response_body( + cls, + aes_key: bytes, + payload: bytes, + kid: str, + method: str, + path: str, + ) -> bytes: + """ + 使用请求协商出的AES密钥加密响应体 + + :param aes_key: 请求协商出的AES会话密钥 + :param payload: 需要加密的响应体字节串 + :param kid: 当前使用的密钥版本标识 + :param method: 当前HTTP请求方法 + :param path: 当前HTTP请求路径 + :return: 加密后的响应体字节串 + """ + iv = os.urandom(12) + aad = {'method': method.upper(), 'path': path, 'direction': 'response'} + ciphertext = AESGCM(aes_key).encrypt(iv, payload, cls._build_aad_bytes(aad)) + encrypted_payload = { + 'v': cls._ENVELOPE_VERSION, + 'kid': kid, + 'alg': cls._RESPONSE_ENVELOPE_ALGORITHM, + 'aad': aad, + 'iv': _urlsafe_b64encode(iv), + 'ct': _urlsafe_b64encode(ciphertext), + } + return json.dumps(encrypted_payload, ensure_ascii=False).encode('utf-8') + + @classmethod + def decode_query_envelope(cls, encrypted_query: str) -> dict[str, Any]: + """ + 解码查询参数中的加密信封 + + :param encrypted_query: 查询参数中的加密信封字符串 + :return: 解码后的信封字典 + """ + decoded_query = _urlsafe_b64decode(encrypted_query).decode('utf-8') + return json.loads(decoded_query) + + @classmethod + def build_public_key_payload(cls) -> dict[str, Any]: + """ + 构建公钥下发载荷 + + :return: 公钥下发载荷字典 + """ + return { + 'kid': TransportKeyProvider.get_current_kid(), + 'envelopeVersion': cls._ENVELOPE_VERSION, + 'alg': TransportCryptoConfig.transport_crypto_algorithm, + 'publicKey': TransportKeyProvider.get_public_key_pem(), + 'supportedKids': TransportKeyProvider.get_supported_kids(), + 'expireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_public_key_ttl_seconds, + } + + @classmethod + def build_frontend_config_payload(cls) -> dict[str, Any]: + """ + 构建前端传输层加解密运行配置载荷 + + :return: 前端传输层加解密运行配置载荷字典 + """ + transport_crypto_active = ( + TransportCryptoConfig.transport_crypto_enabled and TransportCryptoConfig.transport_crypto_mode != 'off' + ) + return { + 'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled, + 'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode, + 'transportCryptoActive': transport_crypto_active, + 'envelopeVersion': cls._ENVELOPE_VERSION, + 'publicKeyUrl': '/transport/crypto/public-key', + 'requestEnvelopeAlgorithm': TransportCryptoConfig.transport_crypto_algorithm, + 'responseEnvelopeAlgorithm': cls.get_response_envelope_algorithm(), + 'enabledPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths), + 'requiredPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_required_paths), + 'excludePaths': cls._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths), + 'maxEncryptedGetUrlLength': TransportCryptoConfig.transport_crypto_max_get_url_length, + 'configExpireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_frontend_config_ttl_seconds, + } + + @classmethod + def _validate_envelope(cls, envelope: dict[str, Any]) -> None: + """ + 校验请求加密信封的结构、协议版本与算法是否有效 + + :param envelope: 请求加密信封 + :return: None + """ + if not isinstance(envelope, dict): + raise ValueError('加密请求信封格式不合法') + + missing_fields = [field_name for field_name in cls._REQUIRED_ENVELOPE_FIELDS if not envelope.get(field_name)] + if missing_fields: + raise ValueError(f'加密请求缺少必要字段: {",".join(missing_fields)}') + + if str(envelope.get('v', '')) != cls._ENVELOPE_VERSION: + raise ValueError('加密请求协议版本不受支持') + + if str(envelope.get('alg', '')) != TransportCryptoConfig.transport_crypto_algorithm: + raise ValueError('加密请求算法不受支持') + + @classmethod + def _extract_and_validate_aad( + cls, + envelope: dict[str, Any], + expected_method: str, + expected_path: str, + ) -> dict[str, str]: + """ + 提取并校验请求AAD,确保密文与当前接口绑定 + + :param envelope: 请求加密信封 + :param expected_method: 当前请求预期HTTP方法 + :param expected_path: 当前请求预期路径 + :return: 归一化后的AAD字典 + """ + aad = envelope.get('aad') + if not isinstance(aad, dict): + raise ValueError('加密请求缺少合法的aad') + + method = str(aad.get('method', '')).upper() + path = str(aad.get('path', '')) + if method != expected_method.upper() or path != expected_path: + raise ValueError('加密请求的method/path与当前接口不匹配') + + return {'method': method, 'path': path} + + @staticmethod + def _build_aad_bytes(aad: dict[str, str]) -> bytes: + """ + 将AAD字典序列化为AES-GCM additionalData所需字节串 + + :param aad: AAD字典 + :return: 序列化后的AAD字节串 + """ + return json.dumps(aad, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + + @staticmethod + def _split_paths(path_value: str) -> list[str]: + """ + 将逗号分隔的路径配置拆分为列表 + + :param path_value: 原始路径配置 + :return: 路径列表 + """ + return [path.strip() for path in path_value.split(',') if path.strip()] + + +# 传输层监控读写与聚合 +class TransportCryptoMonitorUtil: + """ + 传输层加解密监控工具 + """ + + _REDIS_KEY_PREFIX = 'transport:monitor' + _META_STARTED_AT_KEY = f'{_REDIS_KEY_PREFIX}:started_at' + _COUNTERS_KEY = f'{_REDIS_KEY_PREFIX}:counters' + _FAILURE_REASONS_KEY = f'{_REDIS_KEY_PREFIX}:failure_reasons' + _KIDS_KEY = f'{_REDIS_KEY_PREFIX}:kids' + _RECENT_FAILURES_KEY = f'{_REDIS_KEY_PREFIX}:recent_failures' + _RECENT_FAILURE_LIMIT = 20 + _REDIS_WARNING_INTERVAL_SECONDS = 60 + _lock = Lock() + _started_at = datetime.now() + _counters: Counter[str] = Counter() + _failure_reasons: Counter[str] = Counter() + _kid_counters: defaultdict[str, Counter[str]] = defaultdict(Counter) + _recent_failures: deque[dict[str, Any]] = deque(maxlen=_RECENT_FAILURE_LIMIT) + _last_redis_warning_at = 0.0 + + # 对外暴露的监控记录与查询入口 + @classmethod + async def record_plain_request(cls, app: FastAPI | None = None) -> None: + """ + 记录明文请求 + + :param app: FastAPI应用对象 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={ + 'requests_total': 1, + 'plain_requests_total': 1, + }, + ): + return + cls._record_plain_request_local() + + @classmethod + async def record_encrypted_request(cls, app: FastAPI | None = None, kid: str | None = None) -> None: + """ + 记录加密请求 + + :param app: FastAPI应用对象 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={ + 'requests_total': 1, + 'encrypted_requests_total': 1, + }, + kid=kid, + kid_counter_updates={'encrypted_requests_total': 1}, + ): + return + cls._record_encrypted_request_local(kid) + + @classmethod + async def record_required_rejected(cls, app: FastAPI | None = None, method: str = '', path: str = '') -> None: + """ + 记录强制加密接口被明文访问的拒绝事件 + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :return: None + """ + if await cls._write_redis_failure( + app, + method=method, + path=path, + reason='required_missing', + include_decrypt_failure=False, + ): + return + cls._record_failure_local(method, path, 'required_missing', include_decrypt_failure=False) + + @classmethod + async def record_decrypt_success(cls, app: FastAPI | None = None, kid: str | None = None) -> None: + """ + 记录请求解密成功事件 + + :param app: FastAPI应用对象 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_counters( + app, + counter_updates={'decrypt_success_total': 1}, + kid=kid, + kid_counter_updates={'decrypt_success_total': 1}, + ): + return + cls._record_decrypt_success_local(kid) + + @classmethod + async def record_decrypt_failure( + cls, + app: FastAPI | None = None, + method: str = '', + path: str = '', + reason: str = '', + kid: str | None = None, + ) -> None: + """ + 记录请求解密失败事件 + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :return: None + """ + if await cls._write_redis_failure(app, method=method, path=path, reason=reason, kid=kid): + return + cls._record_failure_local(method, path, reason, kid=kid) + + @classmethod + async def record_plain_response(cls, app: FastAPI | None = None) -> None: + """ + 记录明文响应 + + :param app: FastAPI应用对象 + :return: None + """ + if await cls._write_redis_counters(app, counter_updates={'plain_responses_total': 1}): + return + cls._record_plain_response_local() + + @classmethod + async def record_encrypted_response( + cls, + app: FastAPI | None = None, + kid: str | None = None, + is_error: bool = False, + ) -> None: + """ + 记录加密响应 + + :param app: FastAPI应用对象 + :param kid: 当前响应使用的密钥版本 + :param is_error: 是否为错误响应 + :return: None + """ + counter_updates = {'encrypted_responses_total': 1} + if is_error: + counter_updates['encrypted_error_responses_total'] = 1 + if await cls._write_redis_counters( + app, + counter_updates=counter_updates, + kid=kid, + kid_counter_updates={'encrypted_responses_total': 1}, + ): + return + cls._record_encrypted_response_local(kid, is_error) + + @classmethod + async def get_snapshot(cls, app: FastAPI | None = None) -> dict[str, Any]: + """ + 获取传输层加解密监控快照 + + :param app: FastAPI应用对象 + :return: 监控快照字典 + """ + redis_snapshot = await cls._get_redis_snapshot(app) + local_snapshot = cls._get_local_snapshot_parts() + snapshot_parts = cls._merge_snapshot_parts(redis_snapshot, local_snapshot) + return cls._build_snapshot(snapshot_parts) + + # Redis 聚合写入与读取 + @classmethod + async def _write_redis_counters( + cls, + app: FastAPI | None, + counter_updates: dict[str, int], + kid: str | None = None, + kid_counter_updates: dict[str, int] | None = None, + ) -> bool: + """ + 将监控计数写入Redis + + :param app: FastAPI应用对象 + :param counter_updates: 全局计数增量 + :param kid: 当前密钥版本 + :param kid_counter_updates: 按密钥版本统计的增量 + :return: 是否写入成功 + """ + redis = cls._get_redis_client(app) + if redis is None: + return False + try: + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + for counter_name, delta in counter_updates.items(): + pipe.hincrby(cls._COUNTERS_KEY, counter_name, delta) + if kid and kid_counter_updates: + pipe.sadd(cls._KIDS_KEY, kid) + kid_counter_key = cls._build_kid_counter_key(kid) + for counter_name, delta in kid_counter_updates.items(): + pipe.hincrby(kid_counter_key, counter_name, delta) + await pipe.execute() + return True + except Exception as exc: + cls._log_redis_warning('write_counters', exc) + return False + + @classmethod + async def _write_redis_failure( + cls, + app: FastAPI | None, + method: str, + path: str, + reason: str, + kid: str | None = None, + include_decrypt_failure: bool = True, + ) -> bool: + """ + 将失败事件写入Redis + + :param app: FastAPI应用对象 + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :param include_decrypt_failure: 是否计入解密失败次数 + :return: 是否写入成功 + """ + redis = cls._get_redis_client(app) + if redis is None: + return False + try: + recent_failure = json.dumps( + { + 'time': datetime.now().isoformat(), + 'method': method, + 'path': path, + 'reason': reason, + 'kid': kid, + }, + ensure_ascii=False, + ) + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + if include_decrypt_failure: + pipe.hincrby(cls._COUNTERS_KEY, 'decrypt_failure_total', 1) + if reason == 'required_missing': + pipe.hincrby(cls._COUNTERS_KEY, 'required_rejected_total', 1) + pipe.hincrby(cls._FAILURE_REASONS_KEY, reason, 1) + pipe.lpush(cls._RECENT_FAILURES_KEY, recent_failure) + pipe.ltrim(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1) + if kid: + pipe.sadd(cls._KIDS_KEY, kid) + pipe.hincrby(cls._build_kid_counter_key(kid), 'decrypt_failure_total', 1) + await pipe.execute() + return True + except Exception as exc: + cls._log_redis_warning('write_failure', exc) + return False + + @classmethod + async def _get_redis_snapshot(cls, app: FastAPI | None) -> dict[str, Any]: + """ + 从Redis中读取监控快照 + + :param app: FastAPI应用对象 + :return: Redis监控快照字典 + """ + redis = cls._get_redis_client(app) + if redis is None: + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': {}, + 'failure_reasons': {}, + 'kid_stats': [], + 'recent_failures': [], + } + try: + async with redis.pipeline(transaction=False) as pipe: + pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True) + pipe.get(cls._META_STARTED_AT_KEY) + pipe.hgetall(cls._COUNTERS_KEY) + pipe.hgetall(cls._FAILURE_REASONS_KEY) + pipe.lrange(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1) + pipe.smembers(cls._KIDS_KEY) + _, started_at_raw, counters_raw, failure_reasons_raw, recent_failures_raw, kids = await pipe.execute() + kid_stats = await cls._get_redis_kid_stats(redis, sorted(kids)) + return { + 'monitor_scope': 'redis-aggregated', + 'started_at': cls._parse_datetime(started_at_raw) or cls._started_at, + 'counters': cls._to_int_mapping(counters_raw), + 'failure_reasons': cls._to_int_mapping(failure_reasons_raw), + 'kid_stats': kid_stats, + 'recent_failures': cls._parse_recent_failures(recent_failures_raw), + } + except Exception as exc: + cls._log_redis_warning('read_snapshot', exc) + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': {}, + 'failure_reasons': {}, + 'kid_stats': [], + 'recent_failures': [], + } + + @classmethod + async def _get_redis_kid_stats(cls, redis: aioredis.Redis, kids: list[str]) -> list[dict[str, Any]]: + """ + 获取Redis中的按密钥版本聚合统计 + + :param redis: Redis客户端 + :param kids: 密钥版本列表 + :return: 按密钥版本统计列表 + """ + if not kids: + return [] + async with redis.pipeline(transaction=False) as pipe: + for kid in kids: + pipe.hgetall(cls._build_kid_counter_key(kid)) + kid_counter_rows = await pipe.execute() + return [ + { + 'kid': kid, + 'encryptedRequests': cls._to_int_mapping(kid_counter).get('encrypted_requests_total', 0), + 'decryptSuccess': cls._to_int_mapping(kid_counter).get('decrypt_success_total', 0), + 'decryptFailure': cls._to_int_mapping(kid_counter).get('decrypt_failure_total', 0), + 'encryptedResponses': cls._to_int_mapping(kid_counter).get('encrypted_responses_total', 0), + } + for kid, kid_counter in zip(kids, kid_counter_rows, strict=False) + ] + + # 进程内回退统计 + @classmethod + def _record_plain_request_local(cls) -> None: + """ + 在本地内存中记录明文请求 + + :return: None + """ + with cls._lock: + cls._counters['requests_total'] += 1 + cls._counters['plain_requests_total'] += 1 + + @classmethod + def _record_encrypted_request_local(cls, kid: str | None = None) -> None: + """ + 在本地内存中记录加密请求 + + :param kid: 当前请求使用的密钥版本 + :return: None + """ + with cls._lock: + cls._counters['requests_total'] += 1 + cls._counters['encrypted_requests_total'] += 1 + cls._increase_kid_counter_local(kid, 'encrypted_requests_total') + + @classmethod + def _record_decrypt_success_local(cls, kid: str | None = None) -> None: + """ + 在本地内存中记录解密成功事件 + + :param kid: 当前请求使用的密钥版本 + :return: None + """ + with cls._lock: + cls._counters['decrypt_success_total'] += 1 + cls._increase_kid_counter_local(kid, 'decrypt_success_total') + + @classmethod + def _record_plain_response_local(cls) -> None: + """ + 在本地内存中记录明文响应 + + :return: None + """ + with cls._lock: + cls._counters['plain_responses_total'] += 1 + + @classmethod + def _record_encrypted_response_local(cls, kid: str | None = None, is_error: bool = False) -> None: + """ + 在本地内存中记录加密响应 + + :param kid: 当前响应使用的密钥版本 + :param is_error: 是否为错误响应 + :return: None + """ + with cls._lock: + cls._counters['encrypted_responses_total'] += 1 + if is_error: + cls._counters['encrypted_error_responses_total'] += 1 + cls._increase_kid_counter_local(kid, 'encrypted_responses_total') + + @classmethod + def _record_failure_local( + cls, + method: str, + path: str, + reason: str, + kid: str | None = None, + include_decrypt_failure: bool = True, + ) -> None: + """ + 在本地内存中记录失败事件 + + :param method: 请求方法 + :param path: 请求路径 + :param reason: 失败原因分类 + :param kid: 当前请求使用的密钥版本 + :param include_decrypt_failure: 是否计入解密失败次数 + :return: None + """ + with cls._lock: + if include_decrypt_failure: + cls._counters['decrypt_failure_total'] += 1 + if reason == 'required_missing': + cls._counters['required_rejected_total'] += 1 + cls._failure_reasons[reason] += 1 + cls._increase_kid_counter_local(kid, 'decrypt_failure_total') + cls._recent_failures.appendleft( + { + 'time': datetime.now(), + 'method': method, + 'path': path, + 'reason': reason, + 'kid': kid, + } + ) + + @classmethod + def _get_local_snapshot_parts(cls) -> dict[str, Any]: + """ + 获取本地内存中的监控快照片段 + + :return: 本地监控快照片段 + """ + with cls._lock: + return { + 'monitor_scope': 'process-local-fallback', + 'started_at': cls._started_at, + 'counters': dict(cls._counters), + 'failure_reasons': dict(cls._failure_reasons), + 'kid_stats': [ + { + 'kid': kid, + 'encryptedRequests': kid_counter.get('encrypted_requests_total', 0), + 'decryptSuccess': kid_counter.get('decrypt_success_total', 0), + 'decryptFailure': kid_counter.get('decrypt_failure_total', 0), + 'encryptedResponses': kid_counter.get('encrypted_responses_total', 0), + } + for kid, kid_counter in sorted(cls._kid_counters.items(), key=lambda item: item[0]) + ], + 'recent_failures': list(cls._recent_failures), + } + + @classmethod + def _merge_snapshot_parts(cls, redis_snapshot: dict[str, Any], local_snapshot: dict[str, Any]) -> dict[str, Any]: + """ + 合并Redis统计与本地回退统计 + + :param redis_snapshot: Redis监控快照片段 + :param local_snapshot: 本地监控快照片段 + :return: 合并后的监控快照片段 + """ + merged_counters = Counter(redis_snapshot['counters']) + merged_counters.update(local_snapshot['counters']) + + merged_failure_reasons = Counter(redis_snapshot['failure_reasons']) + merged_failure_reasons.update(local_snapshot['failure_reasons']) + + merged_kid_stats: dict[str, dict[str, Any]] = {} + for kid_stat in redis_snapshot['kid_stats'] + local_snapshot['kid_stats']: + kid = kid_stat.get('kid') + if not kid: + continue + merged_kid_stat = merged_kid_stats.setdefault( + kid, + { + 'kid': kid, + 'encryptedRequests': 0, + 'decryptSuccess': 0, + 'decryptFailure': 0, + 'encryptedResponses': 0, + }, + ) + merged_kid_stat['encryptedRequests'] += int(kid_stat.get('encryptedRequests', 0) or 0) + merged_kid_stat['decryptSuccess'] += int(kid_stat.get('decryptSuccess', 0) or 0) + merged_kid_stat['decryptFailure'] += int(kid_stat.get('decryptFailure', 0) or 0) + merged_kid_stat['encryptedResponses'] += int(kid_stat.get('encryptedResponses', 0) or 0) + + combined_failures = redis_snapshot['recent_failures'] + local_snapshot['recent_failures'] + combined_failures.sort( + key=lambda item: cls._coerce_datetime_for_sort(item.get('time')), + reverse=True, + ) + + monitor_scope = redis_snapshot['monitor_scope'] + if monitor_scope == 'redis-aggregated' and cls._has_local_fallback_data(local_snapshot): + monitor_scope = 'redis-aggregated+local-fallback' + + return { + 'monitor_scope': monitor_scope, + 'started_at': min(redis_snapshot['started_at'], local_snapshot['started_at']), + 'counters': dict(merged_counters), + 'failure_reasons': dict(merged_failure_reasons), + 'kid_stats': sorted(merged_kid_stats.values(), key=lambda item: item['kid']), + 'recent_failures': combined_failures[: cls._RECENT_FAILURE_LIMIT], + } + + # 快照构建与通用辅助 + @classmethod + def _build_snapshot(cls, snapshot_parts: dict[str, Any]) -> dict[str, Any]: + """ + 基于监控片段构建最终快照 + + :param snapshot_parts: 监控快照片段 + :return: 最终监控快照 + """ + try: + current_kid = TransportKeyProvider.get_current_kid() + supported_kids = TransportKeyProvider.get_supported_kids() + except Exception: + current_kid = '' + supported_kids = [] + + counters = snapshot_parts['counters'] + return { + 'monitorScope': snapshot_parts['monitor_scope'], + 'startedAt': snapshot_parts['started_at'], + 'appEnv': AppConfig.app_env, + 'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled, + 'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode, + 'currentKid': current_kid, + 'supportedKids': supported_kids, + 'enabledPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths), + 'requiredPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_required_paths), + 'excludePaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths), + 'requestsTotal': counters.get('requests_total', 0), + 'plainRequestsTotal': counters.get('plain_requests_total', 0), + 'encryptedRequestsTotal': counters.get('encrypted_requests_total', 0), + 'requiredRejectedTotal': counters.get('required_rejected_total', 0), + 'decryptSuccessTotal': counters.get('decrypt_success_total', 0), + 'decryptFailureTotal': counters.get('decrypt_failure_total', 0), + 'plainResponsesTotal': counters.get('plain_responses_total', 0), + 'encryptedResponsesTotal': counters.get('encrypted_responses_total', 0), + 'encryptedErrorResponsesTotal': counters.get('encrypted_error_responses_total', 0), + 'failureReasons': snapshot_parts['failure_reasons'], + 'kidStats': snapshot_parts['kid_stats'], + 'recentFailures': snapshot_parts['recent_failures'], + } + + @classmethod + def _get_redis_client(cls, app: FastAPI | None) -> aioredis.Redis | None: + """ + 获取当前应用中的Redis客户端 + + :param app: FastAPI应用对象 + :return: Redis客户端,不存在时返回None + """ + if app is None: + return None + return getattr(app.state, 'redis', None) + + @classmethod + def _increase_kid_counter_local(cls, kid: str | None, counter_name: str) -> None: + """ + 在本地内存中按密钥版本累加统计值 + + :param kid: 当前密钥版本 + :param counter_name: 统计项名称 + :return: None + """ + if not kid: + return + cls._kid_counters[kid][counter_name] += 1 + + @classmethod + def _log_redis_warning(cls, action: str, exc: Exception) -> None: + """ + 记录Redis监控降级日志,并限制日志频率 + + :param action: 当前执行动作 + :param exc: 异常对象 + :return: None + """ + now = time.monotonic() + with cls._lock: + if now - cls._last_redis_warning_at < cls._REDIS_WARNING_INTERVAL_SECONDS: + return + cls._last_redis_warning_at = now + logger.warning('传输层加解密监控Redis操作失败,已回退为进程内统计,action={}, error={}', action, exc) + + @classmethod + def _has_local_fallback_data(cls, local_snapshot: dict[str, Any]) -> bool: + """ + 判断本地回退统计中是否存在有效数据 + + :param local_snapshot: 本地监控快照片段 + :return: 是否存在有效数据 + """ + if local_snapshot['counters']: + return True + if local_snapshot['failure_reasons']: + return True + if local_snapshot['kid_stats']: + return True + return bool(local_snapshot['recent_failures']) + + @classmethod + def _build_kid_counter_key(cls, kid: str) -> str: + """ + 构建按密钥版本统计的Redis键名 + + :param kid: 密钥版本 + :return: Redis键名 + """ + return f'{cls._REDIS_KEY_PREFIX}:kid:{kid}:counters' + + @classmethod + def _parse_recent_failures(cls, recent_failures: list[str]) -> list[dict[str, Any]]: + """ + 解析Redis中的最近失败记录 + + :param recent_failures: Redis中存储的失败记录列表 + :return: 失败记录对象列表 + """ + parsed_failures: list[dict[str, Any]] = [] + for recent_failure in recent_failures: + try: + recent_failure_item = json.loads(recent_failure) + except json.JSONDecodeError: + continue + if not isinstance(recent_failure_item, dict): + continue + recent_failure_item['time'] = cls._parse_datetime(recent_failure_item.get('time')) + parsed_failures.append(recent_failure_item) + return parsed_failures + + @staticmethod + def _to_int_mapping(mapping: dict[str, Any]) -> dict[str, int]: + """ + 将Redis返回的字符串字典转换为整数字典 + + :param mapping: Redis原始字典 + :return: 转换后的整数字典 + """ + return {str(key): int(value) for key, value in mapping.items()} + + @staticmethod + def _parse_datetime(value: Any) -> datetime | None: + """ + 将字符串时间解析为datetime对象 + + :param value: 原始时间值 + :return: datetime对象,解析失败时返回None + """ + if isinstance(value, datetime): + return value + if not value or not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + @classmethod + def _coerce_datetime_for_sort(cls, value: Any) -> datetime: + """ + 将任意时间值转换为可排序的datetime对象 + + :param value: 原始时间值 + :return: datetime对象 + """ + parsed_datetime = cls._parse_datetime(value) + if parsed_datetime: + return parsed_datetime + return datetime.min diff --git a/ruoyi-fastapi-frontend/src/api/monitor/transportCrypto.js b/ruoyi-fastapi-frontend/src/api/monitor/transportCrypto.js new file mode 100644 index 0000000..6636bc0 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/api/monitor/transportCrypto.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +// 获取传输加密监控信息 +export function getTransportCryptoMonitor() { + return request({ + url: '/transport/crypto/monitor', + method: 'get' + }) +} diff --git a/ruoyi-fastapi-frontend/src/utils/request.js b/ruoyi-fastapi-frontend/src/utils/request.js index a9ec88f..52b29c8 100644 --- a/ruoyi-fastapi-frontend/src/utils/request.js +++ b/ruoyi-fastapi-frontend/src/utils/request.js @@ -6,6 +6,14 @@ import errorCode from '@/utils/errorCode' import { tansParams, blobValidate } from "@/utils/ruoyi"; import cache from '@/plugins/cache' import { saveAs } from 'file-saver' +import { + decryptTransportErrorResponse, + decryptTransportResponse, + encryptTransportRequest, + invalidateTransportKeyMeta, + resetTransportRequestConfig, + shouldRetryTransportWithFreshKey +} from '@/utils/transportCrypto' let downloadLoadingInstance; // 是否显示重新登录 @@ -20,8 +28,14 @@ const service = axios.create({ timeout: 10000 }) +/** + * 统一处理请求发送前的公共逻辑。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 最终发送的请求配置 + */ // request拦截器 -service.interceptors.request.use(config => { +service.interceptors.request.use(async config => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false // 是否需要防止数据重复提交 @@ -31,13 +45,6 @@ service.interceptors.request.use(config => { if (getToken() && !isToken) { config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改 } - // get请求映射params参数 - if (config.method === 'get' && config.params) { - let url = config.url + '?' + tansParams(config.params); - url = url.slice(0, -1); - config.params = {}; - config.url = url; - } if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) { const requestObj = { url: config.url, @@ -66,14 +73,31 @@ service.interceptors.request.use(config => { } } } + // 在参数拼接前完成传输层加密,避免明文查询串提前写入 URL。 + config = await encryptTransportRequest(config) + // get请求映射params参数 + if (config.method === 'get' && config.params) { + let url = config.url + '?' + tansParams(config.params); + url = url.slice(0, -1); + config.params = {}; + config.url = url; + } return config }, error => { console.log(error) - Promise.reject(error) + return Promise.reject(error) }) +/** + * 统一处理响应成功场景下的解密与业务状态码判断。 + * + * @param {Object} res Axios 响应对象 + * @returns {Promise} 业务响应数据 + */ // 响应拦截器 -service.interceptors.response.use(res => { +service.interceptors.response.use(async res => { + // 响应若命中了传输层加密,这里先还原为原始业务 JSON。 + res = await decryptTransportResponse(res) // 未设置状态码则默认成功状态 const code = res.data.code || 200; // 获取错误信息 @@ -108,7 +132,18 @@ service.interceptors.response.use(res => { return res.data } }, - error => { + async error => { + // 错误响应也可能是加密信封,先尝试解密再进入统一错误提示流程。 + error = await decryptTransportErrorResponse(error) + // 若后端提示密钥失效,则清空本地公钥缓存并基于原始请求重试一次。 + if (shouldRetryTransportWithFreshKey(error) && error.config && !error.config.__transportRetried) { + invalidateTransportKeyMeta() + error.config.__transportRetried = true + error.config.headers = error.config.headers || {} + error.config.headers.repeatSubmit = false + resetTransportRequestConfig(error.config) + return service.request(error.config) + } console.log('err' + error) const response = error.response const responseStatus = response?.status @@ -132,12 +167,21 @@ service.interceptors.response.use(res => { } ) +/** + * 通用文件下载方法。 + * + * @param {string} url 下载接口地址 + * @param {*} params 请求参数 + * @param {string} filename 下载文件名 + * @param {Object} config 额外请求配置 + * @returns {Promise} + */ // 通用下载方法 export function download(url, params, filename, config) { downloadLoadingInstance = Loading.service({ text: "正在下载数据,请稍候", spinner: "el-icon-loading", background: "rgba(0, 0, 0, 0.7)", }) return service.post(url, params, { transformRequest: [(params) => { return tansParams(params) }], - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', encrypt: false, encryptResponse: false }, responseType: 'blob', ...config }).then(async (data) => { diff --git a/ruoyi-fastapi-frontend/src/utils/transportCrypto.js b/ruoyi-fastapi-frontend/src/utils/transportCrypto.js new file mode 100644 index 0000000..3fe8e85 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/utils/transportCrypto.js @@ -0,0 +1,748 @@ +import axios from 'axios' + +import { + ensureTransportCryptoPolicyLoaded, + getTransportCryptoPolicy, + shouldEncryptQuery, + shouldEncryptRequest, + shouldEncryptResponse +} from '@/utils/transportCryptoPolicy' +import cache from '@/plugins/cache' + +const TRANSPORT_BASE_URL = process.env.VUE_APP_BASE_API || '' +const TRANSPORT_ENABLE_HEADER = 'X-Transport-Encrypt' +const TRANSPORT_KEY_ID_HEADER = 'X-Key-Id' +const ENCRYPTED_RESPONSE_HEADER = 'x-body-encrypted' +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = '1' + +const transportClient = axios.create({ + baseURL: TRANSPORT_BASE_URL, + timeout: 10000 +}) + +let cachedKeyMeta = null +let inflightKeyMetaPromise = null +const KEY_REFRESH_BUFFER_MIN_SECONDS = 30 +const KEY_REFRESH_BUFFER_MAX_SECONDS = 300 +const TRANSPORT_KEY_META_CACHE_KEY = 'transportCryptoKeyMeta' +const TRANSPORT_RETRYABLE_ERROR_MESSAGES = new Set(['Decryption failed', '密钥版本不存在']) + +/** + * 获取当前浏览器的 Web Crypto 实例。 + * + * @returns {Crypto} 浏览器加密能力对象 + */ +function getBrowserCrypto() { + const browserCrypto = globalThis.crypto + if (!browserCrypto?.subtle) { + throw new Error('当前浏览器不支持 Web Crypto API') + } + return browserCrypto +} + +/** + * 从请求头对象中读取指定字段。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined + } + if (typeof headers.get === 'function') { + return headers.get(name) + } + return headers[name] ?? headers[name.toLowerCase()] +} + +/** + * 为请求头对象设置指定字段。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @param {*} value 请求头值 + * @returns {void} + */ +function setHeaderValue(headers, name, value) { + if (!headers) { + return + } + if (typeof headers.set === 'function') { + headers.set(name, value) + return + } + headers[name] = value +} + +/** + * 将字节数组编码为 Base64URL 文本。 + * + * @param {Uint8Array} bytes 待编码字节数组 + * @returns {string} Base64URL 文本 + */ +function toBase64Url(bytes) { + let binary = '' + bytes.forEach(byte => { + binary += String.fromCharCode(byte) + }) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') +} + +/** + * 将 Base64URL 文本还原为字节数组。 + * + * @param {string} text Base64URL 文本 + * @returns {Uint8Array} 解码后的字节数组 + */ +function fromBase64Url(text) { + const normalizedText = text.replace(/-/g, '+').replace(/_/g, '/') + const paddingLength = (4 - (normalizedText.length % 4 || 4)) % 4 + const binary = atob(normalizedText + '='.repeat(paddingLength)) + return Uint8Array.from(binary, char => char.charCodeAt(0)) +} + +/** + * 将 PEM 公钥转换为 Web Crypto 可导入的 ArrayBuffer。 + * + * @param {string} pem PEM 格式公钥 + * @returns {ArrayBuffer} DER 二进制内容 + */ +function pemToArrayBuffer(pem) { + const normalizedPem = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\s+/g, '') + const binary = atob(normalizedPem) + return Uint8Array.from(binary, char => char.charCodeAt(0)).buffer +} + +/** + * 对查询参数信封进行 JSON 编码后再转为 Base64URL。 + * + * @param {Object} envelope 查询参数信封 + * @returns {string} 编码后的查询字符串片段 + */ +function encodeQueryEnvelope(envelope) { + const jsonText = JSON.stringify(envelope) + return toBase64Url(new TextEncoder().encode(jsonText)) +} + +/** + * 计算加密查询参数最终生成的 URL 长度。 + * + * @param {string} url 请求地址 + * @param {Object} params 查询参数 + * @returns {number} URL 长度 + */ +function buildQueryUrlLength(url = '', params = {}) { + const queryText = new URLSearchParams(params).toString() + if (!queryText) { + return String(url || '').length + } + const normalizedUrl = String(url || '') + const separator = normalizedUrl.includes('?') ? '&' : '?' + return `${normalizedUrl}${separator}${queryText}`.length +} + +/** + * 获取基础 API 地址对应的路径前缀。 + * + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath() { + if (!TRANSPORT_BASE_URL) { + return '' + } + if (TRANSPORT_BASE_URL.startsWith('http://') || TRANSPORT_BASE_URL.startsWith('https://')) { + const baseApiPath = new URL(TRANSPORT_BASE_URL).pathname + return baseApiPath === '/' ? '' : baseApiPath + } + return TRANSPORT_BASE_URL +} + +/** + * 计算参与 AAD 校验的标准请求路径。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = '') { + const baseApiPath = getBaseApiPath() + const normalizedUrl = String(url || '') + + let pathname = normalizedUrl + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { + pathname = new URL(normalizedUrl).pathname + } else { + pathname = normalizedUrl.split('?')[0] || '/' + } + + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length) + return normalizedPath || '/' + } + return pathname || '/' +} + +/** + * 构建请求方向的 AAD 元数据。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 请求 AAD + */ +function buildRequestAad(config) { + return { + method: (config.method || 'get').toUpperCase(), + path: getRequestPath(config.url) + } +} + +/** + * 构建响应方向的 AAD 元数据。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 响应 AAD + */ +function buildResponseAad(config) { + return { + method: (config?.method || 'get').toUpperCase(), + path: getRequestPath(config?.url), + direction: 'response' + } +} + +/** + * 将空值载荷规范化为可序列化对象。 + * + * @param {*} payload 原始载荷 + * @returns {*} 规范化后的载荷 + */ +function normalizePlainPayload(payload) { + if (payload === undefined || payload === null) { + return {} + } + return payload +} + +/** + * 将请求载荷序列化为 JSON 文本。 + * + * @param {*} payload 原始载荷 + * @returns {string} JSON 文本 + */ +function stringifyPayload(payload) { + const normalizedPayload = normalizePlainPayload(payload) + return JSON.stringify(normalizedPayload) +} + +/** + * 克隆请求配置中的可变字段,避免重试时互相污染。 + * + * @param {*} value 待克隆值 + * @returns {*} 克隆结果 + */ +function cloneRequestValue(value) { + if (value === undefined || value === null) { + return value + } + if (typeof globalThis.structuredClone === 'function') { + return globalThis.structuredClone(value) + } + if (typeof value === 'object') { + return JSON.parse(JSON.stringify(value)) + } + return value +} + +/** + * 将信封字段转换为适合表单提交的字符串。 + * + * @param {*} value 字段值 + * @returns {string} 序列化文本 + */ +function stringifyEnvelopeField(value) { + if (value && typeof value === 'object') { + return JSON.stringify(value) + } + return String(value) +} + +/** + * 将加密信封编码为表单字符串。 + * + * @param {Object} envelope 信封对象 + * @returns {string} 表单编码文本 + */ +function encodeFormEnvelope(envelope) { + const formData = new URLSearchParams() + Object.entries(envelope).forEach(([key, value]) => { + formData.set(key, stringifyEnvelopeField(value)) + }) + return formData.toString() +} + +/** + * 将输入解析为 JSON 对象并校验结构。 + * + * @param {*} payload 原始数据 + * @param {string} errorMessage 校验失败提示 + * @returns {Object} 解析后的对象 + */ +function parseJsonObject(payload, errorMessage) { + const parsedPayload = typeof payload === 'string' ? JSON.parse(payload) : payload + if (!parsedPayload || typeof parsedPayload !== 'object' || Array.isArray(parsedPayload)) { + throw new Error(errorMessage) + } + return parsedPayload +} + +/** + * 校验公钥接口响应壳是否有效。 + * + * @param {Object} responsePayload 公钥接口原始响应 + * @returns {void} + */ +function validateTransportPublicKeyResponse(responsePayload) { + if (responsePayload?.code !== 200 || !responsePayload?.data || typeof responsePayload.data !== 'object') { + throw new Error(responsePayload?.msg || '获取传输层公钥失败') + } +} + +/** + * 校验公钥业务载荷是否满足当前协议要求。 + * + * @param {Object} payload 公钥业务载荷 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateTransportPublicKeyPayload(payload, transportPolicy) { + if (!payload?.publicKey || !payload?.kid) { + throw new Error('获取传输层公钥失败') + } + if (String(payload.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION) !== transportPolicy.envelopeVersion) { + throw new Error('传输层公钥协议版本不受支持') + } + if (payload.alg !== transportPolicy.requestEnvelopeAlgorithm) { + throw new Error('传输层公钥算法不受支持') + } +} + +/** + * 校验响应信封与当前请求上下文是否一致。 + * + * @param {Object} envelope 响应信封 + * @param {Object} response Axios 响应对象 + * @param {Object} transportContext 请求加密上下文 + * @param {Object} transportPolicy 当前传输策略 + * @returns {void} + */ +function validateResponseEnvelope(envelope, response, transportContext, transportPolicy) { + const expectedAad = buildResponseAad(response.config) + const responseKid = getHeaderValue(response.headers, TRANSPORT_KEY_ID_HEADER) + const aad = envelope.aad + + if (String(envelope.v || '') !== transportPolicy.envelopeVersion) { + throw new Error('传输层响应协议版本不受支持') + } + if (String(envelope.alg || '') !== transportPolicy.responseEnvelopeAlgorithm) { + throw new Error('传输层响应算法不受支持') + } + if (String(envelope.kid || '') !== String(transportContext.kid)) { + throw new Error('传输层响应密钥版本不匹配') + } + if (responseKid && String(envelope.kid) !== String(responseKid)) { + throw new Error('传输层响应头与响应体密钥版本不一致') + } + if (!aad || typeof aad !== 'object' || Array.isArray(aad)) { + throw new Error('传输层响应AAD不合法') + } + if (String(aad.method || '').toUpperCase() !== expectedAad.method || String(aad.path || '') !== expectedAad.path) { + throw new Error('传输层响应的method/path与当前请求不匹配') + } + if (String(aad.direction || '') !== expectedAad.direction) { + throw new Error('传输层响应方向标识不合法') + } +} + +/** + * 记录原始请求快照,供密钥刷新重试时恢复。 + * + * @param {Object} config Axios 请求配置 + * @returns {void} + */ +function rememberOriginalRequestSnapshot(config) { + if (config.__transportOriginalSnapshot) { + return + } + config.__transportOriginalSnapshot = { + url: config.url, + params: cloneRequestValue(config.params), + data: cloneRequestValue(config.data), + contentType: getHeaderValue(config.headers, 'Content-Type') + } +} + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000) +} + +/** + * 根据公钥有效期计算本地提前刷新时间。 + * + * @param {number} expireAt 公钥失效时间 + * @param {number} fetchedAt 公钥获取时间 + * @returns {number} 建议刷新时间 + */ +function buildKeyRefreshAt(expireAt, fetchedAt = getNowTimestamp()) { + const normalizedExpireAt = Number(expireAt || 0) + const normalizedFetchedAt = Number(fetchedAt || 0) + const ttlSeconds = Math.max(normalizedExpireAt - normalizedFetchedAt, 0) + if (!normalizedExpireAt || !ttlSeconds) { + return 0 + } + const refreshBufferSeconds = Math.min( + KEY_REFRESH_BUFFER_MAX_SECONDS, + Math.max(KEY_REFRESH_BUFFER_MIN_SECONDS, Math.floor(ttlSeconds * 0.1)) + ) + return Math.max(normalizedFetchedAt, normalizedExpireAt - refreshBufferSeconds) +} + +/** + * 判断缓存中的公钥元数据是否仍可使用。 + * + * @param {Object} keyMeta 公钥元数据 + * @param {number} nowTimestamp 当前时间戳 + * @returns {boolean} 是否可继续使用 + */ +function isUsableKeyMeta(keyMeta, nowTimestamp = getNowTimestamp()) { + if (!keyMeta?.publicKeyPem || !keyMeta?.kid || !keyMeta?.expireAt) { + return false + } + const refreshAt = Number(keyMeta.refreshAt || buildKeyRefreshAt(keyMeta.expireAt, keyMeta.fetchedAt || nowTimestamp)) + return refreshAt > nowTimestamp +} + +/** + * 获取当前可用的后端公钥与密钥元信息。 + * + * @param {boolean} forceRefresh 是否强制刷新 + * @returns {Promise} 公钥元信息 + */ +async function getTransportKeyMeta(forceRefresh = false) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded() + const nowTimestamp = getNowTimestamp() + if (!forceRefresh && !cachedKeyMeta) { + const persistedKeyMeta = cache.session.getJSON(TRANSPORT_KEY_META_CACHE_KEY) + if (isUsableKeyMeta(persistedKeyMeta, nowTimestamp)) { + const browserCrypto = getBrowserCrypto() + const cryptoKey = await browserCrypto.subtle.importKey( + 'spki', + pemToArrayBuffer(persistedKeyMeta.publicKeyPem), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) + cachedKeyMeta = { + kid: persistedKeyMeta.kid, + alg: persistedKeyMeta.alg, + envelopeVersion: persistedKeyMeta.envelopeVersion || transportPolicy.envelopeVersion, + publicKey: cryptoKey, + publicKeyPem: persistedKeyMeta.publicKeyPem, + expireAt: persistedKeyMeta.expireAt, + fetchedAt: persistedKeyMeta.fetchedAt || nowTimestamp, + refreshAt: persistedKeyMeta.refreshAt || buildKeyRefreshAt(persistedKeyMeta.expireAt, persistedKeyMeta.fetchedAt || nowTimestamp) + } + } + } + if (!forceRefresh && isUsableKeyMeta(cachedKeyMeta, nowTimestamp)) { + return cachedKeyMeta + } + if (inflightKeyMetaPromise) { + return inflightKeyMetaPromise + } + inflightKeyMetaPromise = transportClient.get(transportPolicy.publicKeyUrl || '/transport/crypto/public-key').then(async response => { + const responsePayload = response.data || {} + const payload = responsePayload.data || {} + const fetchedAt = getNowTimestamp() + validateTransportPublicKeyResponse(responsePayload) + validateTransportPublicKeyPayload(payload, transportPolicy) + const browserCrypto = getBrowserCrypto() + const cryptoKey = await browserCrypto.subtle.importKey( + 'spki', + pemToArrayBuffer(payload.publicKey), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'] + ) + cachedKeyMeta = { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String(payload.envelopeVersion || transportPolicy.envelopeVersion), + publicKey: cryptoKey, + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt) + } + cache.session.setJSON(TRANSPORT_KEY_META_CACHE_KEY, { + kid: payload.kid, + alg: payload.alg, + envelopeVersion: String(payload.envelopeVersion || transportPolicy.envelopeVersion), + publicKeyPem: payload.publicKey, + expireAt: payload.expireAt, + fetchedAt, + refreshAt: buildKeyRefreshAt(payload.expireAt, fetchedAt) + }) + inflightKeyMetaPromise = null + return cachedKeyMeta + }).catch(error => { + inflightKeyMetaPromise = null + throw error + }) + return inflightKeyMetaPromise +} + +/** + * 为当前请求创建一次性的对称密钥上下文。 + * + * @returns {Promise} 请求级传输上下文 + */ +async function buildTransportContext() { + const browserCrypto = getBrowserCrypto() + const keyMeta = await getTransportKeyMeta() + const aesKey = await browserCrypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) + const rawAesKey = new Uint8Array(await browserCrypto.subtle.exportKey('raw', aesKey)) + const encryptedAesKey = new Uint8Array( + await browserCrypto.subtle.encrypt({ name: 'RSA-OAEP' }, keyMeta.publicKey, rawAesKey) + ) + return { + kid: keyMeta.kid, + alg: keyMeta.alg, + envelopeVersion: keyMeta.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + aesKey, + ek: toBase64Url(encryptedAesKey) + } +} + +/** + * 使用 AES-GCM 对明文载荷执行信封加密。 + * + * @param {Object} context 请求级传输上下文 + * @param {string} plainText 明文内容 + * @param {Object} aad AAD 元数据 + * @returns {Promise} 加密信封 + */ +async function encryptPayloadText(context, plainText, aad) { + const browserCrypto = getBrowserCrypto() + const iv = browserCrypto.getRandomValues(new Uint8Array(12)) + const ciphertext = new Uint8Array( + await browserCrypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(JSON.stringify(aad)) }, + context.aesKey, + new TextEncoder().encode(plainText) + ) + ) + return { + v: context.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION, + kid: context.kid, + alg: context.alg, + ts: Math.floor(Date.now() / 1000), + nonce: browserCrypto.randomUUID(), + ek: context.ek, + aad, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext) + } +} + +/** + * 使用请求上下文中的 AES 密钥解密响应信封。 + * + * @param {Object} envelope 响应信封 + * @param {Object} context 请求级传输上下文 + * @returns {Promise} 解密后的明文 + */ +async function decryptEnvelope(envelope, context) { + const browserCrypto = getBrowserCrypto() + const decryptedBytes = await browserCrypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromBase64Url(envelope.iv), + additionalData: new TextEncoder().encode(JSON.stringify(envelope.aad || {})) + }, + context.aesKey, + fromBase64Url(envelope.ct) + ) + return new TextDecoder().decode(decryptedBytes) +} + +/** + * 复用同一次请求内的传输上下文,避免重复生成密钥。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 请求级传输上下文 + */ +function getOrCreateTransportContext(config) { + if (config.__transportCryptoContextPromise) { + return config.__transportCryptoContextPromise + } + config.__transportCryptoContextPromise = buildTransportContext() + return config.__transportCryptoContextPromise +} + +/** + * 对 Axios 请求配置执行传输层加密封装。 + * + * @param {Object} config Axios 请求配置 + * @returns {Promise} 加密后的请求配置 + */ +export async function encryptTransportRequest(config) { + const transportPolicy = await ensureTransportCryptoPolicyLoaded() + if (!shouldEncryptRequest(config, transportPolicy)) { + config.__transportCryptoEnabledForRequest = false + return config + } + + rememberOriginalRequestSnapshot(config) + const transportContext = await getOrCreateTransportContext(config) + const contentType = (getHeaderValue(config.headers, 'Content-Type') || 'application/json').toLowerCase() + const method = (config.method || 'get').toLowerCase() + const requestAad = buildRequestAad(config) + + if (shouldEncryptQuery(config, transportPolicy) && (config.params || method === 'get' || method === 'delete')) { + const queryEnvelope = await encryptPayloadText( + transportContext, + JSON.stringify(normalizePlainPayload(config.params)), + requestAad + ) + config.params = { __enc: encodeQueryEnvelope(queryEnvelope) } + if (buildQueryUrlLength(config.url, config.params) > Number(transportPolicy.maxEncryptedGetUrlLength || 4096)) { + throw new Error('当前GET/DELETE请求参数加密后长度超限,请改用POST请求或精简查询条件') + } + } + + if (method === 'post' || method === 'put' || method === 'patch' || method === 'delete') { + const plainText = stringifyPayload(config.data) + const bodyEnvelope = await encryptPayloadText(transportContext, plainText, requestAad) + if (contentType.includes('application/x-www-form-urlencoded')) { + config.data = encodeFormEnvelope(bodyEnvelope) + } else { + config.data = bodyEnvelope + setHeaderValue(config.headers, 'Content-Type', 'application/json;charset=utf-8') + } + } + + setHeaderValue(config.headers, TRANSPORT_ENABLE_HEADER, '1') + setHeaderValue(config.headers, TRANSPORT_KEY_ID_HEADER, transportContext.kid) + config.__transportCryptoContext = transportContext + config.__transportCryptoEnabledForRequest = true + return config +} + +/** + * 清空当前缓存的公钥元数据。 + * + * @returns {void} + */ +export function invalidateTransportKeyMeta() { + cachedKeyMeta = null + inflightKeyMetaPromise = null + cache.session.remove(TRANSPORT_KEY_META_CACHE_KEY) +} + +/** + * 将被加密改写过的请求恢复为原始形态。 + * + * @param {Object} config Axios 请求配置 + * @returns {Object} 恢复后的请求配置 + */ +export function resetTransportRequestConfig(config) { + const originalSnapshot = config?.__transportOriginalSnapshot + if (!config || !originalSnapshot) { + return config + } + + config.url = originalSnapshot.url + config.params = cloneRequestValue(originalSnapshot.params) + config.data = cloneRequestValue(originalSnapshot.data) + if (originalSnapshot.contentType) { + setHeaderValue(config.headers, 'Content-Type', originalSnapshot.contentType) + } + delete config.__transportCryptoContext + delete config.__transportCryptoContextPromise + delete config.__transportCryptoEnabledForRequest + return config +} + +/** + * 判断错误是否属于可通过刷新公钥重试的场景。 + * + * @param {Object} error 错误对象 + * @returns {boolean} 是否可刷新密钥重试 + */ +export function shouldRetryTransportWithFreshKey(error) { + const responseMsg = error?.response?.data?.msg + const errorMessage = error?.message + return TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(responseMsg) || TRANSPORT_RETRYABLE_ERROR_MESSAGES.has(errorMessage) +} + +/** + * 解密成功响应中的传输层信封。 + * + * @param {Object} response Axios 响应对象 + * @returns {Promise} 解密后的响应对象 + */ +export async function decryptTransportResponse(response) { + if (getHeaderValue(response.headers, ENCRYPTED_RESPONSE_HEADER) !== '1') { + return response + } + if (!shouldEncryptResponse(response.config, getTransportCryptoPolicy())) { + return response + } + const transportPolicy = getTransportCryptoPolicy() + const transportContext = response.config.__transportCryptoContext + if (!transportContext) { + throw new Error('缺少响应解密上下文') + } + + const envelope = parseJsonObject(response.data, '传输层响应信封格式不合法') + validateResponseEnvelope(envelope, response, transportContext, transportPolicy) + const plaintext = await decryptEnvelope(envelope, transportContext) + response.data = JSON.parse(plaintext) + return response +} + +/** + * 尝试解密异常响应中的传输层信封。 + * + * @param {Object} error Axios 错误对象 + * @returns {Promise} 原始或已解密的错误对象 + */ +export async function decryptTransportErrorResponse(error) { + const response = error?.response + if (!response || getHeaderValue(response.headers, ENCRYPTED_RESPONSE_HEADER) !== '1') { + return error + } + if (!shouldEncryptResponse(response.config || {}, getTransportCryptoPolicy())) { + return error + } + const transportPolicy = getTransportCryptoPolicy() + const transportContext = response.config?.__transportCryptoContext + if (!transportContext) { + return error + } + + try { + const envelope = parseJsonObject(response.data, '传输层响应信封格式不合法') + validateResponseEnvelope(envelope, response, transportContext, transportPolicy) + const plaintext = await decryptEnvelope(envelope, transportContext) + response.data = JSON.parse(plaintext) + } catch (decryptError) { + console.error(decryptError) + } + return error +} diff --git a/ruoyi-fastapi-frontend/src/utils/transportCryptoPolicy.js b/ruoyi-fastapi-frontend/src/utils/transportCryptoPolicy.js new file mode 100644 index 0000000..a84a105 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/utils/transportCryptoPolicy.js @@ -0,0 +1,368 @@ +import axios from 'axios' + +import cache from '@/plugins/cache' + +const TRANSPORT_BASE_URL = process.env.VUE_APP_BASE_API || '' +const EXCLUDED_URL_PATTERNS = [ + '/transport/crypto/frontend-config', + '/transport/crypto/public-key', + '/common/download', + '/common/download/resource' +] +const TRANSPORT_FRONTEND_CONFIG_CACHE_KEY = 'transportCryptoFrontendConfig' +const TRANSPORT_FRONTEND_CONFIG_URL = '/transport/crypto/frontend-config' +const TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS = 60 +const DEFAULT_TRANSPORT_ENVELOPE_VERSION = '1' +const DEFAULT_REQUEST_ENVELOPE_ALGORITHM = 'RSA_OAEP_AES_256_GCM' +const DEFAULT_RESPONSE_ENVELOPE_ALGORITHM = 'AES_256_GCM' +const DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH = 4096 + +const transportPolicyClient = axios.create({ + baseURL: TRANSPORT_BASE_URL, + timeout: 10000 +}) + +let cachedTransportPolicy = null +let inflightTransportPolicyPromise = null + +/** + * 获取当前 Unix 秒级时间戳。 + * + * @returns {number} 当前时间戳 + */ +function getNowTimestamp() { + return Math.floor(Date.now() / 1000) +} + +/** + * 判断请求地址是否命中固定排除名单。 + * + * @param {string} url 请求地址 + * @returns {boolean} 是否命中排除规则 + */ +function matchExcludedUrl(url = '') { + return EXCLUDED_URL_PATTERNS.some(pattern => url.includes(pattern)) +} + +/** + * 判断路径是否匹配指定前缀集合。 + * + * @param {string} path 待匹配路径 + * @param {string[]} pathPatterns 路径前缀集合 + * @returns {boolean} 是否匹配成功 + */ +function matchPathPrefix(path = '', pathPatterns = []) { + return pathPatterns.some(pattern => path === pattern || path.startsWith(`${pattern}/`)) +} + +/** + * 读取请求头中的指定字段,兼容原生对象与 Headers 实例。 + * + * @param {Object|Headers} headers 请求头对象 + * @param {string} name 请求头名称 + * @returns {*} 请求头值 + */ +function getHeaderValue(headers, name) { + if (!headers) { + return undefined + } + if (typeof headers.get === 'function') { + return headers.get(name) + } + return headers[name] ?? headers[name.toLowerCase()] +} + +/** + * 解析基础 API 地址对应的路径前缀。 + * + * @returns {string} 基础路径前缀 + */ +function getBaseApiPath() { + if (!TRANSPORT_BASE_URL) { + return '' + } + if (TRANSPORT_BASE_URL.startsWith('http://') || TRANSPORT_BASE_URL.startsWith('https://')) { + const baseApiPath = new URL(TRANSPORT_BASE_URL).pathname + return baseApiPath === '/' ? '' : baseApiPath + } + return TRANSPORT_BASE_URL +} + +/** + * 计算用于策略匹配的标准请求路径。 + * + * @param {string} url 请求地址 + * @returns {string} 标准化请求路径 + */ +function getRequestPath(url = '') { + const baseApiPath = getBaseApiPath() + const normalizedUrl = String(url || '') + + let pathname = normalizedUrl + if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) { + pathname = new URL(normalizedUrl).pathname + } else { + pathname = normalizedUrl.split('?')[0] || '/' + } + + if (baseApiPath && pathname.startsWith(baseApiPath)) { + const normalizedPath = pathname.slice(baseApiPath.length) + return normalizedPath || '/' + } + return pathname || '/' +} + +/** + * 标准化后端下发的路径数组。 + * + * @param {Array} paths 原始路径集合 + * @returns {string[]} 标准化后的路径列表 + */ +function normalizePaths(paths) { + if (!Array.isArray(paths)) { + return [] + } + return paths.map(path => String(path || '').trim()).filter(Boolean) +} + +/** + * 将后端配置响应转换为前端统一的策略对象。 + * + * @param {Object} payload 后端返回配置 + * @returns {Object} 标准化后的策略对象 + */ +function normalizeTransportPolicy(payload) { + return { + transportCryptoEnabled: Boolean(payload?.transportCryptoEnabled), + transportCryptoMode: String(payload?.transportCryptoMode || 'off'), + transportCryptoActive: Boolean(payload?.transportCryptoActive), + envelopeVersion: String(payload?.envelopeVersion || DEFAULT_TRANSPORT_ENVELOPE_VERSION), + publicKeyUrl: String(payload?.publicKeyUrl || '/transport/crypto/public-key'), + requestEnvelopeAlgorithm: String(payload?.requestEnvelopeAlgorithm || DEFAULT_REQUEST_ENVELOPE_ALGORITHM), + responseEnvelopeAlgorithm: String(payload?.responseEnvelopeAlgorithm || DEFAULT_RESPONSE_ENVELOPE_ALGORITHM), + enabledPaths: normalizePaths(payload?.enabledPaths), + requiredPaths: normalizePaths(payload?.requiredPaths), + excludePaths: normalizePaths(payload?.excludePaths), + maxEncryptedGetUrlLength: Number(payload?.maxEncryptedGetUrlLength || DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH), + configExpireAt: Number(payload?.configExpireAt || 0), + retryAt: Number(payload?.retryAt || payload?.configExpireAt || 0) + } +} + +/** + * 构建无法获取后端配置时的本地兜底策略。 + * + * @returns {Object} 明文回退策略 + */ +function buildFallbackTransportPolicy() { + const nowTimestamp = getNowTimestamp() + return { + transportCryptoEnabled: false, + transportCryptoMode: 'off', + transportCryptoActive: false, + envelopeVersion: DEFAULT_TRANSPORT_ENVELOPE_VERSION, + publicKeyUrl: '/transport/crypto/public-key', + requestEnvelopeAlgorithm: DEFAULT_REQUEST_ENVELOPE_ALGORITHM, + responseEnvelopeAlgorithm: DEFAULT_RESPONSE_ENVELOPE_ALGORITHM, + enabledPaths: [], + requiredPaths: [], + excludePaths: [...EXCLUDED_URL_PATTERNS], + maxEncryptedGetUrlLength: DEFAULT_TRANSPORT_MAX_GET_URL_LENGTH, + configExpireAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS, + retryAt: nowTimestamp + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS + } +} + +/** + * 基于旧策略构建短期可重试策略。 + * + * @param {Object} policy 旧的策略对象 + * @returns {Object} 可重试策略 + */ +function buildRetryableTransportPolicy(policy) { + const normalizedPolicy = normalizeTransportPolicy(policy) + const retryAt = getNowTimestamp() + TRANSPORT_FRONTEND_CONFIG_FALLBACK_TTL_SECONDS + return { + ...normalizedPolicy, + retryAt + } +} + +/** + * 判断策略是否仍处于可用期内。 + * + * @param {Object} policy 待校验策略 + * @returns {boolean} 是否可用 + */ +function isUsableTransportPolicy(policy) { + if (!policy) { + return false + } + if (!policy.publicKeyUrl) { + return false + } + if (!policy.retryAt) { + return false + } + return policy.retryAt > getNowTimestamp() +} + +/** + * 从会话缓存读取最近一次持久化策略。 + * + * @returns {Object|null} 缓存策略 + */ +function loadPersistedTransportPolicy() { + const persistedTransportPolicy = cache.session.getJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY) + if (!persistedTransportPolicy) { + return null + } + return normalizeTransportPolicy(persistedTransportPolicy) +} + +/** + * 获取当前生效的传输加密策略。 + * + * @returns {Object} 当前策略对象 + */ +export function getTransportCryptoPolicy() { + return cachedTransportPolicy || buildFallbackTransportPolicy() +} + +/** + * 清空当前策略缓存与会话缓存。 + * + * @returns {void} + */ +export function invalidateTransportCryptoPolicy() { + cachedTransportPolicy = null + inflightTransportPolicyPromise = null + cache.session.remove(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY) +} + +/** + * 确保本地已加载一份可用的传输加密策略。 + * + * @param {boolean} forceRefresh 是否强制刷新策略 + * @returns {Promise} 当前可用策略 + */ +export async function ensureTransportCryptoPolicyLoaded(forceRefresh = false) { + if (!forceRefresh && !cachedTransportPolicy) { + const persistedTransportPolicy = loadPersistedTransportPolicy() + if (isUsableTransportPolicy(persistedTransportPolicy)) { + cachedTransportPolicy = persistedTransportPolicy + } + } + + if (!forceRefresh && isUsableTransportPolicy(cachedTransportPolicy)) { + return cachedTransportPolicy + } + + if (inflightTransportPolicyPromise) { + return inflightTransportPolicyPromise + } + + inflightTransportPolicyPromise = transportPolicyClient.get(TRANSPORT_FRONTEND_CONFIG_URL).then(response => { + const payload = normalizeTransportPolicy(response?.data?.data || {}) + cachedTransportPolicy = payload + cache.session.setJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, payload) + inflightTransportPolicyPromise = null + return cachedTransportPolicy + }).catch(error => { + const staleTransportPolicy = cachedTransportPolicy || loadPersistedTransportPolicy() + inflightTransportPolicyPromise = null + cachedTransportPolicy = staleTransportPolicy + ? buildRetryableTransportPolicy(staleTransportPolicy) + : buildFallbackTransportPolicy() + cache.session.setJSON(TRANSPORT_FRONTEND_CONFIG_CACHE_KEY, cachedTransportPolicy) + if (staleTransportPolicy) { + console.warn('加载传输加密前端配置失败,当前继续沿用最近一次后端策略', error) + } else { + console.warn('加载传输加密前端配置失败,当前回退为明文请求策略', error) + } + return cachedTransportPolicy + }) + + return inflightTransportPolicyPromise +} + +/** + * 判断当前请求是否需要执行请求加密。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否需要加密 + */ +export function shouldEncryptRequest(config, transportPolicy = getTransportCryptoPolicy()) { + if (!transportPolicy.transportCryptoActive) { + return false + } + const requestPath = getRequestPath(config.url) + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false + } + if ((transportPolicy.enabledPaths || []).length && !matchPathPrefix(requestPath, transportPolicy.enabledPaths || [])) { + return false + } + if ((config.headers || {}).encrypt === false) { + return false + } + if (matchExcludedUrl(config.url)) { + return false + } + if (config.responseType === 'blob' || config.responseType === 'arraybuffer') { + return false + } + const contentType = getHeaderValue(config.headers, 'Content-Type') || '' + if (contentType.includes('multipart/form-data')) { + return false + } + return true +} + +/** + * 判断当前响应是否需要执行自动解密。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否需要解密 + */ +export function shouldEncryptResponse(config, transportPolicy = getTransportCryptoPolicy()) { + const requestPath = getRequestPath(config.url) + if (matchPathPrefix(requestPath, transportPolicy.excludePaths || [])) { + return false + } + if ((transportPolicy.enabledPaths || []).length && !matchPathPrefix(requestPath, transportPolicy.enabledPaths || [])) { + return false + } + if ((config.headers || {}).encryptResponse === false) { + return false + } + if (matchExcludedUrl(config.url)) { + return false + } + if (config.responseType === 'blob' || config.responseType === 'arraybuffer') { + return false + } + if (config.__transportCryptoEnabledForRequest === true) { + return true + } + if (config.__transportCryptoEnabledForRequest === false) { + return false + } + return transportPolicy.transportCryptoActive +} + +/** + * 判断查询参数是否需要走加密信封流程。 + * + * @param {Object} config 请求配置 + * @param {Object} transportPolicy 传输加密策略 + * @returns {boolean} 是否启用查询参数加密 + */ +export function shouldEncryptQuery(config, transportPolicy = getTransportCryptoPolicy()) { + if ((config.headers || {}).encryptQuery === false) { + return false + } + return shouldEncryptRequest(config, transportPolicy) +} diff --git a/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue b/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue new file mode 100644 index 0000000..bb308c6 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/monitor/transportCrypto/index.vue @@ -0,0 +1,1073 @@ + + + + +