红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【BudgetMatch】加固支付 RPC 授权与支付宝回调业务校验

背景

目前支付宝通知验签后,未继续校验 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 Outbox

RSA2验签已经在异步通知的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-rpc

  • issue_at:token的签发时间

  • not_beore:在什么时间之前不能使用,当前设置为签发时刻,因此token生成后会立即生效

  • expired_at:token的过期时间,当前设置为Minute,表示一分钟后过期,当收到过期token后会返回invalid_token

  • jti(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时还会再执行:

  1. 锁住订单行,避免并发确认;
  2. 检查订单存在;
  3. 检查订单用户与 payment 用户一致;
  4. 检查订单实付金额与 payment 金额一致;
  5. 检查订单当前是待支付;
  6. 检查交易号是否与已有确认结果冲突;
  7. 更新订单状态、支付时间和交易号;
  8. 在同一个事务里写入 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 事务回滚。