如何仅为特定 route 禁用 Laravel CSRF 验证?
结论
可以只为支付回调路由关闭 CSRF 验证,但必须改用支付平台提供的签名、MAC、证书或服务端查询接口来验证请求是否真实。
Laravel 通常不会对 GET 请求执行 CSRF 验证。如果示例路由确实使用 GET,却仍然出现 TokenMismatchException,先检查支付平台实际发送的是否为 POST,并确认异常是不是由其他请求触发的。
配置 CSRF 例外
Laravel 版本不同,配置方式也有所不同。
较新的 Laravel 项目
如果项目通过 bootstrap/app.php 配置中间件,可以在其中添加例外路径:
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'payment/ok',
'payment/fail',
]);
})
路径通常不需要以 / 开头。
使用 VerifyCsrfToken 中间件的项目
在 app/Http/Middleware/VerifyCsrfToken.php 中设置 $except:
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
protected $except = [
'payment/ok',
'payment/fail',
];
}
这里也支持通配符。不过,支付回调最好逐一列出,以免无意中扩大豁免范围:
protected $except = [
'payment/callback/*',
];
正确声明回调路由
路由使用哪种 HTTP 方法,应以第三方支付平台实际发送的请求为准。如果平台发送 POST:
use App\Http\Controllers\TransactionsController;
use Illuminate\Support\Facades\Route;
Route::post('/payment/ok', [TransactionsController::class, 'ok']);
Route::post('/payment/fail', [TransactionsController::class, 'fail']);
如果支付平台确实使用 GET,则可以保留:
Route::get('/payment/ok', [TransactionsController::class, 'ok']);
Route::get('/payment/fail', [TransactionsController::class, 'fail']);
不要依赖 $_REQUEST,应通过 Laravel 的 Request 对象读取参数:
use Illuminate\Http\Request;
public function ok(Request $request)
{
$transId = $request->input('trans_id');
if (!$transId) {
return response('Missing transaction ID', 400);
}
return response($transId);
}
input() 会同时读取查询字符串和请求体。如果只接受查询字符串参数,可以改用:
$transId = $request->query('trans_id');
CSRF 豁免不等于回调可信
CSRF token 用于保护浏览器会话,不适合验证第三方服务器回调。关闭 CSRF 后,任何人都可以尝试访问该 URL,所以不能只凭 trans_id 就把订单标记为支付成功。
较稳妥的处理流程如下:
- 按照支付平台文档验证回调签名。
- 核对商户编号、订单编号、金额、币种和支付状态。
- 必要时调用支付平台的服务端 API,再次查询交易结果。
- 确认交易对应的本地订单存在,并且尚未处理。
- 在数据库事务中更新订单状态。
- 保证接口具有幂等性,避免重复回调造成重复入账或发货。
- 按平台要求返回确认响应,否则平台可能继续重试。
下面是示意代码。具体字段和签名算法必须以支付平台文档为准:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function ok(Request $request)
{
if (!$this->paymentGateway->verifySignature($request->all())) {
return response('Invalid signature', 403);
}
$transId = $request->input('trans_id');
if (!$transId) {
return response('Missing transaction ID', 400);
}
$transaction = $this->paymentGateway->queryTransaction($transId);
if (!$transaction || $transaction->status !== 'paid') {
return response('Transaction not paid', 400);
}
DB::transaction(function () use ($transaction) {
$order = Order::where('payment_reference', $transaction->id)
->lockForUpdate()
->firstOrFail();
if ($order->is_paid) {
return;
}
if ($order->amount !== $transaction->amount) {
throw new RuntimeException('Payment amount mismatch');
}
$order->update([
'is_paid' => true,
'paid_at' => now(),
]);
});
return response('OK', 200);
}
区分“浏览器跳转”和“服务端通知”
支付平台通常会提供两类地址:
- 浏览器返回地址:支付完成后将用户重定向回来,用于展示结果。
- 服务端回调地址:由支付平台直接通知应用,用于确认交易并更新订单。
浏览器返回页面中的参数可能被用户修改,不能将其作为支付成功的唯一依据。订单状态应由经过签名验证的服务端回调或主动查询结果决定。返回页面只负责读取并展示本地订单的最新状态。
因此,只应为真正接收第三方通知的回调端点关闭 CSRF 验证,并按照支付平台的要求,为该端点配置独立的身份验证机制。
备注:内容仅供参考。