Files
portal/app/app/Http/Controllers/Api/PaymentWebhookController.php
T
Дмитрий 3b142f9375
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix(billing-security): хардненинг webhook ЮKassa + чистка admin-auth комментариев
Webhook (PaymentWebhookController): строгий матч gatewayPaymentId===paymentId
(confused-deputy), проверка валюты RUB (WebhookVerifyResult.currency), IP-allowlist
services.yookassa.webhook_ip_allowlist (fail-open при пустом). web.php: убраны
устаревшие «MVP без auth» комментарии — saas-admin зона fail-closed (nginx-basic
+ M-1 REMOTE_USER allowlist, проверено на проде). +3 теста, 11/11 зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:15:48 +03:00

120 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\IpUtils;
/**
* Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant),
* URL под маской api/webhook/* → CSRF-exempt (bootstrap/app.php).
*
* Подлинность: НЕ доверяем телу webhook — по object.id делаем server-to-server
* сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
*
* RLS: webhook вне tenant-сессии. Поиск платежа cross-tenant — через
* BYPASSRLS-соединение pgsql_supplier (как джобы, Plan 3/4). Зачисление —
* под app.current_tenant_id (SET LOCAL внутри транзакции, как BalancePreflightSweepJob).
*
* Идемпотентность: атомарный claim pending→success (UPDATE ... WHERE status='pending').
* Повторный webhook → claimed=0 → no-op, 200 OK без двойного зачисления.
*
* Зачисление денег делегируется BillingTopupService (lockForUpdate + append-only ledger).
*/
class PaymentWebhookController extends Controller
{
public function __construct(
private readonly PaymentGatewayDriver $driver,
private readonly BillingTopupService $topupService,
) {}
public function receive(Request $request): JsonResponse
{
// Defense-in-depth: IP-allowlist ЮKassa. Fail-open при пустом списке —
// не ломаем легитимный поток; на проде заполнить YOOKASSA_WEBHOOK_IPS
// опубликованными ЮKassa подсетями, чтобы аноним не дёргал endpoint.
$allowlist = array_values(array_filter((array) config('services.yookassa.webhook_ip_allowlist', [])));
if ($allowlist !== [] && ! IpUtils::checkIp((string) $request->ip(), $allowlist)) {
return response()->json(['status' => 'ignored'], 200);
}
$paymentId = (string) $request->input('object.id', '');
if ($paymentId === '') {
return response()->json(['status' => 'ignored'], 200);
}
// Cross-tenant поиск платежа под BYPASSRLS-ролью (tenant ещё неизвестен).
$tx = DB::connection('pgsql_supplier')->table('saas_transactions')
->where('gateway_payment_id', $paymentId)
->first();
if ($tx === null) {
return response()->json(['status' => 'unknown'], 200);
}
$gateway = $this->gatewayFor($tx);
// Server-to-server сверка — источник правды о статусе.
$verify = $this->driver->verifyPayment($gateway, $paymentId);
if (! $verify->isSucceeded()) {
return response()->json(['status' => 'not_paid'], 200);
}
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
if ($verify->gatewayPaymentId !== $paymentId) {
return response()->json(['status' => 'ignored'], 200);
}
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
if ($verify->currency !== 'RUB') {
return response()->json(['status' => 'currency_mismatch'], 200);
}
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
return response()->json(['status' => 'amount_mismatch'], 200);
}
DB::transaction(function () use ($tx, $verify) {
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
$claimed = SaasTransaction::where('id', $tx->id)
->where('status', SaasTransaction::STATUS_PENDING)
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
if ($claimed === 0) {
return; // идемпотентный no-op
}
$balanceTx = $this->topupService->topup(
(int) $tx->tenant_id, (string) $tx->amount_rub, null
);
SaasTransaction::where('id', $tx->id)->update([
'balance_rub_after' => $balanceTx->balance_rub_after,
'payment_method' => $verify->paymentMethod,
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
]);
});
return response()->json(['status' => 'ok'], 200);
}
private function gatewayFor(object $tx): PaymentGateway
{
return $tx->gateway_id !== null
? PaymentGateway::findOrFail($tx->gateway_id)
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
}
}