接口参数加解密与签名验证深度解析
引子:前后端分离的安全隐患
在微服务架构下,前后端分离已成为标配。但这种架构也带来了一个严重的安全问题:接口参数完全暴露在网络中。
对于大麦网这类涉及高并发抢票和金融交易的系统,接口“裸奔”会导致两大风险:
- 参数篡改:黑客抓包后修改金额、库存或用户ID,导致资产损失。
- 数据泄露:用户隐私(手机号、身份证)在传输过程中被中间人截获。
- 恶意刷单:黄牛利用脚本模拟接口请求,绕过前端限制直接抢票。
为了解决这些问题,我们需要在网关层(Gateway)构建一套透明的、高安全性的防护体系。
加密算法基础
在设计安全方案前,我们需要了解两种核心加密方式的区别:
对称加密 vs 非对称加密
- 对称加密 (如 AES):加密和解密使用同一个密钥。
- 优点:速度快,适合大量数据。
- 缺点:密钥分发困难。如果前端(App)里硬编码了密钥,一旦 App 被反编译,密钥泄露,整个系统崩塌。
- 非对称加密 (如 RSA):有一对密钥(公钥和私钥)。
- 公钥:公开给所有人,用于加密或验签。
- 私钥:严格保密,用于解密或签名。
- 优点:安全性极高。前端只存公钥,即使被反编译,黑客也只能加密数据,无法解密服务器返回的数据。
选择:RSA 非对称加密
本方案采用了 RSA 作为核心算法,配合不同场景使用不同的密钥对,实现了双向验证。
理解
首先v1 是使用轻量级的哈希 得到一个签名 后端可以先拿到可以签名确定是否是真正的请求 从而过滤掉无效请求 防止了重放攻击和数据篡改 接着是v2版本 更加严格 数据看都不希望让别人截取看到 所以在基础上把传输的内容体都加密了一下 但这样的成本较高 后端解密需要花一定资源 这样就更凸显了v1的重要性 如果没有事先验证是否为真正的请求就解析 会浪费大量资源 如果重放攻击ddos就会导致系统崩溃 所以需要先v2加密内容体 然后对加密后的内容生成签名 后端拿到后 先验签 确保正确后 再解析内容 但这切切实实会多花一些资源 只有在非常敏感的数据时才需要这种级别的加密 一般的情况 只要确定不被篡改、敏感数据脱敏 就够用了
问题症候——为什么只加密不够?
场景复现:假如只有加密,没有签名
你正在大麦网上抢演唱会门票,点击"购票",前端执行:
// 前端代码
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)—— 最常见
黑客行为:
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 次钱!
签名为什么能防这个?
标准的签名结构应该包含:时间戳 (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 攻击(资源耗尽)
黑客行为:
虽然我不知道你的业务数据格式,但数据公钥写在前端代码里(公开的)
我可以用这个公钥加密任意垃圾数据
for i in 1..100000:
POST /order/create
{
"code": "app",
"businessBody": RSA_encrypt("asdfghjkl随机垃圾", dataPublicKey),
"sign": "任意值" // 根本不验签的话,什么都可以
}
后端无防线情况:
1. 收到请求 -> 解密 businessBody(CPU 密集操作)
2. 发现是垃圾数据 -> 丢弃
3. 继续下一个请求
→ 后端 CPU 被 RSA 解密操作耗满 -> 服务瘫痪
签名验证为什么能防这个?
标准流程:
1. 收到请求
2. 先验签(轻量级操作,只需检验签名是否合法)
3. 签名失败 -> 直接拒绝,不进行解密(很快)
4. 签名成功 -> 才执行昂贵的解密操作
黑客即使发 100000 个请求,99999 个在"验签关"就被挡住了
后端只需执行 1 个解密,而不是 100000 个解密
结论:先验签 = 第一道防火墙,保护昂贵的解密操作
双套密钥设计的高明之处
为什么需要两对密钥?
一对密钥负责防篡改(验证身份),另一对密钥负责防泄露(保护隐私)。
┌─────────────────────────────────────────────────────┐
│ 双套密钥 = 纵深防御的最佳实践 │
├─────────────────────────────────────────────────────┤
│ │
│ 【签名密钥对】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:不是"二选一",而是"递进关系"
应用场景分级(何时用V1、何时用V2)
为什么不是"全站 V2"?
既然 V2(加密+签名)更安全,为什么不把所有接口都改成 V2?
答案是:过度安全 = 性能浪费 + 开发复杂度爆炸。
┌─────────────────────────────────────────────────────────┐
│ 用户行为分布与安全等级映射 │
├─────────────────────────────────────────────────────────┤
│ │
│ 首页浏览 ┐ │
│ 商品列表 │ 99% 高频操作 │
│ 搜索 │ QPS 极高(每秒万级) │
│ 看评论 ┘ 数据不敏感 → V1 就够了 │
│ │
│ ────────────────────────────────────── │
│ │
│ 注册登录 ┐ │
│ 修改密码 │ 1% 低频操作 │
│ 支付 │ QPS 很低(每分钟百级) │
│ 实名认证 ┘ 数据极敏感 → 必须用 V2 │
│ │
└─────────────────────────────────────────────────────────┘
核心数据:
- 注册接口:每天 10 万新用户,平均 1.16 个/秒
- 商品查询:每秒 10 万次查询,并发度是注册的 86000 倍
结论:为注册加密(V2)的成本可以忽略不计!
前端请求 V2 应用场景一览表
优先级 场景分类 具体接口 数据示例 加密方案
─────────────────────────────────────────────────────────────────────
🔴高危 账号体系 /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:编辑模式回显(最常见的漏洞)
用户流程:
1. 点击"修改收货地址"按钮
2. 网关返回响应
{
"code": 200,
"data": {
"name": "张三",
"phone": "138****1234", ← 脱敏了
"address": "北京市朝阳区..."
}
}
3. 用户看到输入框是空的(因为 phone 是带星号的)
4. 用户感到困惑,这个体验极差!
❌ 错误做法:
后端直接通过 V1 返回真实手机号 13800138000
黑客截获响应,拿到了用户隐私
✅ 正确做法:
后端检测这是一个"编辑"请求(比如带了 editMode=true 参数)
自动切换到 V2 加密响应
{
"code": 200,
"data": "Xy7zAb9cDe..." ← 加密了真实号码
}
前端收到密文,自动解密得到 13800138000
用户看到完整的号码,可以正常编辑
场景 2:客户端内部业务逻辑需要
这些数据不是给人看的,是给代码用的:
- JWT Token 或 SessionKey:APP 内跳转时要用
- 第三方支付的调起参数:支付宝 sign 串
- 加密狗的 License Key:客户端验证许可证
- 订单加密编号:用于分享时不暴露真实订单 ID
风险举例:
后端通过 V1 返回 sessionKey="abc123xyz789"
黑客截获 -> 伪造用户身份进行后续操作
用户账户被盗用
对策:必须走 V2 加密响应
场景 3:虚拟资产与高价值数据
一旦泄露就是直接的经济损失:
- 加密货币钱包私钥:点击"导出私钥"时显示
- 购物卡卡密:充值卡购买后显示密码
- 代金券码:生成的兑换码全文
- 合同原文:未脱敏的法律文件
示例:
用户充值了一张 500 元的游戏充值卡,卡密是 1234567890abcdef
❌ 错误:
{
"code": 200,
"data": {
"cardNo": "1234567890abcdef", ← V1 明文
"amount": 500
}
}
黑客截获 -> 卡密泄露 -> 黑客先充值使用这张卡
✅ 正确:
{
"code": 200,
"data": "Xy7zAb9cDe..." ← V2 加密
}
前端解密后显示给用户
场景速查表
┌──────────────────────────────────────────────────────────────────┐
│ 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 密钥对。
- 签名密钥对 (
sign_):- 前端持有私钥:用于对请求参数签名,证明“我是我”。
- 后端持有公钥:用于验证签名,防止篡改。
- 数据密钥对 (
data_):- 前端持有公钥:用于加密敏感数据(如密码、金额)。
- 后端持有私钥:用于解密数据。
- 签名密钥对 (
-- 渠道基础数据信息
-- 不同平台(小程序、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 中。
{
"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 |
两种加密版本
密钥使用说明
核心流程与密钥使用说明
这是整个架构最难理解的部分。我们需要明确谁持有哪个钥匙以及数据如何流转。
密钥分布视图
| 密钥类型 | 密钥名称 | 持有方 | 用途 | 安全级别 |
|---|---|---|---|---|
| 签名私钥 | sign_secret_key |
前端 | 生成请求签名 (sign 字段) |
🔴 高危 (需混淆保护) |
| 签名公钥 | sign_public_key |
后端 | 验证请求签名 | 🟢 公开 |
| 数据公钥 | data_public_key |
前端 | 加密 businessBody |
🟢 公开 |
| 数据私钥 | data_secret_key |
后端 | 解密 businessBody |
🔴 绝密 (存服务器) |
Gateway 网关处理流程
当一个请求到达 Gateway 时,就像过安检一样,需要经过层层过滤:
- 提取 Code:识别请求来源(是 App 还是 PC?)。
- 加载密钥:利用 Code 从 Redis 缓存中读取该渠道的密钥配置。
- 解密 (V2):如果开启了加密,使用后端私钥解密
businessBody,还原成明文 JSON。 - 验签:将所有参数(含解密后的 Body)按规则排序拼接,使用后端公钥验证
sign是否正确。 - Token 校验:如果业务需要登录,解析 Header 中的 Token,获取 UserID。
- 转发:将解密、验证后的明文参数转发给下游微服务。
核心代码解析
渠道数据服务:多级缓存设计
为了保证网关的高性能,我们不能每次请求都查数据库。这里采用了 DB -> Redis 的缓存策略。
代码解读 ChannelDataService:
add(): 新增渠道时,既写入 MySQL,也同步写入 Redis。getChannelDataByCode(): 网关调用的高频接口。优先查 Redis -> 如果没有 -> 查 DB -> 回写 Redis。这是标准的 Cache-Aside 模式。
/**
* 渠道数据服务
* 添加渠道配置时同步缓存到 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
);
}
}
请求验证过滤器:网关的大脑
RequestValidationFilter 是网关最核心的组件,实现了
GlobalFilter接口,拦截所有请求。
逻辑拆解 doExecute 方法:
- 前置检查:判断是否开启了
noVerify(开发环境后门,生产环境必须关闭)。 - 加载配置:
channelDataService.getChannelDataByCode(code)。 - 解密阶段 (Step 4):
- 检查
encrypt头是否为 "v2"。 - 如果是,调用
RsaTool.decrypt使用后端私钥解密数据。
- 检查
- 验签阶段 (Step 5):
- 调用
RsaSignTool.verifyRsaSign256使用后端公钥验证签名。 - 注意:一旦验签失败,直接抛出异常,请求中止。
- 调用
- Token 处理 (Step 6):
- 非登录接口(如首页)也可携带 Token,网关会解析出 UserId 传给下游,用于个性化推荐。
- 强制登录接口若无 Token,直接拦截。
- 重构请求:将解密后的
businessBody作为新的 Body 传递给下游服务,下游服务完全感知不到加解密的过程,只需处理明文。
/**
* 请求验证过滤器
* 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<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return doFilter(exchange, chain);
}
/**
* 核心处理逻辑
*/
public Mono<Void> 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<String, String> 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<String, String> doExecute(String originalBody, ServerWebExchange exchange) {
log.info("current thread verify: {}", Thread.currentThread().getName());
ServerHttpRequest request = exchange.getRequest();
String requestBody = originalBody;
Map<String, String> 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<String, String> 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) 对响应数据进行加密。 - 前端收到后,使用自己的私钥解密展示。
/**
* 响应验证过滤器
* 对 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...)才能保证前后端生成的签名一致。
/**
* 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<String, String> 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<String, String> map, String publicKey) {
// 1. 取出签名
String sign = map.get("sign");
// 2. 移除 sign 字段后重新构建签名内容
Map<String, String> 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<String, String> 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。既防篡改,又防偷窥。
/**
* 模拟前端的签名和加密过程
* 用于测试和理解流程
*/
public class RsaSignTool {
public static void main(String[] args) {
// V1 版本:仅签名
parameterTransferV1();
// V2 版本:加密 + 签名
parameterTransferV2();
}
/**
* V1 版本:仅签名
*/
public static void parameterTransferV1() {
Map<String, String> 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<String, Object> businessMap = new HashMap<>(8);
businessMap.put("id", "1111");
businessMap.put("sleepTime", 10);
String businessBody = JSON.toJSONString(businessMap);
// 2. 先对明文业务参数签名
Map<String, String> 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<String, String> requestBody = new HashMap<>();
requestBody.put("code", "app");
requestBody.put("businessBody", encryptedBody); // 加密后的密文
requestBody.put("sign", sign);
System.out.println("最终请求: " + JSON.toJSONString(requestBody));
}
}
配置化开关
为了方便开发调试,系统设计了完善的开关:
- 前端:
.env文件控制是否开启加密,开发时可关闭以方便抓包调试。 - 后端:
GatewayProperty配置了白名单(如支付回调不需要验签)和 Token 校验路径。
前端配置切换
// .env.development 文件
// 签名开关:0-不签名普通调用,1-签名调用
VITE_SIGN_FLAG = 1
// 加密版本:v1-仅签名,v2-加密+签名
VITE_ENCRYPT_VERSION = v2
后端配置
@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;
}
完整数据流转图
核心总结
安全机制对比
| 版本 | 防篡改 | 防窃听 | 响应加密 | 适用场景 |
|---|---|---|---|---|
| 无验证 | ❌ | ❌ | ❌ | 开发测试 |
| V1 签名 | ✅ | ❌ | ❌ | 一般安全要求 |
| V2 加密+签名 | ✅ | ✅ | ✅ | 高安全要求(金融交易) |
密钥管理
| 密钥类型 | 前端持有 | 后端持有 | 用途 |
|---|---|---|---|
| 签名私钥 | ✅ | ❌ | 前端签名 |
| 签名公钥 | ❌ | ✅ | 后端验签 |
| 数据公钥 | ✅ | ✅ | 加密数据 |
| 数据私钥 | ✅ | ✅ | 解密数据 |
设计亮点
| 设计点 | 说明 |
|---|---|
| 多渠道隔离 | 不同平台使用不同密钥,互不影响 |
| 双重验证 | 签名验证 + Token 验证 |
| 渐进式加密 | V1/V2 可配置切换 |
| 密钥缓存 | Redis 缓存渠道配置,提高效率 |
| 统一网关处理 | 业务服务无需关心加解密 |
| 链路追踪 | 生成 traceId 贯穿全链路 |
设计思想: 接口安全不是单一技术能解决的,而是纵深防御。
- HTTPS:防止通道监听(物理层防护)。
- RSA 签名:防止参数被改(数据完整性)。
- RSA 加密:防止数据被看(数据机密性)。
- Redis 缓存:用空间换时间,解决 RSA 计算慢带来的性能损耗。
💡 设计思想:接口安全不是单一技术能解决的,需要多层防护。签名保证数据完整性和来源可信,加密保证数据机密性,Token 保证用户身份合法,API 防刷保证服务可用性。这些机制在网关层统一处理,对业务服务完全透明,既保证了安全性,又不增加业务开发的复杂度。
💬 评论