背景
目前支付宝通知验签后,未继续校验 app_id、seller_id 和 total_amount。支付 RPC 逻辑也主要信任请求中的 user_id/order_id,存在越权查询或伪造调用风险。
ConfirmPayment 当前默认只要求普通用户 JWT,没有限制为 payment 服务调用。
证据:
- services/rpc/payment/internal/logic/paymentservice/handle_notify_logic.go:43
- services/rpc/payment/main.go:37
- services/rpc/mall/internal/logic/orderservice/confirm_payment_logic.go:30
实现
这里我们需要实现的是“支付结果可信、用户不能越权、内部服务不能伪造”的安全闭环,解决了三个问题:
- 支付宝通知是真的吗,属于我们的应用和商户吗
- 发起或查询支付的用户,真的是订单所有者吗
- 修改订单为已支付的调用,真的是payment-rpc发出的吗
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
用户 JWT
│
├─ CreatePayment / QueryPayment
│ └─ 校验认证用户和订单/支付流水归属
│
支付宝
│
└─ HandleNotify
├─ RSA2 验签
├─ 校验 AppID、SellerID、金额和交易字段
└─ markPaid
├─ payment 流水更新为成功
└─ 短期服务 JWT
↓
mall-rpc ConfirmPayment
├─ 校验 payment-rpc 身份
├─ 校验订单、用户、金额、交易号
├─ 更新订单为已支付
└─ 写入 mall_order_paid OutboxRSA2验签已经在异步通知的logic中的DecodeNotify实现,最后会进入支付宝sdk的验签部分,它会使用ALIPAY_PUBLIC_KEY验签。我们需要实现验签成功后的业务校验
增加收款方配置
目前支付宝配置中缺少商户id相关配置,需要在Config部分,配置文件增加,并且要定义对应环境变量
修改Alipay.go中的结构体,以及payment的配置文件,添加alipay下的商户id
在env.example中增加环境变量,并传入DockerCompose
精确支付金额
alipay.go中的分元转换使用了float64,支付场景下最好移除浮点数,定义YuanToFen函数用于将通知里的total_amount精确解析为分,要注意规避以下情况:
- 小数位数过多
- 负数金额
- 科学计数法
- 空字符串
- 超出int64的金额
验签后的业务校验
为什么RSA2验签后还需要校验?
因为验签只能证明这份参数确实是支付宝发的,没有中途篡改,但是它不能证明我们之前提出的问题
若这一套系统被多个支付宝应用,只验签不检查AppID和SellerID,其他应用或商户的合法通知可能被错误处理
因此我们需要在handle_notify_logic.go中形成两层校验:
- 密码学验签:验证支付宝公钥,可以拦截伪造通知
- 业务字段验签:验证app,seller,tradeno,status,amount等字段是否正确,这部分需要一个校验函数实现
添加校验函数validateSuccessfulNotify校验已经通过签名认证的支付成功通知,传入notification,record和config,
比对:
AppId, SellerId, OutTradeNo和TradeNo是否一致TradeStatus必须是success或finished通过单位转换函数计算的金额(分为单位)是否和record中的金额相同
record中的
Status是否是支付状态中的success,且TradeNo需要相同
修改HanldeNotify,在验签后先验证通知是否属于本应用和收款方,以及支付ID是否为空
找到对应支付流水后,查询金额是否和noti中的金额匹配
最后在TradeStatus为success和finished的基础上,通过校验函数校验是否正确,只有全部校验通过后才能进入markPaid
防止伪造订单和跨用户查询
CreatePayment主要信任传入的in.UserId等,不足以阻止直接RPC调用,另一个用户B完全可以获得用户A的信息并传入A的订单
common.go新增authenticatedUserId,用于返回拦截器确认的用户身份
CreatePayment的逻辑需要改为:
- 从context读取当前用户,而非从请求读取
- 要求请求中的用户id和context中的相同
- 通过mallrpc查询订单
- 校验订单的真实归属、金额和状态
- 最终使用mall返回的用户和金额创建流水,不使用请求中的金额
QueryPayment 同样从context获取用户,找到支付流水后比较是否相同,返回notFound告诉用户支付存在,但不属于你
服务身份认证
我们的ConfirmPayment会直接把商城订单改为已支付,如果普通用户JWT可以调用它,攻击者可能会绕过支付宝直接伪造支付成功,而且当前common.go给payment-rpc临时签发了RoleUser的JWT,mall-rpc无法区分是真正的普通用户还是rpc假扮的普通用户
因此我们应该建立独立的服务认证infra/serviceauth
Claims结构
签发的token应该包含JWT的Claims,用于描述该token的来龙去脉:
service:调用方的服务身份,应该设为payment-rpc,mall-rpc验证后写入contex他,confirmPaymet再检查是否caller是否是payment服务身份,普通用户没有这个身份issuer:签发token方,当前设计中payment-rpc为自己生成服务token,因此签发者是payment-rpc,更复杂的服务中,可能是统一认证服务签发subject:token代表的身份,当前服务token是payment-rpc自己签发,代表自己audience:token允许给谁用,当前只允许payment-rpc签发给mall-rpc使用,token只能用于调用mall-rpcissue_at:token的签发时间not_beore:在什么时间之前不能使用,当前设置为签发时刻,因此token生成后会立即生效expired_at:token的过期时间,当前设置为Minute,表示一分钟后过期,当收到过期token后会返回invalid_tokenjti(JWT ID):token的唯一编号,每生成一枚token,就产生一个新的UUID,防止两次调用相同
验证过程
payment-rpc生成token后,通过gRPC
metadata发送Authorization: Bearer <服务JWT>
mall-rpc收到后依次检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
签名算法是不是 HS256
↓
签名能否用 PAYMENT_MALL_SERVICE_SECRET 验证
↓
service / iss / sub 是否都是 payment-rpc
↓
aud 是否包含 mall-rpc
↓
Token 是否已经生效
↓
Token 是否过期
↓
全部通过,将 service_name=payment-rpc 写入 context
↓
ConfirmPayment 再检查调用方身份这里服务有专门的签名密钥,和普通用户JWT的密钥分开,避免体系混用
生成token的函数位于common.go,内部调用infra中的生成函数
mall-rpc在全局认证拦截器中把confirmPayment登记为服务专用方法
confirmPayment内部
confirmPayment在开始会从context中获取服务调用方,并判断是否是payment-rpc的调用
另外,mall-rpc在confirmPayment时还会再执行:
- 锁住订单行,避免并发确认;
- 检查订单存在;
- 检查订单用户与 payment 用户一致;
- 检查订单实付金额与 payment 金额一致;
- 检查订单当前是待支付;
- 检查交易号是否与已有确认结果冲突;
- 更新订单状态、支付时间和交易号;
- 在同一个事务里写入 mall_order_paid Outbox。
若已被相同的out_trade_no和trade_no确认过,则直接返回幂等成功
验收标准
- 回调验签成功后,精确校验 app_id、收款方和订单金额;金额比较不能使用浮点数。
- trade_no、out_trade_no、交易状态均满足要求后才能标记成功。
- CreatePayment、QueryPayment 根据认证上下文校验资源归属,不能仅信任请求字段。
- 普通用户 JWT 无法直接调用 ConfirmPayment。
- 引入明确的服务身份认证机制,供 payment-rpc 调用 mall-rpc。
- 补充“签名正确但金额/AppID/收款方错误”和“跨用户查询”等测试。
测试
自动化单测覆盖了以下情形:
- 正常通知;
- AppID 错误;
- SellerID 错误;
- 金额错误;
- 金额精度错误;
- out_trade_no 错误;
- trade_no 为空;
- 等待支付状态;
- 已确认流水交易号冲突;
- CreatePayment 跨用户伪造;
- QueryPayment 跨用户查询;
- 普通用户 JWT 调用 ConfirmPayment;
- 错误服务、audience、密钥和过期 Token;
- payment-rpc 服务 Token;
- ConfirmPayment 事务回滚。