--- title: "00-接口参数加解密与签名验证深度解析" created: 2025-12-14 aliases: - 接口参数加解密与签名验证深度解析 tags: - 项目 --- # 接口参数加解密与签名验证深度解析 ## **引子:前后端分离的安全隐患** 在微服务架构下,前后端分离已成为标配。但这种架构也带来了一个严重的安全问题:**接口参数完全暴露在网络中**。 ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/前后端分离的安全风险-2204713c.jpg]] 对于大麦网这类涉及**高并发抢票**和**金融交易**的系统,接口“裸奔”会导致两大风险: 1. **参数篡改**:黑客抓包后修改金额、库存或用户ID,导致资产损失。 2. **数据泄露**:用户隐私(手机号、身份证)在传输过程中被中间人截获。 3. **恶意刷单**:黄牛利用脚本模拟接口请求,绕过前端限制直接抢票。 为了解决这些问题,我们需要在网关层(Gateway)构建一套透明的、高安全性的防护体系。 ## **加密算法基础** **在设计安全方案前,我们需要了解两种核心加密方式的区别:** [[00-接口参数加解密与签名验证深度解析|接口参数加解密与签名验证深度解析]] ### **对称加密 vs 非对称加密** - **对称加密 (如 AES)**:加密和解密使用**同一个密钥**。 - *优点*:速度快,适合大量数据。 - *缺点*:密钥分发困难。如果前端(App)里硬编码了密钥,一旦 App 被反编译,密钥泄露,整个系统崩塌。 - **非对称加密 (如 RSA)**:有一对密钥(**公钥**和**私钥**)。 - *公钥*:公开给所有人,用于**加密**或**验签**。 - *私钥*:严格保密,用于**解密**或**签名**。 - *优点*:安全性极高。前端只存公钥,即使被反编译,黑客也只能加密数据,无法解密服务器返回的数据。 ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/加密算法对比-c9c2010b.jpg]] ### **选择:RSA 非对称加密** 本方案采用了 **RSA** 作为核心算法,配合不同场景使用不同的密钥对,实现了**双向验证**。 ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/参数加密方案-4cf32986.jpg]] #### **理解** > 首先v1 是使用轻量级的哈希 得到一个签名 后端可以先拿到可以签名确定是否是真正的请求 从而过滤掉无效请求 防止了重放攻击和数据篡改 接着是v2版本 更加严格 数据看都不希望让别人截取看到 所以在基础上把传输的内容体都加密了一下 但这样的成本较高 后端解密需要花一定资源 这样就更凸显了v1的重要性 如果没有事先验证是否为真正的请求就解析 会浪费大量资源 如果重放攻击ddos就会导致系统崩溃 所以需要先v2加密内容体 然后对加密后的内容生成签名 后端拿到后 先验签 确保正确后 再解析内容 但这切切实实会多花一些资源 只有在非常敏感的数据时才需要这种级别的加密 一般的情况 只要确定不被篡改、敏感数据脱敏 就够用了 ##### 问题症候——为什么只加密不够? 场景复现:假如只有加密,没有签名 你正在大麦网上抢演唱会门票,点击"购票",前端执行: ```javascript // 前端代码 const ticketData = { ticketId: 1001, price: 500, count: 1 }; // 用数据公钥加密 const encrypted = RSA_encrypt(JSON.stringify(ticketData), dataPublicKey); // 发送给后端 POST /order/create { "code": "app", "businessBody": "Xy7zAb9cDe...Fgh", // 密文(无法被他人解读) "sign": "xxxxx" // 已签名,防篡改 } ``` 虽然数据是加密的(黑客看不懂),但会发生两个致命风险: 风险一:重放攻击(Replay Attack)—— 最常见 ```text 黑客行为: 1. 截获一条购票请求的密文:Xy7zAb9cDe...Fgh 2. 黑客虽然解不开(没有私钥) 3. 但黑客可以直接复制这段密文,写个脚本在 1 秒内发送 1000 次 后果: POST /order/create { "code": "app", "businessBody": "Xy7zAb9cDe...Fgh", // 同一段密文,重复发送 "sign": "xxxxx" } 后端:解密 -> {"ticketId": 1001, "price": 500, "count": 1} -> 创建订单成功 → 你被锁了 1000 张票,或被扣了 1000 次钱! ``` 签名为什么能防这个? ```text 标准的签名结构应该包含:时间戳 (timestamp) 或 随机数 (nonce) sign = SHA256withRSA(timestamp + businessBody) ↑ 每次请求都不同 黑客重放时: 1. 复制了密文 businessBody 2. 但原签名是:sign = SHA256(2024-12-15T10:00:00 + businessBody) 3. 重放时请求已经是 2024-12-15T10:00:05 4. 如果签名没更新,后端会发现:时间戳与签名不匹配 -> 拒绝 5. 如果黑客想造个新签名,必须有 sign_secret_key(前端私钥)-> 不可能 结论:签名 = 带时间戳的防重放符 ``` 风险二:DDoS 攻击(资源耗尽) ```text 黑客行为: 虽然我不知道你的业务数据格式,但数据公钥写在前端代码里(公开的) 我可以用这个公钥加密任意垃圾数据 for i in 1..100000: POST /order/create { "code": "app", "businessBody": RSA_encrypt("asdfghjkl随机垃圾", dataPublicKey), "sign": "任意值" // 根本不验签的话,什么都可以 } 后端无防线情况: 1. 收到请求 -> 解密 businessBody(CPU 密集操作) 2. 发现是垃圾数据 -> 丢弃 3. 继续下一个请求 → 后端 CPU 被 RSA 解密操作耗满 -> 服务瘫痪 ``` 签名验证为什么能防这个? ```text 标准流程: 1. 收到请求 2. 先验签(轻量级操作,只需检验签名是否合法) 3. 签名失败 -> 直接拒绝,不进行解密(很快) 4. 签名成功 -> 才执行昂贵的解密操作 黑客即使发 100000 个请求,99999 个在"验签关"就被挡住了 后端只需执行 1 个解密,而不是 100000 个解密 结论:先验签 = 第一道防火墙,保护昂贵的解密操作 ``` ##### 双套密钥设计的高明之处 为什么需要两对密钥? **一对密钥负责防篡改(验证身份),另一对密钥负责防泄露(保护隐私)。** ```text ┌─────────────────────────────────────────────────────┐ │ 双套密钥 = 纵深防御的最佳实践 │ ├─────────────────────────────────────────────────────┤ │ │ │ 【签名密钥对】Sign Key │ │ ├─ 前端私钥 (sign_secret_key) │ │ │ └─ 用来"盖章":生成请求签名 │ │ │ sign = SHA256withRSA(请求内容) │ │ │ │ │ └─ 后端公钥 (sign_public_key) │ │ └─ 用来"验章":检查签名是否真实 │ │ 只有持有私钥的 App 才能造出正确的签名 │ │ │ │ 【数据密钥对】Data Key │ │ ├─ 前端公钥 (data_public_key) │ │ │ └─ 用来"上锁":加密敏感数据 │ │ │ businessBody_encrypted = RSA_encrypt(data) │ │ │ │ │ └─ 后端私钥 (data_secret_key) │ │ └─ 用来"开锁":解密数据 │ │ 只有后端才能打开这个保险箱 │ │ │ └─────────────────────────────────────────────────────┘ ``` 关键的"反向"设计 注意上面的 **密钥持有方向完全相反**: | 组件 | Sign Key 签名 | Data Key 加密 | | --- | --- | --- | | **前端持有** | 🔴 私钥(绝密) | 🟢 公钥(公开) | | **后端持有** | 🟢 公钥(公开) | 🔴 私钥(绝密) | | **用途** | 前端签名 → 后端验证 | 前端加密 → 后端解密 | | **安全级别** | 中(需代码混淆) | 高(完全保密) | **为什么这样设计?** - **Sign Key**:需要前端私钥,所以必须打包在 App 里 → 容易被反编译 → 需要代码混淆 - **Data Key**:私钥绝对在后端 → 无法被前端反编译 → 安全性最高 ##### V1 与 V2 的层级关系 V1 vs V2:不是"二选一",而是"递进关系" ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/v1v2层级关系-119e3249.jpg]] ##### 应用场景分级(何时用V1、何时用V2) ###### 为什么不是"全站 V2"? 既然 V2(加密+签名)更安全,为什么不把所有接口都改成 V2? 答案是:**过度安全 = 性能浪费 + 开发复杂度爆炸**。 ```text ┌─────────────────────────────────────────────────────────┐ │ 用户行为分布与安全等级映射 │ ├─────────────────────────────────────────────────────────┤ │ │ │ 首页浏览 ┐ │ │ 商品列表 │ 99% 高频操作 │ │ 搜索 │ QPS 极高(每秒万级) │ │ 看评论 ┘ 数据不敏感 → V1 就够了 │ │ │ │ ────────────────────────────────────── │ │ │ │ 注册登录 ┐ │ │ 修改密码 │ 1% 低频操作 │ │ 支付 │ QPS 很低(每分钟百级) │ │ 实名认证 ┘ 数据极敏感 → 必须用 V2 │ │ │ └─────────────────────────────────────────────────────────┘ 核心数据: - 注册接口:每天 10 万新用户,平均 1.16 个/秒 - 商品查询:每秒 10 万次查询,并发度是注册的 86000 倍 结论:为注册加密(V2)的成本可以忽略不计! ``` ###### 前端请求 V2 应用场景一览表 ```text 优先级 场景分类 具体接口 数据示例 加密方案 ───────────────────────────────────────────────────────────────────── 🔴高危 账号体系 /api/user/register 密码、手机号 ✅ V2 (密码相关) /api/user/login 登录密码 ✅ V2 /api/user/pwd/reset 旧密码、新密码 ✅ V2 /api/user/pwd/change 旧密码、新密码 ✅ V2 🔴高危 个人信息 /api/user/realname 身份证号 ✅ V2 (身份证) /api/user/bind/card 银行卡号 ✅ V2 /api/user/bind/alipay 支付宝账号 ✅ V2 🔴高危 交易支付 /api/order/create 支付密码、金额 ✅ V2 (钱相关) /api/pay/submit CVV码、验证码 ✅ V2 /api/wallet/withdraw 提现密码、账户 ✅ V2 🟡中危 收货地址 /api/address/save 详细地址、电话 ✅ V2 (隐私) /api/address/update (包含门牌号) ✅ V2 🟡中危 个人档案 /api/user/update 邮箱(部分App) ✅ V2 (个人) /api/user/resume/save 简历内容 ✅ V2 🟢低危 公开查询 /api/goods/list 商品名、价格 ❌ V1 (浏览) /api/goods/detail 品牌、规格 ❌ V1 /api/search 搜索关键词 ❌ V1 /api/comments 评论内容 ❌ V1 🟢低危 用户档案显示 /api/user/profile 昵称、签名、头像 ❌ V1 (展示) /api/user/getInfo 脱敏后的手机号 ❌ V1 ``` ###### 后端响应 V2 应用场景 在 **90% 的场景是**"后端返回脱敏就够了"。但有 3 个例外必须用 V2: 场景 1:编辑模式回显(最常见的漏洞) ```text 用户流程: 1. 点击"修改收货地址"按钮 2. 网关返回响应 { "code": 200, "data": { "name": "张三", "phone": "138****1234", ← 脱敏了 "address": "北京市朝阳区..." } } 3. 用户看到输入框是空的(因为 phone 是带星号的) 4. 用户感到困惑,这个体验极差! ❌ 错误做法: 后端直接通过 V1 返回真实手机号 13800138000 黑客截获响应,拿到了用户隐私 ✅ 正确做法: 后端检测这是一个"编辑"请求(比如带了 editMode=true 参数) 自动切换到 V2 加密响应 { "code": 200, "data": "Xy7zAb9cDe..." ← 加密了真实号码 } 前端收到密文,自动解密得到 13800138000 用户看到完整的号码,可以正常编辑 ``` 场景 2:客户端内部业务逻辑需要 ```text 这些数据不是给人看的,是给代码用的: - JWT Token 或 SessionKey:APP 内跳转时要用 - 第三方支付的调起参数:支付宝 sign 串 - 加密狗的 License Key:客户端验证许可证 - 订单加密编号:用于分享时不暴露真实订单 ID 风险举例: 后端通过 V1 返回 sessionKey="abc123xyz789" 黑客截获 -> 伪造用户身份进行后续操作 用户账户被盗用 对策:必须走 V2 加密响应 ``` 场景 3:虚拟资产与高价值数据 ```text 一旦泄露就是直接的经济损失: - 加密货币钱包私钥:点击"导出私钥"时显示 - 购物卡卡密:充值卡购买后显示密码 - 代金券码:生成的兑换码全文 - 合同原文:未脱敏的法律文件 示例: 用户充值了一张 500 元的游戏充值卡,卡密是 1234567890abcdef ❌ 错误: { "code": 200, "data": { "cardNo": "1234567890abcdef", ← V1 明文 "amount": 500 } } 黑客截获 -> 卡密泄露 -> 黑客先充值使用这张卡 ✅ 正确: { "code": 200, "data": "Xy7zAb9cDe..." ← V2 加密 } 前端解密后显示给用户 ``` ###### 场景速查表 ```text ┌──────────────────────────────────────────────────────────────────┐ │ V1 vs V2 应用场景速查表 │ ├──────────┬─────────────────┬────────────────┬───────────────────┤ │ 数据流向 │ 场景 │ 数据示例 │ 推荐方案 │ ├──────────┼─────────────────┼────────────────┼───────────────────┤ │ │ 注册、登录 │ 密码、手机号 │ ✅ V2 (加密) │ │ 前→后 │ 支付、提现 │ 支付密码、CVV │ ✅ V2 (加密) │ │ (上行) │ 修改隐私信息 │ 身份证、地址 │ ✅ V2 (加密) │ │ 敏感数据 │ 实名认证 │ 真实姓名 │ ✅ V2 (加密) │ ├──────────┼─────────────────┼────────────────┼───────────────────┤ │ │ 商品列表、搜索 │ 公开数据 │ ❌ V1 (仅签名) │ │ 前→后 │ 查看评论、新闻 │ 公开内容 │ ❌ V1 (仅签名) │ │ (上行) │ │ │ │ │ 公开数据 │ 分页、排序参数 │ page, sort │ ❌ V1 (仅签名) │ ├──────────┼─────────────────┼────────────────┼───────────────────┤ │ │ 脱敏展示 │ 138****8000 │ ❌ V1 (仅签名) │ │ 后→前 │ 个人主页显示 │ 脱敏昵称 │ ❌ V1 (仅签名) │ │ (下行) │ 商品详情返回 │ 品牌、价格 │ ❌ V1 (仅签名) │ │ 脱敏后 │ │ │ │ ├──────────┼─────────────────┼────────────────┼───────────────────┤ │ │ 编辑回显 │ 真实手机号 │ ✅ V2 (加密) │ │ 后→前 │ 导出私钥、卡密 │ 完整密码 │ ✅ V2 (加密) │ │ (下行) │ SessionKey/JWT │ Token 全文 │ ✅ V2 (加密) │ │ 敏感数据 │ 第三方支付参数 │ Sign 串 │ ✅ V2 (加密) │ └──────────┴─────────────────┴────────────────┴───────────────────┘ 一句话总结: 能在后端脱敏就脱敏(用 V1) 无法脱敏就加密(用 V2) 永远不要依赖前端脱敏 ``` ## **整体架构设计** ### **渠道数据表设计** 我们需要为不同的接入端(App、小程序、PC)分配不同的“身份证”和“钥匙”。 - **设计核心**:采用**双套 RSA 密钥对**。 1. **签名密钥对 (**`sign_`**)**: - **前端持有私钥**:用于对请求参数签名,证明“我是我”。 - **后端持有公钥**:用于验证签名,防止篡改。 2. **数据密钥对 (**`data_`**)**: - **前端持有公钥**:用于加密敏感数据(如密码、金额)。 - **后端持有私钥**:用于解密数据。 ```sql -- 渠道基础数据信息 -- 不同平台(小程序、App、PC)使用不同的密钥 CREATE TABLE `channel_data` ( `id` bigint(64) NOT NULL COMMENT 'id', `name` varchar(50) DEFAULT NULL COMMENT '名称', `code` varchar(50) NOT NULL COMMENT '编码(如:app、miniprogram、pc)', `introduce` varchar(500) DEFAULT NULL COMMENT '介绍描述', -- 签名相关密钥(前端持有私钥签名,后端持有公钥验签) `sign_public_key` text NOT NULL COMMENT 'rsa签名公钥', `sign_secret_key` text NOT NULL COMMENT 'rsa签名私钥', -- 数据加密相关密钥(前端持有公钥加密,后端持有私钥解密) `data_public_key` text COMMENT 'rsa参数公钥', `data_secret_key` text COMMENT 'rsa参数私钥', -- AES 密钥(备用,用于大数据量加密) `aes_key` text COMMENT 'aes密钥', -- Token 密钥(用于 JWT 签名) `token_secret` text NOT NULL COMMENT 'token密钥', `status` int(1) DEFAULT '1' COMMENT '状态 1:启用 0:禁用', `edit_time` datetime DEFAULT NULL COMMENT '编辑时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `code_IDX` (`code`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道基础数据信息'; ``` ### **请求参数格式** 为了统一处理,我们定义了一个标准的报文格式。业务参数不再散落在外,而是封装在 `businessBody` 中。 ```json { "code": "app", // 1. 告诉后端我是谁,以便后端查找对应的密钥 "businessBody": "{...}", // 2. 核心业务数据(如果是 V2 版本,这里是密文) "sign": "xxxxx..." // 3. 数字签名,保证 code 和 businessBody 没被改过 } ``` | 字段 | 说明 | 示例 | | --- | --- | --- | | `code` | 渠道标识,网关用它查找对应的密钥 | `"app"` 或 `"miniprogram"` | | `businessBody` | 核心业务数据 | V1: `"{\"userId\":123,\"page\":1}"` (明文) V2: `"Xy7zAb9cDe..."` (密文) | | `sign` | 数字签名,证明上面两个字段没被改过 | `"xxx/yyy+zzz=="` | | `encrypt` (Header) | 加密版本标记 | V1: 无或 `v1` V2: `v2` | ### **两种加密版本** ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/加密版本对比-78b6e12b.jpg]] ### **密钥使用说明** ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/两层校验分工-61434281.jpg]] ## **核心流程与密钥使用说明** 这是整个架构最难理解的部分。我们需要明确**谁持有哪个钥匙**以及**数据如何流转**。 ### **密钥分布视图** | **密钥类型** | **密钥名称** | **持有方** | **用途** | **安全级别** | | --- | --- | --- | --- | --- | | **签名私钥** | `sign_secret_key` | **前端** | 生成请求签名 (`sign` 字段) | 🔴 高危 (需混淆保护) | | **签名公钥** | `sign_public_key` | **后端** | 验证请求签名 | 🟢 公开 | | **数据公钥** | `data_public_key` | **前端** | 加密 `businessBody` | 🟢 公开 | | **数据私钥** | `data_secret_key` | **后端** | 解密 `businessBody` | 🔴 绝密 (存服务器) | ### **Gateway 网关处理流程** 当一个请求到达 Gateway 时,就像过安检一样,需要经过层层过滤: 1. **提取 Code**:识别请求来源(是 App 还是 PC?)。 2. **加载密钥**:利用 Code 从 Redis 缓存中读取该渠道的密钥配置。 3. **解密 (V2)**:如果开启了加密,使用**后端私钥**解密 `businessBody`,还原成明文 JSON。 4. **验签**:将所有参数(含解密后的 Body)按规则排序拼接,使用**后端公钥**验证 `sign` 是否正确。 5. **Token 校验**:如果业务需要登录,解析 Header 中的 Token,获取 UserID。 6. **转发**:将解密、验证后的明文参数转发给下游微服务。 ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/gateway网关处理流程-0c0688ca.jpg]] ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/网关处理-cbc1a666.jpg]] ## **核心代码解析** ### **渠道数据服务:多级缓存设计** 为了保证网关的高性能,我们不能每次请求都查数据库。这里采用了 **DB -> Redis** 的缓存策略。 **代码解读** `ChannelDataService`: - `add()`: 新增渠道时,既写入 MySQL,也同步写入 Redis。 - `getChannelDataByCode()`: 网关调用的高频接口。**优先查 Redis** -> 如果没有 -> 查 DB -> 回写 Redis。这是标准的 Cache-Aside 模式。 ```java /** * 渠道数据服务 * 添加渠道配置时同步缓存到 Redis */ @Service public class ChannelDataService { @Autowired private ChannelDataMapper channelDataMapper; @Autowired private RedisCache redisCache; @Autowired private UidGenerator uidGenerator; /** * 添加渠道数据 */ @Transactional(rollbackFor = Exception.class) public void add(ChannelDataAddDto channelDataAddDto) { ChannelData channelData = new ChannelData(); BeanUtils.copyProperties(channelDataAddDto, channelData); channelData.setId(uidGenerator.getUid()); channelData.setCreateTime(DateUtils.now()); // 保存到数据库 channelDataMapper.insert(channelData); // 同步到 Redis 缓存 addRedisChannelData(channelData); } /** * 保存到 Redis 中 * 网关查询时优先从 Redis 获取,提高效率 */ private void addRedisChannelData(ChannelData channelData) { GetChannelDataVo getChannelDataVo = new GetChannelDataVo(); BeanUtils.copyProperties(channelData, getChannelDataVo); redisCache.set( RedisKeyBuild.createRedisKey(RedisKeyManage.CHANNEL_DATA, channelData.getCode()), getChannelDataVo ); } /** * 根据渠道编码获取密钥配置 * 网关调用此方法 */ public GetChannelDataVo getChannelDataByCode(String code) { // 1. 验证 code checkCode(code); // 2. 先从 Redis 查询 GetChannelDataVo channelDataVo = getChannelDataByRedis(code); if (Objects.isNull(channelDataVo)) { // 3. 缓存没有,调用 base-data 服务查询 channelDataVo = getChannelDataByClient(code); // 4. 写入 Redis 缓存 setChannelDataRedis(code, channelDataVo); } return channelDataVo; } private GetChannelDataVo getChannelDataByRedis(String code) { return redisCache.get( RedisKeyBuild.createRedisKey(RedisKeyManage.CHANNEL_DATA, code), GetChannelDataVo.class ); } } ``` ### **请求验证过滤器:网关的大脑** [[00-接口参数加解密与签名验证深度解析|接口参数加解密与签名验证深度解析]] [[00-接口参数加解密与签名验证深度解析|接口参数加解密与签名验证深度解析]] `RequestValidationFilter` 是网关最核心的组件,实现了 `GlobalFilter` 接口,拦截所有请求。 **逻辑拆解** `doExecute` **方法**: 1. **前置检查**:判断是否开启了 `noVerify`(开发环境后门,生产环境必须关闭)。 2. **加载配置**:`channelDataService.getChannelDataByCode(code)`。 3. **解密阶段 (Step 4)**: - 检查 `encrypt` 头是否为 "v2"。 - 如果是,调用 `RsaTool.decrypt` 使用**后端私钥**解密数据。 4. **验签阶段 (Step 5)**: - 调用 `RsaSignTool.verifyRsaSign256` 使用**后端公钥**验证签名。 - **注意**:一旦验签失败,直接抛出异常,请求中止。 5. **Token 处理 (Step 6)**: - 非登录接口(如首页)也可携带 Token,网关会解析出 UserId 传给下游,用于个性化推荐。 - 强制登录接口若无 Token,直接拦截。 6. **重构请求**:将解密后的 `businessBody` 作为新的 Body 传递给下游服务,**下游服务完全感知不到加解密的过程,只需处理明文**。 ```java /** * 请求验证过滤器 * Gateway 的核心处理逻辑 */ @Component @Slf4j public class RequestValidationFilter implements GlobalFilter, Ordered { @Autowired private ChannelDataService channelDataService; @Autowired private TokenService tokenService; @Autowired private GatewayProperty gatewayProperty; @Autowired private UidGenerator uidGenerator; @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { return doFilter(exchange, chain); } /** * 核心处理逻辑 */ public Mono doFilter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // ========== Step 1: 生成链路追踪ID ========== String traceId = request.getHeaders().getFirst(TRACE_ID); if (StringUtil.isEmpty(traceId)) { traceId = String.valueOf(uidGenerator.getUid()); } // 放入 MDC 便于日志输出 MDC.put(TRACE_ID, traceId); // 放入 ThreadLocal BaseParameterHolder.setParameter(TRACE_ID, traceId); // 灰度标识 String gray = request.getHeaders().getFirst(GRAY_PARAMETER); BaseParameterHolder.setParameter(GRAY_PARAMETER, gray); // 准备要传递的请求头 Map headMap = new HashMap<>(8); headMap.put(TRACE_ID, traceId); headMap.put(GRAY_PARAMETER, gray); // ========== Step 2: 判断请求类型 ========== MediaType contentType = request.getHeaders().getContentType(); if (Objects.nonNull(contentType) && contentType.toString().toLowerCase().contains(MediaType.APPLICATION_JSON_VALUE.toLowerCase())) { // JSON 请求,进行参数验证 return readBody(exchange, chain, headMap); } else { // 非 JSON 请求,直接转发 return chain.filter(exchange); } } /** * 具体的参数验证逻辑 */ private Map doExecute(String originalBody, ServerWebExchange exchange) { log.info("current thread verify: {}", Thread.currentThread().getName()); ServerHttpRequest request = exchange.getRequest(); String requestBody = originalBody; Map bodyContent = new HashMap<>(32); if (StringUtil.isNotEmpty(originalBody)) { bodyContent = JSON.parseObject(originalBody, Map.class); } String code = null; String token; String userId = null; String url = request.getPath().value(); String noVerify = request.getHeaders().getFirst(NO_VERIFY); // ========== Step 2: 判断是否跳过验证 ========== boolean allowNormalAccess = gatewayProperty.isAllowNormalAccess(); if ((!allowNormalAccess) && (VERIFY_VALUE.equals(noVerify))) { throw new DaMaiFrameException(BaseCode.ONLY_SIGNATURE_ACCESS_IS_ALLOWED); } // 是否需要验证 if (checkParameter(originalBody, noVerify) && !skipCheckParameter(url)) { String encrypt = request.getHeaders().getFirst(ENCRYPT); code = bodyContent.get(CODE); token = request.getHeaders().getFirst(TOKEN); // ========== Step 3: 获取渠道密钥配置 ========== GetChannelDataVo channelDataVo = channelDataService.getChannelDataByCode(code); // ========== Step 4: V2 版本参数解密 ========== if (StringUtil.isNotEmpty(encrypt) && V2.equals(encrypt)) { // 使用 RSA 私钥解密 String decrypt = RsaTool.decrypt( bodyContent.get(BUSINESS_BODY), channelDataVo.getDataSecretKey() ); // 替换为解密后的内容 bodyContent.put(BUSINESS_BODY, decrypt); } // ========== Step 5: 签名验证 ========== boolean checkFlag = RsaSignTool.verifyRsaSign256( bodyContent, channelDataVo.getSignPublicKey() ); if (!checkFlag) { throw new DaMaiFrameException(BaseCode.RSA_SIGN_ERROR); } // ========== Step 6: Token 验证 ========== boolean skipCheckTokenResult = skipCheckToken(url); // 需要验证 Token 但没传 if (!skipCheckTokenResult && StringUtil.isEmpty(token)) { ArgumentError argumentError = new ArgumentError(); argumentError.setArgumentName("token"); argumentError.setMessage("token参数为空"); throw new ArgumentException(BaseCode.ARGUMENT_EMPTY.getCode(), Collections.singletonList(argumentError)); } // 验证 Token 并获取 userId if (!skipCheckTokenResult) { UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret()); userId = userVo.getId(); } // 某些接口不强制登录,但如果传了 Token 也要解析 userId if (StringUtil.isEmpty(userId) && checkNeedUserId(url) && StringUtil.isNotEmpty(token)) { UserVo userVo = tokenService.getUser(token, code, channelDataVo.getTokenSecret()); userId = userVo.getId(); } // 提取业务参数作为新的请求体 requestBody = bodyContent.get(BUSINESS_BODY); } // ========== Step 7: API 防刷限制 ========== apiRestrictService.apiRestrict(userId, url, request); // ========== 准备返回数据 ========== Map map = new HashMap<>(4); map.put(REQUEST_BODY, requestBody); if (StringUtil.isNotEmpty(code)) { map.put(CODE, code); } if (StringUtil.isNotEmpty(userId)) { map.put(USER_ID, userId); } return map; } /** * 判断是否跳过 Token 验证 */ public boolean skipCheckToken(String url) { for (String skipCheckTokenPath : gatewayProperty.getCheckTokenPaths()) { PathMatcher matcher = new AntPathMatcher(); if (matcher.match(skipCheckTokenPath, url)) { return false; // 匹配到需要验证的路径 } } return true; // 不在列表中,跳过验证 } @Override public int getOrder() { return -2; // 优先级较高 } } ``` ### **响应加密过滤器:闭环安全** 仅仅保护请求是不够的,服务器返回的数据(如用户余额、订单号)也需要保护。 **代码解读** `ResponseValidationFilter`: - 这是一个 `Post` 类型的过滤器,在请求处理完**返回给前端之前**执行。 - 它拦截响应体,检查是否是 V2 加密模式。 - 使用 **前端数据公钥** (`dataPublicKey`) 对响应数据进行加密。 - 前端收到后,使用自己的私钥解密展示。 ```java /** * 响应验证过滤器 * 对 V2 版本的响应进行加密 */ @Component @Slf4j public class ResponseValidationFilter implements GlobalFilter, Ordered { @Autowired private ChannelDataService channelDataService; /** * 检查并处理响应体 */ private String checkResponseBody(ServerWebExchange serverWebExchange, String responseBody) { String modifyResponseBody = responseBody; ServerHttpRequest request = serverWebExchange.getRequest(); // 获取验证相关请求头 String noVerify = request.getHeaders().getFirst(NO_VERIFY); String encrypt = request.getHeaders().getFirst(ENCRYPT); // V2 版本需要加密响应 if ((!VERIFY_VALUE.equals(noVerify)) && "v2".equals(encrypt) && StringUtil.isNotEmpty(responseBody)) { ApiResponse apiResponse = JSON.parseObject(responseBody, ApiResponse.class); Object data = apiResponse.getData(); if (data != null) { // 获取渠道编码 String code = request.getHeaders().getFirst(CODE); checkCodeHandler.checkCode(code); // 获取渠道密钥配置 GetChannelDataVo channelDataVo = channelDataService.getChannelDataByCode(code); // 使用 RSA 公钥加密响应数据 String rsaEncrypt = RsaTool.encrypt( JSON.toJSONString(data), channelDataVo.getDataPublicKey() ); // 替换响应数据为加密后的密文 apiResponse.setData(rsaEncrypt); modifyResponseBody = JSON.toJSONString(apiResponse); } } return modifyResponseBody; } } ``` ### **RSA 工具类** - **排序是关键**:`buildSignContent` 方法中,使用了 `TreeMap` 或 Stream 排序。这是因为 JSON 的字段顺序是不确定的,必须按 Key 的字母顺序排序(a=1&b=2...)才能保证前后端生成的签名一致。 ```java /** * RSA 签名工具类 */ public class RsaSignTool { // 签名私钥(前端持有) public static String signPrivateKey = "MIIEvAIBADANBgkqhkiG9w0BAQEF..."; // 签名公钥(后端持有) public static String signPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOC..."; /** * RSA SHA256 签名 * @param map 待签名数据 * @param privateKey 私钥 * @return 签名结果 */ public static String rsaSign256(Map map, String privateKey) { // 1. 将参数按 key 排序拼接 String signContent = buildSignContent(map); // 2. 使用私钥签名 try { PrivateKey priKey = getPrivateKeyFromPKCS8(privateKey); Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(priKey); signature.update(signContent.getBytes(StandardCharsets.UTF_8)); byte[] signed = signature.sign(); return Base64.getEncoder().encodeToString(signed); } catch (Exception e) { throw new RuntimeException("签名失败", e); } } /** * RSA SHA256 验签 * @param map 待验签数据(包含 sign 字段) * @param publicKey 公钥 * @return 验证结果 */ public static boolean verifyRsaSign256(Map map, String publicKey) { // 1. 取出签名 String sign = map.get("sign"); // 2. 移除 sign 字段后重新构建签名内容 Map signMap = new HashMap<>(map); signMap.remove("sign"); String signContent = buildSignContent(signMap); // 3. 使用公钥验签 try { PublicKey pubKey = getPublicKeyFromX509(publicKey); Signature signature = Signature.getInstance("SHA256withRSA"); signature.initVerify(pubKey); signature.update(signContent.getBytes(StandardCharsets.UTF_8)); return signature.verify(Base64.getDecoder().decode(sign)); } catch (Exception e) { return false; } } /** * 构建签名内容 * 按 key 的字母顺序排序,拼接成 key=value&key=value 格式 */ private static String buildSignContent(Map map) { return map.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } } /** * RSA 加解密工具类 */ public class RsaTool { /** * RSA 公钥加密 */ public static String encrypt(String plainText, String publicKey) { try { PublicKey pubKey = getPublicKeyFromX509(publicKey); Cipher cipher = Cipher.getInstance("RSA"); cipher.init(Cipher.ENCRYPT_MODE, pubKey); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new RuntimeException("加密失败", e); } } /** * RSA 私钥解密 */ public static String decrypt(String cipherText, String privateKey) { try { PrivateKey priKey = getPrivateKeyFromPKCS8(privateKey); Cipher cipher = Cipher.getInstance("RSA"); cipher.init(Cipher.DECRYPT_MODE, priKey); byte[] decoded = Base64.getDecoder().decode(cipherText); byte[] decrypted = cipher.doFinal(decoded); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("解密失败", e); } } } ``` ### **前端模拟签名和加密** 代码展示了 V1 和 V2 的区别: - **V1**:`sign` + 明文 Body。防篡改,但数据可见。 - **V2**:`sign` + 密文 Body。既防篡改,又防偷窥。 ```java /** * 模拟前端的签名和加密过程 * 用于测试和理解流程 */ public class RsaSignTool { public static void main(String[] args) { // V1 版本:仅签名 parameterTransferV1(); // V2 版本:加密 + 签名 parameterTransferV2(); } /** * V1 版本:仅签名 */ public static void parameterTransferV1() { Map map = new HashMap<>(8); // 基础参数 map.put("code", "app"); // 业务参数(明文) map.put("businessBody", "{\"id\":\"1111\",\"sleepTime\":10}"); // 使用私钥签名 String sign = RsaSignTool.rsaSign256(map, signPrivateKey); System.out.println("签名: " + sign); map.put("sign", sign); // 验签测试 boolean result = RsaSignTool.verifyRsaSign256(map, signPublicKey); System.out.println("验签结果: " + result); } /** * V2 版本:加密 + 签名 */ public static void parameterTransferV2() { // 1. 准备业务参数 Map businessMap = new HashMap<>(8); businessMap.put("id", "1111"); businessMap.put("sleepTime", 10); String businessBody = JSON.toJSONString(businessMap); // 2. 先对明文业务参数签名 Map signMap = new HashMap<>(8); signMap.put("code", "app"); signMap.put("businessBody", businessBody); // 明文 String sign = RsaSignTool.rsaSign256(signMap, signPrivateKey); System.out.println("签名: " + sign); // 3. 再对业务参数加密 String encryptedBody = RsaTool.encrypt(businessBody, dataPublicKey); System.out.println("加密后: " + encryptedBody); // 4. 解密测试 String decryptedBody = RsaTool.decrypt(encryptedBody, dataPrivateKey); System.out.println("解密后: " + decryptedBody); // 5. 验签测试 boolean result = RsaSignTool.verifyRsaSign256(signMap, signPublicKey); System.out.println("验签结果: " + result); // 6. 最终请求体 Map requestBody = new HashMap<>(); requestBody.put("code", "app"); requestBody.put("businessBody", encryptedBody); // 加密后的密文 requestBody.put("sign", sign); System.out.println("最终请求: " + JSON.toJSONString(requestBody)); } } ``` ## **配置化开关** 为了方便开发调试,系统设计了完善的开关: - **前端**:`.env` 文件控制是否开启加密,开发时可关闭以方便抓包调试。 - **后端**:`GatewayProperty` 配置了白名单(如支付回调不需要验签)和 Token 校验路径。 ### **前端配置切换** ```text // .env.development 文件 // 签名开关:0-不签名普通调用,1-签名调用 VITE_SIGN_FLAG = 1 // 加密版本:v1-仅签名,v2-加密+签名 VITE_ENCRYPT_VERSION = v2 ``` ### **后端配置** ```java @Data @Component public class GatewayProperty { /** * 是否允许跳过验证(开发环境可设为 true) */ @Value("${allow.normal.access:true}") private boolean allowNormalAccess; /** * 需要验证 Token 的路径 */ @Value("${skip.check.token.paths:/**/program/order/create/**,...}") private String[] checkTokenPaths; /** * 跳过参数验证的路径(如支付回调) */ @Value("${skip.check.parameter.paths:/**/alipay/notify}") private String[] checkSkipParameterPaths; /** * 需要解析 userId 的路径(即使不强制登录) */ @Value("${userId.paths:/**/program/detail,...}") private String[] userIdPaths; } ``` ## **完整数据流转图** ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/v2数据流转-e93cd194.jpg]] ![[2-Learning/05-项目/08-企业级项目深读/02-damai_pro/05-业务深读/01-接口参数加解密与签名验证深度解析/assets/时序图-10732ec2.jpg]] ## **核心总结** ### **安全机制对比** | **版本** | **防篡改** | **防窃听** | **响应加密** | **适用场景** | | --- | --- | --- | --- | --- | | 无验证 | ❌ | ❌ | ❌ | 开发测试 | | V1 签名 | ✅ | ❌ | ❌ | 一般安全要求 | | V2 加密+签名 | ✅ | ✅ | ✅ | 高安全要求(金融交易) | ### **密钥管理** | **密钥类型** | **前端持有** | **后端持有** | **用途** | | --- | --- | --- | --- | | 签名私钥 | ✅ | ❌ | 前端签名 | | 签名公钥 | ❌ | ✅ | 后端验签 | | 数据公钥 | ✅ | ✅ | 加密数据 | | 数据私钥 | ✅ | ✅ | 解密数据 | ### **设计亮点** | **设计点** | **说明** | | --- | --- | | **多渠道隔离** | 不同平台使用不同密钥,互不影响 | | **双重验证** | 签名验证 + Token 验证 | | **渐进式加密** | V1/V2 可配置切换 | | **密钥缓存** | Redis 缓存渠道配置,提高效率 | | **统一网关处理** | 业务服务无需关心加解密 | | **链路追踪** | 生成 traceId 贯穿全链路 | **设计思想**: 接口安全不是单一技术能解决的,而是**纵深防御**。 1. **HTTPS**:防止通道监听(物理层防护)。 2. **RSA 签名**:防止参数被改(数据完整性)。 3. **RSA 加密**:防止数据被看(数据机密性)。 4. **Redis 缓存**:用空间换时间,解决 RSA 计算慢带来的性能损耗。 --- > *💡 **设计思想**:接口安全不是单一技术能解决的,需要**多层防护**。签名保证数据完整性和来源可信,加密保证数据机密性,Token 保证用户身份合法,API 防刷保证服务可用性。这些机制在网关层统一处理,对业务服务完全透明,既保证了安全性,又不增加业务开发的复杂度。* --- **企业级项目导航**:⬅️ [[00-业务深读|00-业务深读]] | 00-接口参数加解密与签名验证深度解析 | ➡️ [[02-Spring Cloud Gateway 安全验证完整解决方案|02-Spring Cloud Gateway 安全验证完整解决方案]]