如何在付款后安全地允许下载 file.zip?
结论
不要直接向用户暴露 file.zip 的真实地址,也不要仅凭付款成功后的页面跳转开放下载。建议采用以下流程:
- 通过支付平台的服务端回调确认付款。
- 为订单创建随机下载令牌,并限制有效期和下载次数。
- 将文件存放在 Web 根目录之外。
- 下载时,由服务端校验订单、账号、令牌、有效期和下载次数。
- 校验通过后,再由应用程序或 Web 服务器返回文件。
这种方案能减少链接被猜中、长期共享或无限次下载的风险,但无法彻底阻止购买者下载后再次分发。对于价值较高的文件,还可以加入用户水印、许可证绑定或专用客户端验证。
推荐流程
1. 使用支付回调确认付款
不要相信浏览器传来的 paid=1、跳转参数或前端支付结果,因为用户可以自行构造这些请求。
支付平台应通过 Webhook 向服务器发送通知。服务器完成以下检查后,才能将订单标记为已支付:
- 回调签名是否合法;
- 支付状态是否成功;
- 商户订单号是否存在;
- 支付金额和币种是否与订单一致;
- 支付交易号是否已经处理;
- 回调是否属于当前商户。
支付平台可能重复发送同一通知,因此 Webhook 必须具备幂等性。
2. 创建不可预测的下载令牌
确认付款后,生成高强度随机令牌,例如:
$token = bin2hex(random_bytes(32));
不要用订单号、用户 ID、时间戳或这些值的简单哈希作为令牌,因为它们可能被猜中或枚举。
数据库中最好只保存令牌的哈希值:
$tokenHash = hash('sha256', $token);
数据表可以采用类似的结构:
CREATE TABLE download_grants (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
max_downloads INT NOT NULL DEFAULT 3,
download_count INT NOT NULL DEFAULT 0,
revoked_at DATETIME NULL,
created_at DATETIME NOT NULL
);
下载地址可以使用以下形式:
https://example.com/download.php?token=<随机令牌>
令牌的有效期不宜过长。具体时长取决于业务需求,可以是数小时或数天,没有适用于所有业务的固定值。
3. 将文件移出 Web 根目录
不要把文件放在能够直接访问的位置:
/var/www/html/files/file.zip
更合适的位置是:
/srv/private-downloads/file.zip
这样,用户无法绕过下载接口直接请求静态文件。
PHP 下载接口示例
以下示例假设用户必须登录,且数据库连接 $pdo 已启用异常模式。生产环境还需要补充完整的身份认证、日志和错误处理。
<?php
declare(strict_types=1);
session_start();
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
exit('请先登录');
}
$token = $_GET['token'] ?? '';
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
http_response_code(400);
exit('无效的下载链接');
}
$userId = (int) $_SESSION['user_id'];
$tokenHash = hash('sha256', $token);
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'SELECT dg.id, dg.download_count, dg.max_downloads
FROM download_grants dg
JOIN orders o ON o.id = dg.order_id
WHERE dg.token_hash = :token_hash
AND dg.user_id = :user_id
AND dg.revoked_at IS NULL
AND dg.expires_at > CURRENT_TIMESTAMP
AND o.payment_status = :payment_status
FOR UPDATE'
);
$stmt->execute([
'token_hash' => $tokenHash,
'user_id' => $userId,
'payment_status' => 'paid',
]);
$grant = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$grant || (int) $grant['download_count'] >= (int) $grant['max_downloads']) {
$pdo->rollBack();
http_response_code(403);
exit('下载链接无效、已过期或已达到次数限制');
}
$update = $pdo->prepare(
'UPDATE download_grants
SET download_count = download_count + 1
WHERE id = :id'
);
$update->execute(['id' => $grant['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
exit('暂时无法处理下载请求');
}
$file = '/srv/private-downloads/file.zip';
if (!is_file($file) || !is_readable($file)) {
http_response_code(404);
exit('文件不存在');
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="file.zip"');
header('Content-Length: ' . filesize($file));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
$handle = fopen($file, 'rb');
if ($handle === false) {
http_response_code(500);
exit('无法读取文件');
}
fpassthru($handle);
fclose($handle);
exit;
SELECT ... FOR UPDATE 和计数更新位于同一个事务中,可以防止用户通过并发请求绕过次数限制。不同数据库的事务和行锁行为可能有所区别,需要根据实际使用的数据库进行确认。
大文件的处理方式
由 PHP 直接读取大文件会占用应用服务器资源。使用 Nginx 时,可以在权限校验通过后返回 X-Accel-Redirect,由 Nginx 传输文件。
Nginx 配置示例:
location /protected-downloads/ {
internal;
alias /srv/private-downloads/;
}
PHP 校验通过后返回:
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="file.zip"');
header('X-Accel-Redirect: /protected-downloads/file.zip');
exit;
internal 会阻止外部用户直接访问 /protected-downloads/file.zip。如果使用 Apache、对象存储或 CDN,可以采用相应的内部转发或短期签名 URL 机制。
下载次数不宜简单限制为一次
一次性下载看起来更安全,却很容易影响正常用户,例如:
- 网络中断;
- 浏览器或下载器自动重试;
- 用户更换设备;
- 服务端开始响应后传输失败;
- 断点续传产生多个 HTTP 请求。
通常可以采用更实用的策略:
- 要求用户登录,并校验订单归属;
- 设置短期有效的下载令牌;
- 允许少量重试;
- 在订单页面提供“重新生成下载链接”功能;
- 发现异常后允许撤销令牌;
- 限制请求频率和并发数。
如果需要支持 Range 断点续传,不能把每个 HTTP 请求都算作一次完整下载。可以按下载会话记录,也可以采用较宽松的次数限制。
其他安全注意事项
- 全程使用 HTTPS,防止令牌在传输过程中泄露。
- 不要在访问日志、分析系统或第三方跳转参数中记录完整令牌。
- 为下载页面设置合适的
Referrer-Policy,减少令牌通过Referer泄露的可能。 - 发生退款或拒付后,应撤销对应的下载权限。
- 记录同一令牌的异常 IP、设备数量和下载频率,但不要只根据 IP 判断用户身份。
- 使用通用错误信息,避免攻击者据此判断订单号或令牌是否真实存在。
- 为私有文件设置最小文件系统权限,只允许负责传输文件的服务账号读取。
- 对于高价值文件,可以按订单动态加入用户标识或不可见水印,以便追踪泄露来源。
下载页面本身不是权限凭证,支付后的重定向也不能证明用户已经付款。服务器必须在每次下载请求中重新验证授权。
备注:内容仅供参考。
