diff --git a/README.md b/README.md index 6bedcba..37665cf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # gnexus-synapse-client-php Тонкий PHP-клиент Synapse (централизованный хаб уведомлений Gnexus) для сервисов экосистемы: -gnexus-auth (Laravel), Navi-инстансы и другие PHP-сервисы. +gnexus-auth (Laravel), Navi-инстансы и другие PHP-сервисы. Обе стороны: отправка событий +(Ingestion) и приём s2s-доставок (проверка подписи webhook'а). Порядок интеграции — +`docs/09-integration-guide.md` в репо gn-synapse. Клиент берёт на себя весь повторяемый шаблон: env-конфиг, сборку и локальную валидацию конверта, типизированные исключения по HTTP-статусам, fire-and-forget `emit`, batch, статус события. @@ -104,9 +106,36 @@ | `NotFoundException` | 404 | Статус чужого/несуществующего события | | `ValidationException` | 422 / null | Незарегистрированный тип или битый конверт (null = локально) | | `ServerException` | 5xx | Synapse упал | +| `InvalidWebhookException` | — | приём s2s: подпись/freshness/не-JSON — не прошло проверку `WebhookVerifier` | Ловить — одну `SynapseException`. +## Приём s2s-доставки (webhook-приёмник) + +Сервисы, которые и шлют события, и принимают доставки (Navi, …), используют тот же +пакет для второй стороны — `GNexus\Synapse\Webhook\WebhookVerifier`: + +```php +use GNexus\Synapse\Webhook\WebhookVerifier; + +public function synapseWebhook(Request $r): JsonResponse +{ + $envelope = WebhookVerifier::verify( + $r->getContent(), // raw body, до любого парсинга + $r->headers->all(), + env('S2S_SECRET_MY_REF'), // per-target, docs/05 + ); + // $envelope['event_id'], $envelope['subject'], $envelope['payload'] ... + return response()->json(['received' => true]); +} +``` + +Один вход: нет/битая подпись, чужой секрет, replay (тело старше `maxSkew`, +дефолт 300 с), не-JSON — всё ловится одним `InvalidWebhookException`. +Схема — одна на всю экосистему (зеркало `WebhookSignature.php` gnexus-auth и +`app/signature.py` Synapse; перекрёстно проверено), сравнение через +`hash_equals`. `makeSignature()` — для тестов приёмника. + ## Оговорки - **Дедуп best-effort** в окне 24ч: повтор возвращает первое событие с `deduplicated: true`. diff --git a/src/Exception/InvalidWebhookException.php b/src/Exception/InvalidWebhookException.php new file mode 100644 index 0000000..407f092 --- /dev/null +++ b/src/Exception/InvalidWebhookException.php @@ -0,0 +1,14 @@ +,v1=" + hash_hmac('sha256', ".", secret) + * + * verify() — одна точка входа принимающего сервиса: проверка + * (константное сравнение, окно свежести против replay) + парс конверта. + * Тело — байт в байт то, что пришло в сеть ($request->getContent()), + * до любого парсинга. + * + * Использование (Laravel-приёмник Navi): + * + * public function synapseWebhook(Request $r): JsonResponse + * { + * $envelope = WebhookVerifier::verify($r->getContent(), $r->headers->all(), env('S2S_SECRET_X')); + * // $envelope['event_id'], $envelope['subject'], ... + * return response()->json(['received' => true]); + * } + */ +final class WebhookVerifier +{ + public const SIGNATURE_HEADER = 'x-gnexus-signature'; + public const DEFAULT_MAX_SKEW = 300; // секунд (зеркало gnexus-auth) + + /** private __construct: только статика. */ + private function __construct() + { + } + + /** Подпись «сырое» тело (для тестов и локального повторения подписи Synapse). */ + public static function makeSignature(string $rawBody, string $secret, ?int $timestamp = null): string + { + $timestamp ??= time(); + $digest = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); + + return "t={$timestamp},v1={$digest}"; + } + + /** + * Проверить подпись и распарсить доставку; возвращает конверт (assoc-array). + * + * @param array> $headers произвольный массив заголовков + * (Laravel Request::headers->all() подходит — значения могут быть списками) + * + * @return array + */ + public static function verify( + string $rawBody, + array $headers, + string $secret, + int $maxSkew = self::DEFAULT_MAX_SKEW, + ?int $now = null, + ): array { + $supplied = self::headerValue($headers, self::SIGNATURE_HEADER); + $timestamp = null; + $digest = ''; + foreach (explode(',', $supplied) as $chunk) { + [$name, $value] = array_pad(explode('=', $chunk, 2), 2, ''); + $name = strtolower(trim($name)); + if ($name === 't') { + $timestamp = filter_var(trim($value), FILTER_VALIDATE_INT, ['options' => ['default' => null]]); + } elseif ($name === 'v1') { + $digest = trim($value); + } + } + if ($timestamp === null || $digest === '') { + throw new InvalidWebhookException('нет или битый заголовок X-Gnexus-Signature'); + } + + $current = $now ?? time(); + $skew = abs($current - $timestamp); + if ($skew > $maxSkew) { + throw new InvalidWebhookException("тело не свежее: отклонение {$skew} с > {$maxSkew} с (replay?)"); + } + + $expected = self::makeSignature($rawBody, $secret, $timestamp); + [, $expectedDigest] = explode('v1=', $expected, 2); + if (! hash_equals($expectedDigest, $digest)) { + throw new InvalidWebhookException('подпись не совпала — чужой секрет или тело искажено'); + } + + try { + $envelope = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $ex) { + throw new InvalidWebhookException('тело не JSON — Synapse шлёт конверт: ' . $ex->getMessage()); + } + if (! is_array($envelope) || array_is_list($envelope)) { + throw new InvalidWebhookException('тело — не JSON-объект конверта'); + } + + return $envelope; + } + + /** @param array> $headers */ + private static function headerValue(array $headers, string $name): string + { + foreach ($headers as $key => $value) { + if (strtolower($key) !== $name) { + continue; + } + $value = is_array($value) ? ($value[0] ?? '') : $value; + + return is_string($value) ? $value : ''; + } + + return ''; + } +} \ No newline at end of file diff --git a/tests/WebhookVerifierTest.php b/tests/WebhookVerifierTest.php new file mode 100644 index 0000000..e7b401f --- /dev/null +++ b/tests/WebhookVerifierTest.php @@ -0,0 +1,137 @@ + self::EVENT_ID, + 'source' => 'monitoring', + 'subject' => 'container', + 'action' => 'down', + 'priority' => 'critical', + 'payload' => ['container' => 'api'], + ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + public function testHappyPathReturnsEnvelope(): void + { + $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS); + $envelope = WebhookVerifier::verify( + self::body(), + [WebhookVerifier::SIGNATURE_HEADER => $header], + self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, + self::TS, + ); + self::assertSame('monitoring', $envelope['source']); + self::assertSame(self::EVENT_ID, $envelope['event_id']); + } + + public function testHeaderValueMayBeList(): void + { + // Laravel Request::headers->all(): значение — массив заголовков с этим именем + $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS); + $envelope = WebhookVerifier::verify( + self::body(), + ['X-Gnexus-Signature' => [$header], 'X-Synapse-Source' => ['monitoring']], + self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, + self::TS, + ); + self::assertSame('down', $envelope['action']); + } + + public function testWrongSecretRejected(): void + { + $header = WebhookVerifier::makeSignature(self::body(), 'other-secret', self::TS); + try { + WebhookVerifier::verify(self::body(), ['x-gnexus-signature' => $header], self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, self::TS); + self::fail('чужой секрет принят'); + } catch (InvalidWebhookException $ex) { + self::assertIsString($ex->detail); + self::assertStringContainsString('не совпала', $ex->detail); + } + } + + public function testReplayRejected(): void + { + $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS); + try { + WebhookVerifier::verify(self::body(), ['x-gnexus-signature' => $header], self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, self::TS + WebhookVerifier::DEFAULT_MAX_SKEW + 1); + self::fail('старая доставка принята'); + } catch (InvalidWebhookException $ex) { + self::assertStringContainsString('replay', $ex->detail); + } + } + + public function testModifiedBodyRejected(): void + { + $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS); + try { + WebhookVerifier::verify(self::body() . ' ', ['x-gnexus-signature' => $header], self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, self::TS); + self::fail('искажённое тело принято'); + } catch (InvalidWebhookException $ex) { + self::assertIsString($ex->detail); + } + } + + public static function badHeadersProvider(): iterable + { + yield 'no header' => [[]]; + yield 'only t' => [['x-gnexus-signature' => 't=1']]; + yield 'garbage' => [['x-gnexus-signature' => 'garbage']]; + yield 'empty v1' => [['x-gnexus-signature' => 't=' . self::TS . ',v1=']]; + yield 't not int' => [['x-gnexus-signature' => 't=abc,v1=zz']]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('badHeadersProvider')] + public function testBadHeadersRejected(array $headers): void + { + try { + WebhookVerifier::verify(self::body(), $headers, self::SECRET, WebhookVerifier::DEFAULT_MAX_SKEW, self::TS); + self::fail('битые заголовки приняты'); + } catch (InvalidWebhookException $ex) { + self::assertIsString($ex->detail); + } + } + + public function testNonJsonBodyRejected(): void + { + $header = WebhookVerifier::makeSignature('not json', self::SECRET, self::TS); + try { + WebhookVerifier::verify('not json', ['x-gnexus-signature' => $header], self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, self::TS); + self::fail('не-JSON тело принято'); + } catch (InvalidWebhookException $ex) { + self::assertIsString($ex->detail); + } + } + + public function testListBodyRejected(): void + { + $header = WebhookVerifier::makeSignature('[1,2]', self::SECRET, self::TS); + try { + WebhookVerifier::verify('[1,2]', ['x-gnexus-signature' => $header], self::SECRET, + WebhookVerifier::DEFAULT_MAX_SKEW, self::TS); + self::fail('JSON-массив принят как конверт'); + } catch (InvalidWebhookException $ex) { + self::assertStringContainsString('не JSON-объект', $ex->detail); + } + } +} \ No newline at end of file