PAYATHON 2026

HOLLER:通过 API 与 Titanium Studio 实现安全支付

支付老李

明确结论

不要让客户端提交能够直接决定扣款对象的 user_id。更安全的设计是:

  1. 用户完成登录后,客户端获得短期访问令牌。
  2. 客户端通过 HTTPS 发送支付请求,并携带访问令牌。
  3. 服务器验证令牌,从认证结果中确定用户身份,不信任客户端传来的 user_id。
  4. 服务器只使用支付服务商保存的卡片令牌处理支付,不保存或传输原始卡号。
  5. 每次支付都要绑定订单、金额、币种、幂等键和有效期,并根据风险情况要求用户再次确认。
  6. 支付的最终状态以后端收到的支付服务商回调为准,不能只看客户端响应。

基本原则是:客户端只能表示”当前登录用户想支付这个订单”,不能决定”从哪个用户的卡里扣多少钱”。

推荐的请求流程

比较稳妥的流程如下:

用户登录
  ↓
服务器签发短期 access token
  ↓
客户端请求创建订单
  ↓
服务器计算金额、币种和商品内容
  ↓
客户端确认支付,并提交 order_id + Idempotency-Key
  ↓
服务器从 access token 识别用户
  ↓
服务器查询该用户绑定的支付方式 token
  ↓
服务器调用支付服务商 API
  ↓
支付服务商通过 webhook 通知最终结果
  ↓
服务器更新订单状态

不要把支付接口设计成这样:

POST /api/pay
Content-Type: application/json

{
  "user_id": "123",
  "amount": 99.00
}

这里的 user_id 和 amount 都可能被篡改。攻击者修改请求后,就可能尝试使用其他用户的付款方式,或者更改支付金额。

更合适的接口是:

POST /api/orders/order_abc/pay
Authorization: Bearer <access_token>
Idempotency-Key: 7f713170-8f31-4bd5-a72d-a0548ea784b2
Content-Type: application/json

{}

服务器从访问令牌中识别当前用户,从数据库里的订单记录获取金额和币种。这些敏感的决策参数不应由客户端提交。

身份认证不能只靠 user id

user_id 只是身份标识,不是身份凭证。即使它很长,看起来也足够随机,也不能当作密码使用。

用户登录成功后,服务器可以建立会话,也可以签发短期访问令牌。令牌至少应满足这些要求:

  • 生命周期较短;
  • 可以验证签名、签发者、接收方和过期时间;
  • 能关联到唯一的用户和会话;
  • 用户注销、修改密码或账号被冻结后可以失效;
  • 不会泄露到 URL、日志或错误信息中。

如果使用 JWT,服务器不能只解码其中的 sub 字段,还要验证签名以及 exp、iss、aud 等约束。JWT 并非必需。服务器端随机会话令牌通常也能满足需求,而且更容易主动撤销。

客户端可以把令牌放在请求头中:

var client = Ti.Network.createHTTPClient({
    timeout: 15000,
    onload: function () {
        var response = JSON.parse(this.responseText);
        Ti.API.info("Payment status: " + response.status);
    },
    onerror: function (event) {
        Ti.API.error("Payment request failed: " + event.error);
    }
});

client.open("POST", "https://api.example.com/v1/orders/order_abc/pay");
client.setRequestHeader("Authorization", "Bearer " + accessToken);
client.setRequestHeader("Content-Type", "application/json");
client.setRequestHeader("Idempotency-Key", idempotencyKey);
client.send(JSON.stringify({}));

示例中的 accessToken 应通过安全的登录流程获得。idempotencyKey 应是为每次支付操作生成的高随机唯一值。Titanium API 是否可用取决于项目使用的 Titanium SDK 版本,应以对应版本的文档为准。

服务器必须执行授权检查

认证解决的是”请求者是谁”,授权检查解决的是”这个人能不能支付这笔订单”。

服务器至少要验证:

  • 访问令牌有效;
  • 订单确实属于当前用户;
  • 订单当前允许支付;
  • 金额和币种来自服务器保存的数据;
  • 订单尚未支付或取消;
  • 当前用户的付款方式仍然有效;
  • 请求没有超过频率、金额或风险限制;
  • 必要时,用户刚刚完成了密码、生物识别或其他再次认证。

下面是简化的 Node.js/Express 伪代码:

app.post("/v1/orders/:orderId/pay", authenticate, async (req, res) => {
    const userId = req.auth.userId;
    const orderId = req.params.orderId;
    const idempotencyKey = req.get("Idempotency-Key");

    if (!idempotencyKey) {
        return res.status(400).json({
            error: "IDEMPOTENCY_KEY_REQUIRED"
        });
    }

    const order = await orders.findById(orderId);

    if (!order || order.userId !== userId) {
        return res.status(404).json({
            error: "ORDER_NOT_FOUND"
        });
    }

    if (order.status === "paid") {
        return res.json({
            orderId: order.id,
            status: "paid"
        });
    }

    if (order.status !== "pending") {
        return res.status(409).json({
            error: "ORDER_NOT_PAYABLE"
        });
    }

    const paymentMethod = await paymentMethods.findDefaultForUser(userId);

    if (!paymentMethod) {
        return res.status(409).json({
            error: "PAYMENT_METHOD_REQUIRED"
        });
    }

    const result = await paymentProvider.charge({
        customerToken: paymentMethod.customerToken,
        paymentMethodToken: paymentMethod.paymentMethodToken,
        amountMinor: order.amountMinor,
        currency: order.currency,
        reference: order.id,
        idempotencyKey
    });

    await payments.recordProviderResult(order.id, result);

    return res.status(202).json({
        orderId: order.id,
        status: "processing"
    });
});

