PAYATHON 2026

如何防止已授权 Firebase 用户修改自己的 Payment Status

支付小周

结论

客户端不能写入 Payment Status。Firebase Authentication 只能确认”用户是谁”,无法证明”用户已经付款”。付款状态必须由可信后端根据支付平台的服务端通知更新,浏览器端只能读取。

用户节点应以 Firebase Authentication 的 auth.uid 为键。用户可修改的资料和只能由服务端控制的付款状态需要分开存储。

推荐的数据结构

profiles
  └── uid
      └── displayName: "User Name"

billing
  └── uid
      ├── status: "paid"
      ├── providerCustomerId: "..."
      └── updatedAt: 1234567890

paidContent
  └── ...

如果 billing/{uid}/status 不存在,或者它的值不是 paid,就按未付款处理。客户端无需创建初始的 "No" 状态。

也不用额外保存 Google ID。auth.uid 已经是 Firebase Authentication 为当前账号提供的唯一标识,规则可以直接对其进行验证。

Realtime Database Security Rules

以下规则允许用户读写自己的基本资料,但禁止所有客户端修改付款状态:

{
  "rules": {
    ".read": false,
    ".write": false,

    "profiles": {
      "$uid": {
        ".read": "auth != null && auth.uid === $uid",
        ".write": "auth != null && auth.uid === $uid",

        "displayName": {
          ".validate": "newData.isString() && newData.val().length > 0 && newData.val().length <= 100"
        },

        "$other": {
          ".validate": false
        }
      }
    },

    "billing": {
      "$uid": {
        ".read": "auth != null && auth.uid === $uid",
        ".write": false
      }
    },

    "paidContent": {
      ".read": "auth != null && root.child('billing').child(auth.uid).child('status').val() === 'paid'",
      ".write": false
    }
  }
}

这套规则需要注意以下几点:

  • 根节点默认拒绝所有读写,避免原规则中的 ".read": "true" 公开整个数据库。
  • 用户只能访问与 auth.uid 匹配的 profiles/{uid}。
  • 无论用户是否通过 Google 登录,客户端都不能写入 billing。
  • 规则直接保护付费数据,不能只靠 AngularJS 页面控制内容是否显示。
  • $other 阻止用户在资料节点下添加未定义的字段。

Firebase 规则采用级联授权。一旦父节点授予 .write 权限,通常无法再通过子节点的 ".write": false 撤销。因此,不能像下面这样为整个 user 节点授权:

"user": {
  ".write": "auth != null"
}

正确的付款状态更新流程

安全的付款流程如下:

  1. 用户登录后,客户端取得当前 auth.uid。
  2. 客户端请求后端创建支付订单。
  3. 后端将订单与 auth.uid、金额和商品关联。
  4. 用户在支付平台完成付款。
  5. 支付平台通过 webhook 将付款结果发送给后端。
  6. 后端验证 webhook 签名、订单金额、币种、订单状态和订单归属。
  7. 验证通过后,后端使用 Firebase Admin SDK 更新 billing/{uid}/status。
  8. 数据库规则根据该状态决定用户能否读取付费内容。

下面是示意代码。具体 API 取决于支付平台和后端运行环境:

// 仅运行在可信服务端,不得放入 AngularJS 前端
const admin = require("firebase-admin");

admin.initializeApp();

async function handleVerifiedPayment(paymentEvent) {
  // paymentEvent 必须已经完成签名、金额、币种和订单归属验证
  const uid = paymentEvent.metadata.firebaseUid;

  await admin.database().ref(`billing/${uid}`).update({
    status: "paid",
    providerCustomerId: paymentEvent.customerId,
    updatedAt: admin.database.ServerValue.TIMESTAMP
  });
}

Firebase Admin SDK 使用服务账号运行,不受客户端 Security Rules 约束。即使客户端规则将 billing 设置为 ".write": false,后端仍可更新其中的数据。

服务账号密钥只能保存在服务端或受支持的托管环境中,绝不能写入 AngularJS 代码、公开仓库或浏览器配置。

不要信任浏览器返回的”支付成功”

下面的写法并不安全:

paymentProvider.onSuccess(function () {
  firebase.database()
    .ref(`billing/${currentUser.uid}/status`)
    .set("paid");
});

用户可以修改浏览器中的代码和请求。攻击者甚至不必操作正常页面,直接通过控制台、localhost、REST API 或自制脚本就能发出相同的写入请求。

付款结果只能以支付平台发送给后端并通过签名验证的 webhook 为准。支付事件还需要进行幂等处理,防止同一事件被重复提交。

页面限制不能代替数据权限

AngularJS 路由守卫可以改善用户体验,但不能充当安全边界。用户既可以修改前端代码,也可以直接访问 URL。

必须真正限制的是:

  • 付费数据的数据库读取权限;
  • 后端 API;
  • 文件下载地址;
  • 只有付费用户才能执行的服务端操作。

例如,后端接口也需要重新验证 Firebase ID token 和付款状态:

async function requirePaidUser(req, res, next) {
  const idToken = getBearerToken(req);
  const decodedToken = await admin.auth().verifyIdToken(idToken);
  const uid = decodedToken.uid;

  const snapshot = await admin
    .database()
    .ref(`billing/${uid}/status`)
    .once("value");

  if (snapshot.val() !== "paid") {
    return res.status(403).json({ error: "Payment required" });
  }

  req.uid = uid;
  next();
}

原则很简单:客户端可以提交付款请求,但不能自行声明已经付款。只有在支付结果通过验证后,可信后端才能授予付费权限。

备注:内容仅供参考。