diff --git a/.env b/.env index 4fd8920..4f2ec8f 100644 --- a/.env +++ b/.env @@ -44,8 +44,8 @@ MAILJET_API_KEY= MAILJET_API_SECRET= MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST MAILJET_DEFAULT_LIST_ID= -# Development default for HTTP Basic Auth password "secret"; override in production secrets. -MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' +# Development default for HTTP Basic Auth password; override in production secrets. +MAILJET_WEBHOOK_BASIC_PASSWORD=secret APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_BPN_USER= diff --git a/.env.test b/.env.test index f1e0e07..9d63102 100644 --- a/.env.test +++ b/.env.test @@ -4,4 +4,4 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots -MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' +MAILJET_WEBHOOK_BASIC_PASSWORD=secret diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6792363..a87c278 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,6 +1,7 @@ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: @@ -12,7 +13,7 @@ security: memory: users: mailjet: - password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD_HASH)%' + password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%' roles: ['ROLE_MAILJET_WEBHOOK'] firewalls: dev: @@ -58,6 +59,7 @@ when@test: # important to generate secure password hashes. In tests however, secure hashes # are not important, waste resources and increase test times. The following # reduces the work factor to the lowest possible values. + Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto cost: 4 # Lowest possible value for bcrypt diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index c620349..e6a4c0a 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -1169,6 +1169,8 @@ php bin/console app:mailjet:newsletter-webhook deactivate Creates or deletes the Mailjet `unsub` callback for `POST /webhooks/mailjet/newsletter`. - Uses `APP_BASE_URL` by default and appends the webhook path. +- The command prepends `mailjet:@` to the URL before registering it with Mailjet. +- Configure the plain password in `MAILJET_WEBHOOK_BASIC_PASSWORD`; Symfony uses it for the webhook firewall. - Pass `--url` to target a different full webhook URL. - `register` prints the API response payload so you can note the callback ID. - `remove` and `deactivate` require the callback ID returned by Mailjet. diff --git a/src/Command/MailjetNewsletterWebhookCommand.php b/src/Command/MailjetNewsletterWebhookCommand.php index 5a88b0f..7fb770d 100644 --- a/src/Command/MailjetNewsletterWebhookCommand.php +++ b/src/Command/MailjetNewsletterWebhookCommand.php @@ -26,6 +26,7 @@ final class MailjetNewsletterWebhookCommand extends Command private const string ACTION_REMOVE = 'remove'; private const string ACTION_DEACTIVATE = 'deactivate'; private const string EVENT_TYPE_UNSUBSCRIBE = 'unsub'; + private const string WEBHOOK_BASIC_AUTH_USER = 'mailjet'; private const string WEBHOOK_PATH = '/webhooks/mailjet/newsletter'; private const int WEBHOOK_VERSION = 2; @@ -33,6 +34,8 @@ final class MailjetNewsletterWebhookCommand extends Command private readonly MailjetApiClient $mailjetApiClient, #[Autowire('%env(APP_BASE_URL)%')] private readonly string $defaultBaseUrl, + #[Autowire('%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%')] + private readonly string $basicAuthPassword, private readonly LoggerInterface $logger, ) { parent::__construct(); @@ -43,7 +46,7 @@ final class MailjetNewsletterWebhookCommand extends Command $this ->addArgument('action', InputArgument::REQUIRED, 'One of: register, remove, deactivate') ->addArgument('callback_id', InputArgument::OPTIONAL, 'Callback ID required for remove/deactivate') - ->addOption('url', null, InputOption::VALUE_REQUIRED, 'Override the webhook URL to register'); + ->addOption('url', null, InputOption::VALUE_REQUIRED, 'Override the webhook URL to register before Basic Auth credentials are added'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -72,9 +75,10 @@ final class MailjetNewsletterWebhookCommand extends Command private function register(SymfonyStyle $io, mixed $overrideUrl): int { $webhookUrl = $this->resolveWebhookUrl($overrideUrl); - $payload = $this->mailjetApiClient->createEventCallbackUrl(self::EVENT_TYPE_UNSUBSCRIBE, $webhookUrl, self::WEBHOOK_VERSION); + $authenticatedWebhookUrl = $this->prependBasicAuth($webhookUrl); + $payload = $this->mailjetApiClient->createEventCallbackUrl(self::EVENT_TYPE_UNSUBSCRIBE, $authenticatedWebhookUrl, self::WEBHOOK_VERSION); - $io->success(sprintf('Created Mailjet newsletter webhook at %s.', $webhookUrl)); + $io->success(sprintf('Created Mailjet newsletter webhook at %s.', $authenticatedWebhookUrl)); $io->writeln($this->formatPayload($payload)); return Command::SUCCESS; @@ -115,6 +119,47 @@ final class MailjetNewsletterWebhookCommand extends Command return rtrim(trim($this->defaultBaseUrl), '/').self::WEBHOOK_PATH; } + private function prependBasicAuth(string $url): string + { + if ('' === trim($this->basicAuthPassword)) { + throw new \InvalidArgumentException('MAILJET_WEBHOOK_BASIC_PASSWORD is not configured.'); + } + + $parts = parse_url($url); + if (false === is_array($parts) || false === isset($parts['scheme'], $parts['host'])) { + throw new \InvalidArgumentException('The webhook URL is not valid.'); + } + + $host = $parts['host']; + if (false === str_starts_with($host, '[') && true === str_contains($host, ':')) { + $host = sprintf('[%s]', $host); + } + + $authenticatedUrl = sprintf( + '%s://%s:%s@%s', + $parts['scheme'], + rawurlencode(self::WEBHOOK_BASIC_AUTH_USER), + rawurlencode($this->basicAuthPassword), + $host, + ); + + if (isset($parts['port'])) { + $authenticatedUrl .= sprintf(':%d', $parts['port']); + } + + $authenticatedUrl .= $parts['path'] ?? ''; + + if (isset($parts['query'])) { + $authenticatedUrl .= '?'.$parts['query']; + } + + if (isset($parts['fragment'])) { + $authenticatedUrl .= '#'.$parts['fragment']; + } + + return $authenticatedUrl; + } + private function resolveCallbackId(mixed $callbackId): int { if (true === is_int($callbackId) && $callbackId > 0) { diff --git a/src/Controller/Webhook/MailjetNewsletterWebhookController.php b/src/Controller/Webhook/MailjetNewsletterWebhookController.php index 1e991ca..13f33d3 100644 --- a/src/Controller/Webhook/MailjetNewsletterWebhookController.php +++ b/src/Controller/Webhook/MailjetNewsletterWebhookController.php @@ -22,7 +22,7 @@ final class MailjetNewsletterWebhookController extends AbstractController } #[Route('/webhooks/mailjet/newsletter', name: 'app_webhook_mailjet_newsletter', methods: ['POST'])] - public function __invoke(Request $request): JsonResponse + public function index(Request $request): JsonResponse { $payload = json_decode($request->getContent(), true); if (JSON_ERROR_NONE !== json_last_error() || false === is_array($payload)) { @@ -51,8 +51,7 @@ final class MailjetNewsletterWebhookController extends AbstractController } /** - * @param array $payload - * + * @param array $payload * @return list> */ private function normalizeEvents(array $payload): array diff --git a/tests/Command/MailjetNewsletterWebhookCommandTest.php b/tests/Command/MailjetNewsletterWebhookCommandTest.php index 6761fb5..db3ce42 100644 --- a/tests/Command/MailjetNewsletterWebhookCommandTest.php +++ b/tests/Command/MailjetNewsletterWebhookCommandTest.php @@ -20,7 +20,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase ->method('createEventCallbackUrl') ->with( 'unsub', - 'https://my.ep-reisen.de/webhooks/mailjet/newsletter', + 'https://mailjet:secret@my.ep-reisen.de/webhooks/mailjet/newsletter', 2, false, ) @@ -89,6 +89,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase return new MailjetNewsletterWebhookCommand( $mailjetApiClient, 'https://my.ep-reisen.de', + 'secret', $this->createMock(LoggerInterface::class), ); } diff --git a/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php b/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php index 6a3edb4..81be6c7 100644 --- a/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php +++ b/tests/Controller/Webhook/MailjetNewsletterWebhookControllerTest.php @@ -29,7 +29,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase ->willReturnCallback(static fn (object $message): Envelope => new Envelope($message)); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); - $response = $controller(Request::create( + $response = $controller->index(Request::create( '/webhooks/mailjet/newsletter', 'POST', content: json_encode([ @@ -61,7 +61,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase $messageBus->expects(self::never())->method('dispatch'); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); - $response = $controller(Request::create('/webhooks/mailjet/newsletter', 'POST', content: '{')); + $response = $controller->index(Request::create('/webhooks/mailjet/newsletter', 'POST', content: '{')); self::assertSame(400, $response->getStatusCode()); } @@ -72,7 +72,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase $messageBus->expects(self::never())->method('dispatch'); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); - $response = $controller(Request::create( + $response = $controller->index(Request::create( '/webhooks/mailjet/newsletter', 'POST', content: json_encode([