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

  • ~4.45K 字
  1. 1. 背景
  2. 2. 实现
    1. 2.1. 增加收款方配置
    2. 2.2. 精确支付金额
    3. 2.3. 验签后的业务校验
    4. 2.4. 防止伪造订单和跨用户查询
    5. 2.5. 服务身份认证
      1. 2.5.1. Claims结构
      2. 2.5.2. 验证过程
    6. 2.6. confirmPayment内部
  3. 3. 验收标准
  4. 4. 测试

背景

目前支付宝通知验签后,未继续校验 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 事务回滚。
赞助喵
非常感谢您的喜欢!
赞助喵