PAYATHON 2026

Firebase Payments/Credits 系统的安全最佳实践

支付阿杰

明确结论

当前设计不安全。只要客户端对包含 validCredits 的用户节点拥有写权限,这个字段就不能作为可信余额。攻击者无需修改 App,直接通过 REST API、修改版客户端或脚本,就可以尝试调高余额、重复扣款,甚至伪造消费记录。

更安全的设计是:

  • 客户端只能读取余额,不能直接修改。
  • 购买入账和 credit 消耗都由可信后端处理。
  • 后端通过数据库事务检查余额、扣减余额并写入账本。
  • 保留当前余额供快速查询,同时用不可变账本进行审计和对账。
  • 支付是否成功,必须以支付平台的服务端通知或后端验证结果为准,不能相信客户端传来的状态。

展示余额时,不建议扫描全部交易再求和。账本需要保留,但日常查询应直接读取由事务维护的余额。

为什么客户端写权限不可信

Firebase Security Rules 可以约束写入后的数据,例如要求余额不能小于零,或者新余额必须等于原余额减一。但这些规则本身无法构成一套完整、安全的支付系统。

例如,下面的规则可以阻止客户端增加余额:

{
  "rules": {
    "users": {
      "$uid": {
        "validCredits": {
          ".read": "$uid === auth.uid",
          ".write": "$uid === auth.uid",
          ".validate": "newData.isNumber() && newData.val() === data.val() - 1 && newData.val() >= 0"
        }
      }
    }
  }
}

这条规则规定每次写入只能减少一个 credit,但仍有一些明显的问题:

  • 客户端可以在没有完成实际业务操作时扣减 credit。
  • 如果消费记录需要单独写入,余额和记录可能只有一项写入成功。
  • 恶意客户端可以反复创建、删除或修改自己的消费记录。
  • 订单状态、退款、撤销和幂等处理较为复杂,很难只依靠规则正确表达。
  • 即使是合法客户端,也可能因重试、网络中断或并发请求造成重复消费。

因此,这类规则只能作为附加防线。真正的余额变更不应交给客户端处理。

推荐的数据结构

可以将普通用户资料与资金相关数据分开存储:

users/{uid}
  name
  address

creditAccounts/{uid}
  balance
  updatedAt

creditLedger/{entryId}
  uid
  delta
  type
  referenceId
  createdAt
  metadata

paymentEvents/{providerEventId}
  uid
  amount
  credits
  status
  createdAt

各部分的用途如下:

  • users:保存姓名、地址等普通资料,可以允许用户修改。
  • creditAccounts:保存当前余额,只允许可信后端修改。
  • creditLedger:记录每一次增加或扣减,通常不允许客户端修改或删除。
  • paymentEvents:保存支付平台事件,并通过事件 ID 防止重复入账。
  • referenceId:保存订单 ID、消费请求 ID 或退款 ID,用于幂等处理。

账本中的 delta 可以使用正数或负数:

购买 5 个 credits:delta = 5
消费 1 个 credit:delta = -1
退款或撤销:根据业务规则写入相反方向的调整记录

balance 是根据账本维护的缓存结果,账本则用于审计。正常请求直接读取 balance;发生故障、争议或数据迁移时,可以根据账本重新计算并核对余额。

使用后端事务扣减余额

以下示例使用 Cloud Firestore 和 Node.js Admin SDK。不同 Firebase 数据库的 API 写法有所不同,但原则一致:余额检查、余额扣减和账本写入必须在同一个原子事务中完成。

import { onCall, HttpsError } from "firebase-functions/v2/https";
import { getFirestore, FieldValue } from "firebase-admin/firestore";

const db = getFirestore();

export const spendCredit = onCall(async (request) => {
  const uid = request.auth?.uid;

  if (!uid) {
    throw new HttpsError("unauthenticated", "Authentication required.");
  }

  const usageId = request.data?.usageId;
  const purpose = request.data?.purpose;

  if (typeof usageId !== "string" || usageId.length === 0) {
    throw new HttpsError("invalid-argument", "usageId is required.");
  }

  const accountRef = db.doc(`creditAccounts/${uid}`);
  const ledgerRef = db.doc(`creditLedger/${uid}_${usageId}`);

  await db.runTransaction(async (transaction) => {
    const [accountSnapshot, ledgerSnapshot] = await Promise.all([
      transaction.get(accountRef),
      transaction.get(ledgerRef)
    ]);

    // 相同请求已经成功处理,重试时直接返回,避免重复扣减。
    if (ledgerSnapshot.exists) {
      return;
    }

    if (!accountSnapshot.exists) {
      throw new HttpsError("failed-precondition", "Credit account not found.");
    }

    const balance = accountSnapshot.get("balance");

    if (!Number.isInteger(balance) || balance < 1) {
      throw new HttpsError("failed-precondition", "Insufficient credits.");
    }

    transaction.update(accountRef, {
      balance: balance - 1,
      updatedAt: FieldValue.serverTimestamp()
    });

    transaction.create(ledgerRef, {
      uid,
      delta: -1,
      type: "spend",
      referenceId: usageId,
      purpose: typeof purpose === "string" ? purpose : null,
      createdAt: FieldValue.serverTimestamp()
    });
  });

  return { success: true };
});

