如何安全限制付费用户访问私有内容?
结论
不要在付款成功跳转页里直接把用户标记为”已付款”,也不要相信浏览器传回的订单状态。正确的处理流程是:
- 用户登录后,服务端创建订单和支付会话。
- 支付平台通过服务端回调(Webhook)通知付款结果。
- 服务端验证回调签名,并向支付平台核实事件或订单状态。
- 服务端在数据库中记录订单和访问权益。
- 用户访问私有内容时,服务端检查其权益是否有效。
Google Checkout 已经停止服务,不应再用于新项目。开发时应选择仍在维护,并提供服务端 API 和签名 Webhook 的支付平台。具体使用哪一家,需要结合业务所在地、币种和合规要求决定。
WordPress 和 Drupal 都能承载付费内容,但应搭配仍在积极维护的会员、订阅或电商模块。无论选择哪种 CMS,安全校验都必须由服务端完成,不能只靠隐藏链接或前端脚本。
不要只保存一个 paid 布尔值
在用户表中增加一个 paid = true 字段,看起来简单,但很快就会遇到这些问题:
- 同一名用户可能有多笔订单。
- 付款可能仍在处理中,尚未最终成功。
- 订单可能被退款、撤销或拒付。
- 权益可能有有效期。
- 不同商品可能对应不同内容。
- Webhook 可能重复发送,也可能乱序到达。
更合适的做法是分别保存订单、支付事件和访问权益。例如:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
provider VARCHAR(50) NOT NULL,
provider_order_id VARCHAR(255),
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(30) NOT NULL,
created_at TIMESTAMP NOT NULL,
paid_at TIMESTAMP NULL,
UNIQUE (provider, provider_order_id)
);
CREATE TABLE payment_events (
provider VARCHAR(50) NOT NULL,
provider_event_id VARCHAR(255) NOT NULL,
received_at TIMESTAMP NOT NULL,
PRIMARY KEY (provider, provider_event_id)
);
CREATE TABLE entitlements (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
resource_key VARCHAR(255) NOT NULL,
order_id BIGINT NOT NULL,
status VARCHAR(30) NOT NULL,
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NULL,
UNIQUE (user_id, resource_key, order_id)
);
金额应以最小货币单位的整数保存,例如人民币的分或美元的美分,不要使用浮点数。
安全的支付流程
1. 先要求用户登录
创建支付订单前,服务端需要确定当前用户的身份。然后生成内部订单,把用户、商品、金额和币种写入数据库。
不要接受浏览器提交的最终金额。浏览器只需要提交商品标识,实际价格应由服务端根据商品数据计算。
app.post("/api/checkout", requireLogin, async (req, res) => {
const product = await db.products.findById(req.body.productId);
if (!product || !product.active) {
return res.status(404).json({ error: "Product not found" });
}
const order = await db.orders.create({
userId: req.user.id,
productId: product.id,
amountMinor: product.priceMinor,
currency: product.currency,
provider: "payment_provider",
status: "pending"
});
const session = await paymentProvider.createCheckoutSession({
amountMinor: order.amountMinor,
currency: order.currency,
reference: String(order.id),
successUrl: "https://example.com/payment/return",
cancelUrl: "https://example.com/payment/cancel"
});
await db.orders.setProviderOrderId(order.id, session.id);
res.json({ checkoutUrl: session.url });
});
paymentProvider.createCheckoutSession() 只是示意接口。真实参数、签名方式和返回字段必须以所选支付平台的当前官方文档为准。
2. 通过 Webhook 确认付款结果
用户付款后,支付平台通常会向商户配置的 HTTPS 地址发送事件。服务端应以这类通知作为更新订单状态的主要依据。
付款成功后的浏览器跳转不能充当付款凭证。用户可以伪造请求、修改参数,也可以绕过支付页面,直接访问回调地址。
3. 验证 Webhook 的真实性
处理 Webhook 时,至少需要完成这些检查:
- 使用原始 HTTP 请求体验证签名。
- 检查签名时间戳,减少重放风险。
- 校验事件类型。
- 校验支付平台订单号和内部订单号。
- 核对金额、币种和商户账户。
- 确认当前支付状态允许交付。
- 使用事件 ID 做幂等处理。
- 必要时调用支付平台的服务端 API,再查询一次订单。
下面是一段与具体平台无关的结构示例:
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = paymentProvider.verifyWebhook({
rawBody: req.body,
signature: req.get("Payment-Signature")
});
} catch {
return res.status(400).send("Invalid signature");
}
try {
await db.transaction(async (tx) => {
const alreadyProcessed = await tx.paymentEvents.exists(
"payment_provider",
event.id
);
if (alreadyProcessed) {
return;
}
if (event.type === "payment.succeeded") {
const payment = event.data;
const order = await tx.orders.lockById(payment.reference);
if (!order) {
throw new Error("Unknown order");
}
if (
payment.providerOrderId !== order.providerOrderId ||
payment.amountMinor !== order.amountMinor ||
payment.currency !== order.currency
) {
throw new Error("Payment details do not match");
}
await tx.orders.markPaid(order.id, payment.paidAt);
await tx.entitlements.grant({
userId: order.userId,
resourceKey: `product:${order.productId}`,
orderId: order.id,
status: "active",
validFrom: payment.paidAt,
validUntil: null
});
}
await tx.paymentEvents.insert({
provider: "payment_provider",
providerEventId: event.id,
receivedAt: new Date()
});
});
return res.sendStatus(200);
} catch (error) {
console.error("Webhook processing failed", error);
return res.sendStatus(500);
}
}
);
签名验证通常要求使用尚未经过 JSON 解析的原始请求体。如果 Web 框架提前转换了请求体,签名校验可能会失败。中间件的具体顺序应按照支付平台 SDK 的要求配置。
如何保护私有页面
权益检查必须在服务端完成。用户每次请求私有内容时,服务端都要根据当前登录用户查询有效权益:
async function requireEntitlement(resourceKey) {
return async function (req, res, next) {
if (!req.user) {
return res.status(401).send("Login required");
}
const allowed = await db.entitlements.hasActiveEntitlement({
userId: req.user.id,
resourceKey,
now: new Date()
});
if (!allowed) {
return res.status(403).send("Payment required");
}
next();
};
}
app.get(
"/private/course-1",
requireLogin,
await requireEntitlement("product:course-1"),
async (req, res) => {
res.render("private-course-1");
}
);
实际项目通常不能在注册路由时使用 await requireEntitlement(...),除非这个工厂函数确实返回 Promise。更常见的写法是同步返回一个异步中间件:
function requireEntitlement(resourceKey) {
return async function (req, res, next) {
if (!req.user) {
return res.status(401).send("Login required");
}
const allowed = await db.entitlements.hasActiveEntitlement({
userId: req.user.id,
resourceKey,
now: new Date()
});
if (!allowed) {
return res.status(403).send("Payment required");
}
next();
};
}
app.get(
"/private/course-1",
requireLogin,
requireEntitlement("product:course-1"),
showPrivateCourse
);
如果私有内容是 PDF、视频或压缩包,不要把文件直接放进可公开访问的静态目录。可以采用以下方式:
- 应用程序完成鉴权后,再读取并返回文件。
- 把文件存入私有对象存储,鉴权后生成短时有效的签名 URL。
- 使用支持鉴权的 CDN,并把链接有效期设置得较短。
签名 URL 可以减少链接长期传播的风险,但无法阻止已经取得内容的用户复制、录屏或重新发布。
退款、拒付和订阅也要处理
系统不能只处理付款成功事件。根据业务模式,还需要考虑:
payment.succeeded:激活权益。payment.failed:保留或关闭待付款订单。refund.succeeded:按照退款规则撤销权益。chargeback或争议事件:暂停权益,交由人工复核。subscription.renewed:延长有效期。subscription.canceled:决定立即终止权益,还是允许用户使用到当前周期结束。
不同支付平台使用的事件名称并不相同,不能直接照搬这些示例。应按照平台文档,明确建立”支付状态、订单状态、权益状态”之间的映射。
Webhook 可能延迟、重复或乱序到达。处理逻辑必须具备幂等性,并尽量根据支付对象的最终状态更新记录,不能假定事件只会按顺序到达一次。
WordPress 和 Drupal 是否合适
WordPress 和 Drupal 都能用于付费内容,但适合的场景有所不同。
WordPress 更适合内容发布、课程和会员文章等常见业务,搭建速度通常较快。可以使用成熟的会员或电商扩展管理订单、订阅、角色及受限内容。
Drupal 的权限、角色和内容实体模型更灵活,适合权限规则复杂、内容类型较多或需要深度定制的系统。不过,它的开发和维护成本通常也更高。
选择插件或模块时,不能只看它是否提供”购买”按钮,还要确认它是否支持:
- 服务端 Webhook 验签。
- 重复事件的幂等处理。
- 退款和订阅状态同步。
- 根据商品授予具体内容的访问权限。
- 后台对账和异常订单处理。
- 持续维护及安全更新。
- 与当前 CMS、PHP 和支付平台 API 兼容。
CMS 中普通的”订阅者”角色不一定等同于付费权益。如果所有付费用户获得的权限完全相同,而且永久有效,可以使用角色模型。如果存在多个商品、套餐或到期时间,则应单独维护会员资格或权益记录。
需要特别避免的做法
- 根据
success_url的访问结果标记付款成功。 - 相信浏览器提交的金额、商品名称或支付状态。
- 只用 CSS 或 JavaScript 隐藏私有内容。
- 只隐藏页面链接,却允许用户直接访问页面或文件地址。
- 收到 Webhook 后不验证签名。
- 不校验金额、币种、订单号和商户账户。
- 让同一个 Webhook 重复增加权益或延长有效期。
- 把支付平台密钥放进前端代码或公开仓库。
- 在日志中完整记录银行卡信息、密钥或敏感个人资料。
- 假定付款成功后不会再发生退款或拒付。
支付平台应作为付款事实的来源,本地订单负责保存业务记录,entitlements 则用于控制访问权限。将三者分开后,付款确认、权限授予、退款撤权和审计的职责会更清晰,也能减少伪造或重复回调导致私有内容泄露的风险。
备注:内容仅供参考。
