在 Google Firebase 中集成 payment gateway
结论
Firebase Function URL 公开可访问本身没有问题。真正要避免的是把支付页面跳转到 return URL 视为支付成功的证明。
支付流程可以分为三部分:
- 客户端调用受身份验证保护的 Firebase Function,由服务端创建支付订单。
- 支付网关通过服务器之间的
webhook/回调通知支付结果。Firebase Function 验证签名后再更新订单。 return URL只负责把用户带回 Web 页面或 App,并展示订单状态,不能直接把订单改为”已支付”。
如果支付网关不提供签名回调,Firebase Function 收到通知后必须使用服务端密钥查询网关的订单 API,核实支付状态。
为什么不能信任 return URL
return URL 只是浏览器或 App 的跳转目标。攻击者可以:
- 手动访问这个 URL;
- 修改查询参数,例如伪造
status=success; - 重放之前的成功请求;
- 在支付尚未完成时关闭或拦截页面;
- 支付完成后不再跳回应用。
所以下面的逻辑并不安全:
/payment-return?orderId=123&status=success
// 错误示例:信任客户端传来的状态
await updateOrder(orderId, { paid: true });
即使这个 URL 很难猜,也不能用它确认款项已经到账。支付结果必须来自经过密码学验证的 webhook,或由服务端向支付网关 API 查询得出。
推荐的支付流程
1. 由服务端创建支付订单
客户端只提交商品或购物车标识。商品价格、币种、折扣和最终金额都要由服务端重新计算,不能采用客户端传来的最终金额。
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
const paymentApiKey = defineSecret("PAYMENT_API_KEY");
export const createPayment = onCall(
{
secrets: [paymentApiKey],
enforceAppCheck: true,
},
async (request) => {
if (!request.auth) {
throw new HttpsError("unauthenticated", "Authentication required");
}
const { cartId } = request.data;
// 从 Firestore 读取购物车,并在服务端计算价格。
const order = await createPendingOrder({
uid: request.auth.uid,
cartId,
});
// 以下接口名称仅为示意,应替换为支付网关的官方 SDK/API。
const payment = await paymentProvider.createPayment({
merchantOrderId: order.id,
amount: order.amount,
currency: order.currency,
returnUrl: `https://shop.example.com/payment/return?orderId=${order.id}`,
webhookUrl: "https://REGION-PROJECT_ID.cloudfunctions.net/paymentWebhook",
});
await saveProviderPaymentId(order.id, payment.id);
return {
orderId: order.id,
checkoutUrl: payment.checkoutUrl,
};
}
);
PAYMENT_API_KEY、webhook secret 等凭据应保存在 Secret Manager 或 Firebase Functions secrets 中,不能写入 Web 或移动端代码。
enforceAppCheck 能减少非官方客户端的滥用,但无法代替 Firebase Authentication、服务端金额校验和支付签名验证。
2. 用 webhook 接收支付结果
支付网关通常会向商户提供的 HTTPS 地址发送异步通知。这个地址可以是公开的 Firebase HTTP Function,因为支付网关需要访问它。
安全性取决于签名验证:
import { onRequest } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
const webhookSecret = defineSecret("PAYMENT_WEBHOOK_SECRET");
export const paymentWebhook = onRequest(
{ secrets: [webhookSecret] },
async (req, res) => {
if (req.method !== "POST") {
res.status(405).send("Method Not Allowed");
return;
}
try {
const signature = req.header("x-payment-signature");
if (!signature) {
res.status(400).send("Missing signature");
return;
}
// 必须使用网关官方要求的原始请求体、签名算法和 SDK。
// 不要自行假设签名字段或 JSON 序列化规则。
const event = paymentProvider.verifyWebhook({
rawBody: req.rawBody,
signature,
secret: webhookSecret.value(),
});
if (event.type === "payment.succeeded") {
await markOrderPaidIdempotently({
providerEventId: event.id,
providerPaymentId: event.paymentId,
merchantOrderId: event.merchantOrderId,
paidAmount: event.amount,
currency: event.currency,
});
}
res.status(200).send("OK");
} catch (error) {
res.status(400).send("Invalid webhook");
}
}
);
不同支付网关的签名规范差别很大。有些使用 HMAC,有些使用公钥签名,还有些要求把时间戳和原始请求体纳入计算。实现时必须遵循支付网关的官方 SDK 或文档,不能直接把示例中的 header 名称用于生产环境。
如果网关没有可靠的 webhook 签名机制,可以在回调到达后主动核验:
const payment = await paymentProvider.getPayment(providerPaymentId);
if (
payment.status === "paid" &&
payment.merchantOrderId === expectedOrderId &&
payment.amount === expectedAmount &&
payment.currency === expectedCurrency
) {
await markOrderPaidIdempotently(/* ... */);
}
查询所用的 API 密钥只能保存在服务端。
3. 将 return URL 用于页面跳转
Web 应用可以把 return URL 设为自己的支付结果页面。移动端可以根据支付网关提供的能力,使用 Universal Links、App Links 或自定义 URL scheme。
返回页面只读取订单状态:
const order = await getOrder(orderId);
switch (order.status) {
case "paid":
showSuccess();
break;
case "failed":
showFailure();
break;
default:
showProcessing();
}
用户跳回页面时,webhook 可能还没有到达。页面需要支持显示”处理中”,并通过 Firestore 实时监听、轮询受保护的订单查询接口,或者提示用户稍后刷新。
更新订单时必须校验的内容
签名正确,只能说明事件通过了签名验证,并不表示其中的业务数据符合预期。处理成功事件时还要检查:
merchantOrderId是否对应本系统中的订单;- 支付网关的 payment ID 是否与创建订单时保存的一致;
- 实付金额和币种是否完全匹配;
- 订单是否属于正确的商户账户或环境;
- 支付状态是否为最终成功状态,而不是
pending、authorized等中间状态; - 事件是否已经处理过;
- 已取消或已退款的订单是否允许进行这次状态转换。
金额最好使用最小货币单位的整数,例如分,不要使用浮点数:
const expectedAmount = 1999; // 19.99,单位为分
防止重复通知和并发问题
支付网关通常会重试 webhook,因此同一个事件可能被发送多次。处理逻辑必须具备幂等性。可以把网关事件 ID 保存到 Firestore,并在同一个事务中完成检查和更新。
await db.runTransaction(async (tx) => {
const eventRef = db.collection("paymentEvents").doc(providerEventId);
const orderRef = db.collection("orders").doc(merchantOrderId);
const [eventSnapshot, orderSnapshot] = await Promise.all([
tx.get(eventRef),
tx.get(orderRef),
]);
if (eventSnapshot.exists) {
return;
}
if (!orderSnapshot.exists) {
throw new Error("Order not found");
}
const order = orderSnapshot.data();
if (
order?.amount !== paidAmount ||
order?.currency !== currency ||
order?.providerPaymentId !== providerPaymentId
) {
throw new Error("Payment data mismatch");
}
tx.create(eventRef, {
receivedAt: new Date(),
type: "payment.succeeded",
});
if (order.status !== "paid") {
tx.update(orderRef, {
status: "paid",
paidAt: new Date(),
});
}
});
生产代码可以使用 Firestore 服务端时间戳,避免依赖函数实例的本地时间。
其他注意事项
- 不要把支付网关私钥、API secret 或 webhook secret 放入 Firebase Remote Config、Web bundle 或移动端安装包。
- 不要依靠 CORS 保护 webhook。CORS 是浏览器机制,无法阻止攻击者直接发送 HTTP 请求。
- 不要要求 webhook 携带 Firebase 用户登录令牌。支付网关不是 Firebase 用户,通常无法生成这种令牌。
- IP allowlist 可以作为附加措施,但不能代替签名验证,而且网关的出口 IP 可能变化。
- webhook 应尽快返回成功响应。耗时的发货、通知或积分处理可以交给队列或其他后台任务。
- 测试环境和生产环境应分别配置 API 密钥、webhook secret 与订单数据,避免混用。
- 记录网关事件 ID、订单 ID、校验结果和状态变化,但不要记录银行卡信息、完整密钥或其他敏感支付数据。
- 退款、拒付和支付撤销也应通过验证后的 webhook 更新,不能只处理支付成功事件。
最终原则很简单:客户端跳转只决定用户看到什么,经过服务端验证的支付事件才决定订单状态。
备注:内容仅供参考。