这里的 usageId 是消费请求的幂等键。即使客户端因为超时而重复提交同一个请求,固定的账本路径也可以避免重复扣减。

如果扣减 credit 是为了获取某种付费结果,还应在同一个受控流程中创建相应的业务任务。不能先让客户端扣款,再依赖客户端自行完成后续操作。

支付入账必须以服务端结果为准

购买流程通常可以这样设计:

  1. 后端创建支付订单,并记录订单与 uid 的对应关系。
  2. 客户端完成支付界面中的操作。
  3. 支付平台向后端发送 webhook,或者由后端主动向支付平台验证订单。
  4. 后端核对金额、币种、商品、订单状态和用户归属。
  5. 后端通过事务写入支付事件和账本记录,同时增加余额。
  6. 对支付平台事件 ID 或订单 ID 建立唯一约束,防止重复回调造成重复入账。

不要直接接受下面这类客户端请求:

{
  "creditsPurchased": 100,
  "paymentSucceeded": true
}

这些字段都可以被客户端修改。客户端提交的信息只能用于定位交易,最终结果必须通过支付服务商的可信接口验证。

Firestore Security Rules 示例

客户端可以读取自己的账户和账本,但不能修改:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    function isOwner(uid) {
      return signedIn() && request.auth.uid == uid;
    }

    match /users/{uid} {
      allow read: if isOwner(uid);

      allow create, update: if isOwner(uid)
        && !request.resource.data.diff(resource.data).affectedKeys()
          .hasAny(['validCredits', 'balance']);

      allow delete: if false;
    }

    match /creditAccounts/{uid} {
      allow read: if isOwner(uid);
      allow write: if false;
    }

    match /creditLedger/{entryId} {
      allow read: if signedIn()
        && resource.data.uid == request.auth.uid;

      allow create, update, delete: if false;
    }

    match /paymentEvents/{eventId} {
      allow read, write: if false;
    }
  }
}

如果客户端可以新建用户文档,还需要限制创建时允许提交的字段。例如:

allow create: if isOwner(uid)
  && request.resource.data.keys().hasOnly([
    'name',
    'address'
  ]);

否则,即使更新操作禁止修改敏感字段,客户端仍可能在创建文档时夹带这些字段。

Cloud Functions 或自建服务器通常使用 Admin SDK。Admin SDK 不受客户端 Security Rules 约束,所以后端仍需自行完成身份验证、参数校验、权限判断和幂等检查。

是否应该每次对全部交易求和

一般不应该。随着账本记录增加,每次全量读取并求和会产生这些问题:

  • 查询时间和读取费用会随账本增长。
  • 离线缓存或分页可能导致计算结果不完整。
  • 并发写入期间,客户端更难获得一致的余额。
  • 如果客户端无权读取支付记录,也无法自行完成可信计算。

更合适的计算模型是:

当前余额 = 已确认收入合计 - 已确认消费合计 + 调整项合计

后端每次写入账本时,应在同一个事务中更新 balance。账本记录每笔变动的来龙去脉,balance 用于快速读取。

如果业务对账要求较高,还可以由后端定期汇总账本,并与 creditAccounts.balance 比较。发现差异后应报警并调查,不能让客户端自动覆盖余额。

还需要注意的事项

  • 金额应使用最小货币单位的整数,例如分,以免产生浮点数误差。
  • credit 数量也应使用整数,并校验上下限。
  • 不允许修改或删除已有账本。需要更正时,应追加一条冲正记录。
  • 购买、消费和退款都要有唯一的业务 ID。
  • 消费接口需要速率限制,也可以考虑使用 App Check,但 App Check 不能替代身份验证和后端授权。
  • 不要将支付密钥、webhook secret 或服务账号凭据放入客户端。
  • 需要提前定义退款和拒付发生后如何回收 credits,以及余额不足时如何处理。
  • 关键接口应记录审计信息,但日志中不能保存银行卡数据或不必要的个人信息。

边界可以概括为:用户可以请求”消费一个 credit”,但不能直接写入”我的余额减一”;用户可以发起购买,但不能自行声明”支付已经成功”。所有影响价值的状态变化,都应由可信后端验证并以原子方式执行。

备注:内容仅供参考。