paymentProvider.charge() 代表所选支付服务商提供的服务端接口,并非某个通用 SDK 中的真实方法。实际字段、状态和错误处理方式要按照支付服务商的文档实现。

金额最好用最小货币单位的整数表示,避免浮点数计算误差。例如,1099 表示 10.99:

const amountMinor = 1099;
const currency = "USD";

不要在应用或自己的数据库中保存银行卡敏感数据

更安全的方式是让符合相关支付安全要求的支付服务商采集银行卡信息,再返回无法直接还原卡号的客户令牌或付款方式令牌。

服务器通常只需保存:

{
  "userId": "user_123",
  "providerCustomerToken": "customer_token_from_provider",
  "paymentMethodToken": "payment_method_token_from_provider",
  "brand": "visa",
  "last4": "4242"
}

不要自行保存:

  • 完整银行卡号;
  • 磁道数据;
  • PIN;
  • 银行卡安全码,例如 CVV、CVC 或 CID;
  • 可以直接恢复上述数据的调试日志或网络抓包。

支付令牌同样属于敏感信息,需要加密存储并限制读取权限,而且只能由后端使用。不要把支付服务商的私钥或后端 API 密钥放进 Titanium 应用。移动应用可以被反编译,嵌入其中的长期密钥最终可能被提取。

实际系统还要根据业务地区、支付服务商和数据处理方式,评估 PCI DSS、隐私保护及其他合规要求。令牌化可以缩小风险范围,但不代表系统自然就符合相关要求。

防止重复扣款和重放攻击

移动网络可能超时,用户也可能连续点击支付按钮。如果后端每收到一次请求就发起一次扣款,很容易造成重复收费。

因此,支付接口应支持幂等性:

同一用户 + 同一订单 + 同一个 Idempotency-Key

相同组合只能生成一次支付操作。服务器需要保存幂等键及其对应结果。如果支付服务商支持幂等键,还应把同一个键传给服务商。

同时需要采取这些措施:

  • 为待支付订单设置有效期;
  • 禁止已支付订单再次创建扣款;
  • 对同一用户、设备、IP 和付款方式实施合理限流;
  • 记录异常频率、失败次数和金额变化;
  • 高风险操作要求再次认证;
  • 使用服务端时间判断有效期,不依赖手机时间。

客户端签名不能代替用户认证。尤其不要在所有安装包中内置同一个 HMAC 密钥,然后把”请求带有签名”当作可信依据。攻击者可以从应用中提取这个密钥。设备证明、证书固定等措施可以用于纵深防御,但不能取代后端的认证、授权和风控。

正确处理支付结果

支付 API 返回”请求已接受”,不一定表示资金已经最终到账。支付可能处于以下状态:

  • processing
  • requires_action
  • succeeded
  • failed
  • canceled

服务器应验证支付服务商 webhook 的签名,并根据回调事件或主动查询结果更新订单。客户端随后查询自己的订单状态:

GET /api/orders/order_abc
Authorization: Bearer <access_token>

响应可以保持简单,不要向客户端暴露支付服务商的内部错误、密钥或完整风控信息:

{
  "orderId": "order_abc",
  "status": "paid"
}

更新数据库状态时还要使用事务或条件更新,避免两个并发请求同时把同一订单送去扣款。

Titanium 客户端还需要注意什么

访问令牌和刷新令牌应存放在 iOS Keychain 等安全存储中,不要写入普通配置文件、明文数据库或日志。Titanium 项目通常需要使用适配当前 SDK 的 Keychain 模块,具体模块和调用方式要根据项目版本确定。

客户端还应注意:

  • 只连接使用有效证书的 HTTPS 地址;
  • 不忽略 TLS 证书错误;
  • 不在日志中输出令牌、银行卡信息或完整的服务器响应;
  • 支付按钮提交后暂时禁用,但后端仍要实施幂等控制;
  • 应用进入后台时隐藏敏感界面;
  • 令牌过期后重新认证,不要无限期复用;
  • 不要把”越狱检测”当作安全边界。

如果业务希望用户通过 Face ID 或 Touch ID 确认支付,生物识别只能证明设备持有人完成了本地确认。服务器仍需验证访问令牌、订单归属和支付权限。

额外的业务与平台限制

如果应用销售的是在 iPhone 应用内消费的数字内容、功能或订阅,可能需要使用 Apple 的应用内购买体系,不能直接对已存档的银行卡扣款。实体商品和线下服务等场景通常适用其他规则。平台政策可能调整,上线前应核对当时有效的 App Store Review Guidelines,以及业务所在地区的支付规定。

所有安全边界都应放在服务器端。服务器负责识别用户、确定订单和金额、选择已授权的支付令牌、调用支付服务商并确认最终结果。即使客户端被完全控制,攻击者也不应能通过修改 user_id、金额或重复发送请求来扣取其他用户的钱。

备注:内容仅供参考。