From 490adc15eaa171b7c38556fa0f98b4f4f4715bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 8 Aug 2023 17:27:22 +0200 Subject: [PATCH] [Notifier][Webhook] Add Vonage support --- .../Notifier/Bridge/Vonage/CHANGELOG.md | 5 ++ .../Tests/Webhook/Fixtures/delivered.json | 19 ++++ .../Tests/Webhook/Fixtures/delivered.php | 8 ++ .../Tests/Webhook/Fixtures/rejected.json | 25 ++++++ .../Tests/Webhook/Fixtures/rejected.php | 8 ++ .../Tests/Webhook/Fixtures/undeliverable.json | 25 ++++++ .../Tests/Webhook/Fixtures/undeliverable.php | 8 ++ .../Tests/Webhook/VonageRequestParserTest.php | 66 ++++++++++++++ .../Vonage/Webhook/VonageRequestParser.php | 90 +++++++++++++++++++ .../Notifier/Bridge/Vonage/composer.json | 3 + 10 files changed, 257 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/VonageRequestParserTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Vonage/Webhook/VonageRequestParser.php diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Vonage/CHANGELOG.md index 7f6ce4e6893ba..bd860671c5610 100644 --- a/src/Symfony/Component/Notifier/Bridge/Vonage/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.4 +--- + +* Add support for `RemoteEvent` and `Webhook` + 6.2 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.json b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.json new file mode 100644 index 0000000000000..cb4d692ec9c57 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.json @@ -0,0 +1,19 @@ +{ + "message_uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "to": "447700900000", + "from": "447700900001", + "timestamp": {}, + "status": "delivered", + "usage": { + "currency": "EUR", + "price": "0.0333" + }, + "client_ref": "string", + "channel": "sms", + "destination": { + "network_code": "12345" + }, + "sms": { + "count_total": "2" + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.php b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.php new file mode 100644 index 0000000000000..c00eaaa1754be --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/delivered.php @@ -0,0 +1,8 @@ +setRecipientPhone('447700900000'); + +return $wh; diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.json b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.json new file mode 100644 index 0000000000000..75d5af237d043 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.json @@ -0,0 +1,25 @@ +{ + "message_uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "to": "447700900000", + "from": "447700900001", + "timestamp": {}, + "status": "rejected", + "error": { + "type": "https://developer.nexmo.com/api-errors/messages-olympus#1000", + "title": 1000, + "detail": "Throttled - You have exceeded the submission capacity allowed on this account. Please wait and retry", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" + }, + "usage": { + "currency": "EUR", + "price": "0.0333" + }, + "client_ref": "string", + "channel": "sms", + "destination": { + "network_code": "12345" + }, + "sms": { + "count_total": "2" + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.php b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.php new file mode 100644 index 0000000000000..f0c02bba5bbaf --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/rejected.php @@ -0,0 +1,8 @@ +setRecipientPhone('447700900000'); + +return $wh; diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.json b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.json new file mode 100644 index 0000000000000..c1d495f0b6de7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.json @@ -0,0 +1,25 @@ +{ + "message_uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab", + "to": "447700900000", + "from": "447700900001", + "timestamp": {}, + "status": "undeliverable", + "error": { + "type": "https://developer.nexmo.com/api-errors/messages-olympus#1260", + "title": 1260, + "detail": "Destination unreachable - The message could not be delivered to the phone number. If using Viber Business Messages your account might not be enabled for this country.", + "instance": "bf0ca0bf927b3b52e3cb03217e1a1ddf" + }, + "usage": { + "currency": "EUR", + "price": "0.0333" + }, + "client_ref": "string", + "channel": "sms", + "destination": { + "network_code": "12345" + }, + "sms": { + "count_total": "2" + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.php b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.php new file mode 100644 index 0000000000000..f0c02bba5bbaf --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/Fixtures/undeliverable.php @@ -0,0 +1,8 @@ +setRecipientPhone('447700900000'); + +return $wh; diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/VonageRequestParserTest.php b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/VonageRequestParserTest.php new file mode 100644 index 0000000000000..60d6dfa0b5eb8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Tests/Webhook/VonageRequestParserTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Vonage\Tests\Webhook; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Notifier\Bridge\Vonage\Webhook\VonageRequestParser; +use Symfony\Component\Webhook\Client\RequestParserInterface; +use Symfony\Component\Webhook\Exception\RejectWebhookException; +use Symfony\Component\Webhook\Test\AbstractRequestParserTestCase; + +class VonageRequestParserTest extends AbstractRequestParserTestCase +{ + public function testMissingAuthorizationTokenThrows() + { + $request = $this->createRequest('{}'); + $request->headers->remove('Authorization'); + $parser = $this->createRequestParser(); + + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Missing "Authorization" header'); + + $parser->parse($request, $this->getSecret()); + } + + public function testInvalidAuthorizationTokenThrows() + { + $request = $this->createRequest('{}'); + $request->headers->set('Authorization', 'Invalid Header'); + $parser = $this->createRequestParser(); + + $this->expectException(RejectWebhookException::class); + $this->expectExceptionMessage('Signature is wrong'); + + $parser->parse($request, $this->getSecret()); + } + + protected function createRequestParser(): RequestParserInterface + { + return new VonageRequestParser(); + } + + protected function createRequest(string $payload): Request + { + // JWT Token signed with the secret key + $jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.kK9JnTXZwzNo3BYNXJT57PGLnQk-Xyu7IBhRWFmc4C0'; + + $request = parent::createRequest($payload); + $request->headers->set('Authorization', 'Bearer '.$jwt); + + return $request; + } + + protected function getSecret(): string + { + return 'secret-key'; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/Webhook/VonageRequestParser.php b/src/Symfony/Component/Notifier/Bridge/Vonage/Webhook/VonageRequestParser.php new file mode 100644 index 0000000000000..f1a806f7f74aa --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/Webhook/VonageRequestParser.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Vonage\Webhook; + +use Symfony\Component\HttpFoundation\ChainRequestMatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher; +use Symfony\Component\HttpFoundation\RequestMatcherInterface; +use Symfony\Component\RemoteEvent\Event\Sms\SmsEvent; +use Symfony\Component\Webhook\Client\AbstractRequestParser; +use Symfony\Component\Webhook\Exception\RejectWebhookException; + +final class VonageRequestParser extends AbstractRequestParser +{ + protected function getRequestMatcher(): RequestMatcherInterface + { + return new ChainRequestMatcher([ + new MethodRequestMatcher('POST'), + new IsJsonRequestMatcher(), + ]); + } + + protected function doParse(Request $request, string $secret): ?SmsEvent + { + // Signed webhooks: https://developer.vonage.com/en/getting-started/concepts/webhooks#validating-signed-webhooks + if (!$request->headers->has('Authorization')) { + throw new RejectWebhookException(406, 'Missing "Authorization" header.'); + } + $this->validateSignature(substr($request->headers->get('Authorization'), \strlen('Bearer ')), $secret); + + // Statuses: https://developer.vonage.com/en/api/messages-olympus#message-status + $payload = $request->toArray(); + if ( + !isset($payload['status']) + || !isset($payload['message_uuid']) + || !isset($payload['to']) + || !isset($payload['channel']) + ) { + throw new RejectWebhookException(406, 'Payload is malformed.'); + } + + if ('sms' !== $payload['channel']) { + throw new RejectWebhookException(406, sprintf('Unsupported channel "%s".', $payload['channel'])); + } + + $name = match ($payload['status']) { + 'delivered' => SmsEvent::DELIVERED, + 'rejected' => SmsEvent::FAILED, + 'submitted' => null, + 'undeliverable' => SmsEvent::FAILED, + default => throw new RejectWebhookException(406, sprintf('Unsupported event "%s".', $payload['status'])), + }; + if (!$name) { + return null; + } + + $event = new SmsEvent($name, $payload['message_uuid'], $payload); + $event->setRecipientPhone($payload['to']); + + return $event; + } + + private function validateSignature(string $jwt, string $secret): void + { + $tokenParts = explode('.', $jwt); + if (3 !== \count($tokenParts)) { + throw new RejectWebhookException(406, 'Signature is wrong.'); + } + + [$header, $payload, $signature] = $tokenParts; + if ($signature !== $this->base64EncodeUrl(hash_hmac('sha256', $header.'.'.$payload, $secret, true))) { + throw new RejectWebhookException(406, 'Signature is wrong.'); + } + } + + private function base64EncodeUrl(string $string): string + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($string)); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Vonage/composer.json b/src/Symfony/Component/Notifier/Bridge/Vonage/composer.json index c93f3413b2212..6bbc719335e1e 100644 --- a/src/Symfony/Component/Notifier/Bridge/Vonage/composer.json +++ b/src/Symfony/Component/Notifier/Bridge/Vonage/composer.json @@ -20,6 +20,9 @@ "symfony/http-client": "^5.4|^6.0|^7.0", "symfony/notifier": "^6.2.7|^7.0" }, + "require-dev": { + "symfony/webhook": "^6.4|^7.0" + }, "autoload": { "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Vonage\\": "" }, "exclude-from-classmap": [