From b75a677d12361a03dadbdf108fdb6820b9304bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 24 May 2026 16:28:19 +0300 Subject: [PATCH] refactor(webhook): Phase 3 Tasks 3.1+3.2 - delete WebhookReceiveController + remove POST /api/webhook/{token} route --- .../Api/WebhookReceiveController.php | 158 ------------------ app/routes/web.php | 6 - 2 files changed, 164 deletions(-) delete mode 100644 app/app/Http/Controllers/Api/WebhookReceiveController.php diff --git a/app/app/Http/Controllers/Api/WebhookReceiveController.php b/app/app/Http/Controllers/Api/WebhookReceiveController.php deleted file mode 100644 index 35e301ed..00000000 --- a/app/app/Http/Controllers/Api/WebhookReceiveController.php +++ /dev/null @@ -1,158 +0,0 @@ -` - * пришёл, проверяем `hash_hmac('sha256', raw_body, webhook_token)`. - * Невалидная подпись → 401. Отсутствие header — пропускаем (backward-compat - * для существующих интеграций; на prod через `system_settings.webhook_hmac_required` - * сделаем обязательной). - * 5. INSERT в webhook_log (RLS-обёрнутый), dispatch ProcessWebhookJob → 202. - */ -class WebhookReceiveController extends Controller -{ - /** POST /api/webhook/{token} */ - public function receive(Request $request, string $token): JsonResponse - { - $tenant = Tenant::query() - ->where('webhook_token', $token) - ->first(); - - if ($tenant === null) { - return response()->json([ - 'message' => 'Webhook token не найден или ротирован.', - ], 404); - } - - // Per-token rate-limit. Лимит из system_settings.webhook_rate_limit_rps - // (RPS), приводим к per-minute через ×60. Decay 60 сек. - $rpsLimit = $this->getRateLimitRps(); - $perMinuteLimit = $rpsLimit * 60; - $rateKey = "webhook:{$tenant->id}"; - - if (RateLimiter::tooManyAttempts($rateKey, $perMinuteLimit)) { - $retryAfter = RateLimiter::availableIn($rateKey); - - return response()->json([ - 'message' => "Превышен лимит ({$rpsLimit} RPS). Повтор через {$retryAfter} сек.", - 'retry_after' => $retryAfter, - ], 429)->header('Retry-After', (string) $retryAfter); - } - - RateLimiter::hit($rateKey, 60); - - // HMAC-валидация. Опциональная по умолчанию (backward-compat); при - // `system_settings.webhook_hmac_required = true` — обязательная, - // запросы без X-Webhook-Signature → 401. - $signature = $request->header('X-Webhook-Signature'); - $hmacRequired = $this->isHmacRequired(); - - if ($signature === null && $hmacRequired) { - return response()->json([ - 'message' => 'X-Webhook-Signature header требуется (HMAC обязателен в этой инсталляции).', - ], 401); - } - if ($signature !== null) { - $rawBody = $request->getContent(); - $expected = 'sha256='.hash_hmac('sha256', $rawBody, $token); - if (! hash_equals($expected, $signature)) { - return response()->json(['message' => 'Невалидная HMAC-подпись.'], 401); - } - } - - // Валидация payload (после tenant lookup чтобы посчитать rate-limit - // даже на bad payload — иначе rate-limit можно обойти 422-ответами). - $validated = $request->validate([ - 'vid' => 'required|integer|min:1', - 'project' => 'required|string|max:255', - 'phone' => 'required|string|max:50', - 'time' => 'required|integer|min:1', - 'tag' => 'nullable|string|max:100', - 'phones' => 'nullable|array', - ]); - - $logId = $this->insertWebhookLogStub($tenant->id, $validated); - - ProcessWebhookJob::dispatch($tenant->id, $validated, $logId); - - return response()->json([ - 'status' => 'accepted', - 'tenant_id' => $tenant->id, - 'webhook_log_id' => $logId, - ], 202); - } - - private function getRateLimitRps(): int - { - $setting = SystemSetting::find('webhook_rate_limit_rps'); - if ($setting === null) { - return 100; // sensible default из seed v8.7 - } - - return max(1, (int) $setting->value); - } - - /** - * HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default - * TRUE (HMAC обязателен по умолчанию). Отключить можно только явной - * установкой webhook_hmac_required=false. Неизвестное значение → fail-secure - * (HMAC требуется). - */ - private function isHmacRequired(): bool - { - $setting = SystemSetting::find('webhook_hmac_required'); - if ($setting === null) { - return true; - } - - return ! in_array($setting->value, ['false', '0'], true); - } - - /** - * Минимальный INSERT-stub в webhook_log (если таблица существует). - * На MVP webhook_log необязателен — возвращаем null если таблицы нет. - * - * @param array $payload - */ - private function insertWebhookLogStub(int $tenantId, array $payload): ?int - { - if (! Schema::hasTable('webhook_log')) { - return null; - } - - // RLS требует SET LOCAL — оборачиваем в транзакцию. - return (int) DB::transaction(function () use ($tenantId, $payload) { - DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - - return DB::table('webhook_log')->insertGetId([ - 'tenant_id' => $tenantId, - 'received_at' => now(), - 'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), - ]); - }); - } -} diff --git a/app/routes/web.php b/app/routes/web.php index 2c811d46..23219c68 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -270,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); }); -// Receive endpoint для входящих webhook'ов (narrative §5.5). -// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller). -// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit. -Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive') - ->where('token', '[A-Za-z0-9\-_]+'); - // Supplier-integration webhook (Plan 2/5, spec §5.1). // Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. // Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist