如何防止已授权 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"
}
正确的付款状态更新流程
安全的付款流程如下:
- 用户登录后,客户端取得当前
auth.uid。 - 客户端请求后端创建支付订单。
- 后端将订单与
auth.uid、金额和商品关联。 - 用户在支付平台完成付款。
- 支付平台通过 webhook 将付款结果发送给后端。
- 后端验证 webhook 签名、订单金额、币种、订单状态和订单归属。
- 验证通过后,后端使用 Firebase Admin SDK 更新
billing/{uid}/status。 - 数据库规则根据该状态决定用户能否读取付费内容。
下面是示意代码。具体 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();
}
原则很简单:客户端可以提交付款请求,但不能自行声明已经付款。只有在支付结果通过验证后,可信后端才能授予付费权限。
备注:内容仅供参考。
