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 是为了获取某种付费结果,还应在同一个受控流程中创建相应的业务任务。不能先让客户端扣款,再依赖客户端自行完成后续操作。
支付入账必须以服务端结果为准
购买流程通常可以这样设计:
- 后端创建支付订单,并记录订单与
uid的对应关系。 - 客户端完成支付界面中的操作。
- 支付平台向后端发送 webhook,或者由后端主动向支付平台验证订单。
- 后端核对金额、币种、商品、订单状态和用户归属。
- 后端通过事务写入支付事件和账本记录,同时增加余额。
- 对支付平台事件 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”,但不能直接写入”我的余额减一”;用户可以发起购买,但不能自行声明”支付已经成功”。所有影响价值的状态变化,都应由可信后端验证并以原子方式执行。
备注:内容仅供参考。
