refactor(webhook): Phase 3 Tasks 3.1+3.2 - delete WebhookReceiveController + remove POST /api/webhook/{token} route
This commit is contained in:
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Receive endpoint для входящих webhook'ов от crm.bp-gr.ru (narrative §5.5).
|
||||
*
|
||||
* URL: POST /api/webhook/{token}
|
||||
* Token = `tenants.webhook_token` (UUID per tenant; ротация через
|
||||
* `webhook_token_rotated_at` — старый токен живёт 24ч после ротации).
|
||||
*
|
||||
* Шаги:
|
||||
* 1. Резолв tenant по token (404 если не найден).
|
||||
* 2. Per-token rate-limit (system_settings.webhook_rate_limit_rps × 60 ≈ per-minute).
|
||||
* Превышение → 429 + Retry-After.
|
||||
* 3. Валидация payload (vid/project/phone/time).
|
||||
* 4. HMAC-валидация (опциональная) — если header `X-Webhook-Signature: sha256=<hex>`
|
||||
* пришёл, проверяем `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<string,mixed> $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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user