Laravel 使用 Perfect Money 支付回调后 Session 丢失
结论
Perfect Money 并没有删除 Laravel 的 Session。问题通常出在以下几个环节:
- Perfect Money 通过跨站
POST请求访问 success/cancel URL。请求中没有 Laravel 的 CSRF Token,因此 Laravel 返回419 Page Expired。 - 排除 CSRF 检查后,浏览器仍可能受 Session Cookie 的
SameSite=Lax策略限制,不会在跨站POST中携带该 Cookie。Laravel 无法识别原来的登录会话,便会把用户视为未登录。 - Perfect Money 在
step1.asp中保留的支付数据,属于它自己域名下的 Session 或 Cookie,与应用的 Laravel Session 相互独立。
处理时应将“支付通知”“浏览器返回”和“用户 Session”分开:支付结果以 STATUS_URL 的服务端通知为准;success/cancel 页面尽量使用顶层 GET 返回;所有支付回调都不能依赖用户 Session。
为什么会出现 419
Laravel 默认会对 web 中间件组中的 POST、PUT、PATCH 和 DELETE 请求执行 CSRF 校验。
Perfect Money 发来的请求无法携带 Laravel 页面生成的 _token。如果 success/cancel URL 接受的是 POST,就会触发:
419 | PAGE EXPIRED
将回调路由加入 CSRF 排除列表,只能解决第三方请求没有 CSRF Token 的问题,无法保证浏览器会同时携带 Session Cookie。
为什么排除 CSRF 后看起来 Session 丢失
Laravel 的 Session ID 通常保存在 Cookie 中,而现代浏览器会对 Cookie 应用 SameSite 限制。
假设支付流程如下:
your-app.example
-> POST 到 perfectmoney.com
-> Perfect Money 跨站 POST 回 your-app.example/payment/cancel
当 Laravel Session Cookie 使用常见的 SameSite=Lax 时,浏览器通常不会在跨站 POST 中发送该 Cookie。Laravel 收到 cancel 请求后,可能得到以下结果:
auth()->check(); // false
session()->get('order_id'); // null
这并不是 Perfect Money 删除了 Session,而是当前请求没有携带原来的 Session Cookie。有时 Laravel 还会为该请求创建一个新 Session,并在响应中写入新的 Session Cookie,于是看起来更像是“旧 Session 被清空”。
推荐的路由设计
至少应将 URL 分为三类:
use App\Http\Controllers\PerfectMoneyController;
use Illuminate\Support\Facades\Route;
// Perfect Money 服务器通知,不依赖登录状态和浏览器 Session
Route::post('/payments/perfect-money/status', [
PerfectMoneyController::class,
'status',
])->name('payments.perfect-money.status');
// 用户浏览器返回页面,优先配置成 GET
Route::get('/payments/perfect-money/success', [
PerfectMoneyController::class,
'success',
])->name('payments.perfect-money.success');
Route::get('/payments/perfect-money/cancel', [
PerfectMoneyController::class,
'cancel',
])->name('payments.perfect-money.cancel');
不要为这些返回路由添加 auth 中间件。浏览器没有携带 Session Cookie 时,auth 会立即将用户重定向到登录页面。
如果 Perfect Money 只能通过 POST 将浏览器送回应用,可以让路由同时接受 POST,但仍不能依赖 Session:
Route::match(['get', 'post'], '/payments/perfect-money/cancel', [
PerfectMoneyController::class,
'cancel',
])->name('payments.perfect-money.cancel');
PAYMENT_URL_METHOD 和 NOPAYMENT_URL_METHOD 的具体取值,应以当前 Perfect Money SCI 文档为准。如果网关支持链接式或 GET 返回,应优先选择这种方式,避免通过跨站 POST 恢复用户会话。
CSRF 应该如何排除
只有接收 Perfect Money POST 请求的路由需要排除 CSRF。不要因为支付回调问题而关闭整个站点的 CSRF 保护。
较早版本的 Laravel 项目可以在 VerifyCsrfToken.php 中配置:
protected $except = [
'payments/perfect-money/status',
'payments/perfect-money/success',
'payments/perfect-money/cancel',
];
如果 success/cancel 已经改为只接受 GET,它们本身不需要 CSRF Token,此时只需排除通知地址:
protected $except = [
'payments/perfect-money/status',
];
Laravel 11 及以后版本,如果项目采用新的应用配置方式,可以在 bootstrap/app.php 中设置:
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'payments/perfect-money/status',
]);
})
应使用哪种写法,取决于项目的 Laravel 版本和目录结构。
支付回调不要读取用户 Session
创建订单时,先将订单写入数据库,再把订单编号放入 Perfect Money 的 PAYMENT_ID,或放入官方支持的附加字段。
$order = Order::create([
'user_id' => auth()->id(),
'amount' => '49.90',
'currency' => 'USD',
'status' => 'pending',
]);
支付表单示意:
<form action="https://perfectmoney.com/api/step1.asp" method="POST">
<input type="hidden" name="PAYEE_ACCOUNT" value="{{ config('services.perfect_money.account') }}">
<input type="hidden" name="PAYEE_NAME" value="{{ config('app.name') }}">
<input type="hidden" name="PAYMENT_ID" value="{{ $order->id }}">
<input type="hidden" name="PAYMENT_AMOUNT" value="{{ $order->amount }}">
<input type="hidden" name="PAYMENT_UNITS" value="{{ $order->currency }}">
<input type="hidden" name="STATUS_URL" value="{{ route('payments.perfect-money.status') }}">
<input type="hidden" name="PAYMENT_URL" value="{{ route('payments.perfect-money.success') }}">
<input type="hidden" name="NOPAYMENT_URL" value="{{ route('payments.perfect-money.cancel') }}">
<button type="submit">Pay with Perfect Money</button>
</form>
收到回调后,通过 PAYMENT_ID 查询订单:
public function status(Request $request)
{
$order = Order::findOrFail($request->input('PAYMENT_ID'));
// 验证 Perfect Money 签名、收款账号、金额和币种。
// 验证通过后才能更新订单。
}
不要通过 Session 关联订单:
$orderId = session('current_order_id');
支付通知可能直接来自 Perfect Money 的服务器,并不经过用户浏览器,因此不会携带用户的 Session Cookie。
必须验证回调真实性
排除 CSRF 后,仍然不能直接信任回调。CSRF Token 用于验证本站的浏览器表单,不适合验证支付平台发来的请求。
Perfect Money SCI 常见的校验方式是根据回调字段和账户的 alternate passphrase 生成 V2_HASH,然后与请求中的 V2_HASH 比较。示意代码如下:
private function validPerfectMoneyHash(Request $request): bool
{
$alternatePassphraseHash = strtoupper(
md5(config('services.perfect_money.alternate_passphrase'))
);
$source = implode(':', [
$request->input('PAYMENT_ID'),
$request->input('PAYEE_ACCOUNT'),
$request->input('PAYMENT_AMOUNT'),
$request->input('PAYMENT_UNITS'),
$request->input('PAYMENT_BATCH_NUM'),
$request->input('PAYER_ACCOUNT'),
$request->input('TIMESTAMPGMT'),
$alternatePassphraseHash,
]);
$expected = strtoupper(md5($source));
$received = strtoupper((string) $request->input('V2_HASH'));
return hash_equals($expected, $received);
}
字段顺序和签名规则必须与当前 Perfect Money SCI 文档核对,不能只根据浏览器提交的数据判断支付成功。签名验证通过后,还要检查:
if ($request->input('PAYEE_ACCOUNT') !== config('services.perfect_money.account')) {
abort(400, 'Invalid payee account');
}
if ((string) $request->input('PAYMENT_AMOUNT') !== (string) $order->amount) {
abort(400, 'Invalid payment amount');
}
if ($request->input('PAYMENT_UNITS') !== $order->currency) {
abort(400, 'Invalid payment currency');
}
还需要保存并检查 PAYMENT_BATCH_NUM,避免重复处理同一个支付通知。订单状态更新也必须具备幂等性。
success 页面不能直接把订单标记为已支付
用户可以手动访问或伪造浏览器返回 URL,因此下面的处理方式并不安全:
public function success(Request $request)
{
Order::whereKey($request->input('PAYMENT_ID'))
->update(['status' => 'paid']);
}
success 页面只应读取并展示数据库中的订单状态:
public function success(Request $request)
{
$order = Order::findOrFail($request->input('PAYMENT_ID'));
return view('payments.result', [
'order' => $order,
]);
}
只有通过签名验证的 STATUS_URL 通知处理器,才能将订单状态更新为 paid。如果服务端通知可能晚于浏览器返回,success 页面可以显示“支付结果确认中”,并轮询应用自己的订单查询接口。
是否可以设置 SameSite=None
如果业务必须在跨站 POST 返回时恢复 Laravel Session,可以考虑以下配置:
// config/session.php
'secure' => true,
'same_site' => 'none',
有些项目也会通过环境变量配置:
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=none
修改后需要清理配置缓存:
php artisan config:clear
不过,这并不是支付集成的首选方案,原因如下:
SameSite=None必须与 HTTPS 和SecureCookie 配合使用。- 部分浏览器或隐私设置仍可能限制跨站 Cookie。
- 全局放宽 Session Cookie 的跨站限制会扩大安全面。
- 服务端支付通知始终无法获得用户浏览器中的 Session。
更可靠的做法是让返回页面使用 GET,通过数据库和订单编号定位订单,并根据经过签名验证的通知确认支付结果。
还需要检查域名和 HTTPS
如果改用 GET 后仍然无法恢复登录状态,应检查 success/cancel URL 是否更换了主机名或协议,例如:
https://example.com
https://www.example.com
http://example.com
这些地址对 Cookie 而言可能不属于同一作用域。需要重点检查:
APP_URL=https://example.com
SESSION_DOMAIN=example.com
SESSION_SECURE_COOKIE=true
支付表单中不要混用 www 和非 www 域名,也不要让返回地址从 HTTPS 变成 HTTP。修改 Session 配置后,应清除配置缓存并重新登录测试。
关于 Perfect Money 页面保留支付数据
访问 perfectmoney.com/api/step1.asp 时仍能看到此前提交的金额和收款账号,说明 Perfect Money 很可能在自己的域名下保存了临时流程状态。至于它使用服务器 Session 还是 Cookie,仅凭页面表现无法确定,这是 Perfect Money 自身的实现细节。
无论 Perfect Money 使用哪种机制,都无法直接清除应用域名下的 Laravel Cookie。浏览器的同源规则和 Cookie 域限制不允许 perfectmoney.com 修改 example.com 的 Session Cookie。
因此,这两个现象彼此独立:
- Perfect Money 仍然显示原来的支付数据,说明它自己的流程状态还在。
- Laravel 返回登录页面,说明跨站返回请求没有携带原来的 Laravel Session Cookie,或者返回地址使用了不同的域名或协议。
最终的集成方式应当是:由 STATUS_URL 独立验证通知并更新订单;success/cancel URL 只负责展示结果或导航;相关路由不依赖登录 Session;浏览器返回时优先使用 GET。
备注:内容仅供参考。
