From 90ec8992d7cd04e00241d279746c31ebd0bccfab Mon Sep 17 00:00:00 2001 From: michaelbrauner_mysteryminds Date: Thu, 9 Mar 2023 12:49:39 +0100 Subject: [PATCH 1/4] Signiture verification with relative path --- README.md | 7 ++ phpstan-baseline.neon | 69 +++++----------- src/DependencyInjection/Configuration.php | 4 + .../SymfonyCastsVerifyEmailExtension.php | 1 + .../config/verify_email_services.xml | 1 + src/VerifyEmailHelper.php | 61 +++++++++++++- .../VerifyEmailAcceptanceTest.php | 80 ++++++++++++++++++- .../VerifyEmailHelperFunctionalTest.php | 5 +- tests/UnitTests/VerifyEmailHelperTest.php | 69 +++++++++++++++- tests/VerifyEmailTestKernel.php | 15 +++- 10 files changed, 252 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 861c655..569779a 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,13 @@ _Optional_ - Defaults to `3600` seconds This is the length of time a signed URL is valid for in seconds after it has been created. +#### `use_relative_path` + +_Optional_ – Defaults to `false` + +If set to `true`, the generated verification URL will use a relative path instead of an absolute URL. +This is useful if your app is accessible under multiple domains or customer-specific subdomains, as the host will be determined dynamically by the user's current request. + ## Reserved Query Parameters If you add any extra query parameters in the 5th argument of `verifyEmailHelper::generateSignature()`, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3f35fcc..ea80b9f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,16 +5,6 @@ parameters: count: 1 path: src/DependencyInjection/Configuration.php - - - message: "#^Instantiated class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#" - count: 1 - path: src/Factory/UriSignerFactory.php - - - - message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Factory\\\\UriSignerFactory\\:\\:createUriSigner\\(\\) has invalid return type Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 - path: src/Factory/UriSignerFactory.php - - message: "#^Parameter \\#2 \\$data of function hash_hmac expects string, string\\|false given\\.$#" count: 1 @@ -45,16 +35,6 @@ parameters: count: 1 path: src/Util/VerifyEmailQueryUtility.php - - - message: "#^Call to method check\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 - path: src/VerifyEmailHelper.php - - - - message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 - path: src/VerifyEmailHelper.php - - message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:__construct\\(\\) has parameter \\$uriSigner with no type specified\\.$#" count: 1 @@ -86,7 +66,12 @@ parameters: path: src/VerifyEmailHelper.php - - message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#" + message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:\\$useRelativePath has no type specified\\.$#" + count: 1 + path: src/VerifyEmailHelper.php + + - + message: "#^Instanceof between Symfony\\\\Component\\\\HttpFoundation\\\\UriSigner and Symfony\\\\Component\\\\HttpFoundation\\\\UriSigner will always evaluate to true\\.$#" count: 1 path: src/VerifyEmailHelper.php @@ -97,44 +82,29 @@ parameters: - message: "#^Access to an undefined property object\\:\\:\\$helper\\.$#" - count: 2 - path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php - - - - message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 + count: 4 path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php - message: "#^Parameter \\#2 \\$data of function hash_hmac expects string, string\\|false given\\.$#" - count: 2 + count: 4 path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php - - message: "#^Parameter \\$uriSigner of method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceFixture\\:\\:__construct\\(\\) has invalid type Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 + message: "#^Call to method PHPUnit\\\\Framework\\\\Assert::assertTrue\\(\\) with true and 'Test correctly does.*' will always evaluate to true\\.$#" + count: 3 path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php - - message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceFixture\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#" + message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceTest\\:\\:getBootedKernel\\(\\) has parameter \\$customConfig with no value type specified in iterable type array\\.$#" count: 1 path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php - - - message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#" - count: 1 - path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php - - message: "#^Cannot access offset 'query' on array\\{scheme\\?\\: string, host\\?\\: string, port\\?\\: int\\<0, 65535\\>, user\\?\\: string, pass\\?\\: string, path\\?\\: string, query\\?\\: string, fragment\\?\\: string\\}\\|false\\.$#" count: 1 path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php - - - message: "#^Instantiated class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#" - count: 1 - path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php - - message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\FunctionalTests\\\\VerifyEmailHelperFunctionalTest\\:\\:getTestSignature\\(\\) is unused\\.$#" count: 1 @@ -156,7 +126,7 @@ parameters: path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php - - message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\FunctionalTests\\\\VerifyEmailHelperFunctionalTest\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#" + message: "#^Call to method PHPUnit\\\\Framework\\\\Assert::assertTrue\\(\\) with true and 'Test correctly does.*' will always evaluate to true\\.$#" count: 1 path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php @@ -195,11 +165,6 @@ parameters: count: 2 path: tests/UnitTests/Model/VerifyEmailSignatureComponentsTest.php - - - message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#" - count: 1 - path: tests/UnitTests/VerifyEmailHelperTest.php - - message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\UnitTests\\\\VerifyEmailHelperTest\\:\\:\\$mockQueryUtility has no type specified\\.$#" count: 1 @@ -225,6 +190,11 @@ parameters: count: 1 path: tests/VerifyEmailTestKernel.php + - + message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:__construct\\(\\) has parameter \\$customConfig with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/VerifyEmailTestKernel.php + - message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$builder has no type specified\\.$#" count: 1 @@ -239,3 +209,8 @@ parameters: message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$routes has no type specified\\.$#" count: 1 path: tests/VerifyEmailTestKernel.php + + - + message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$customConfig type has no value type specified in iterable type array\\.$#" + count: 1 + path: tests/VerifyEmailTestKernel.php diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 146e906..de00a2d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -29,6 +29,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(3600) ->info('The length of time in seconds that a signed URI is valid for after it is created.') ->end() + ->booleanNode('use_relative_path') + ->defaultValue(false) + ->info('Decides whether to use an absolute url or a relative path for signing.') + ->end() ->end(); return $treeBuilder; diff --git a/src/DependencyInjection/SymfonyCastsVerifyEmailExtension.php b/src/DependencyInjection/SymfonyCastsVerifyEmailExtension.php index 50fe099..725f7ba 100644 --- a/src/DependencyInjection/SymfonyCastsVerifyEmailExtension.php +++ b/src/DependencyInjection/SymfonyCastsVerifyEmailExtension.php @@ -34,6 +34,7 @@ public function load(array $configs, ContainerBuilder $container): void $helperDefinition = $container->getDefinition('symfonycasts.verify_email.helper'); $helperDefinition->replaceArgument(4, $config['lifetime']); + $helperDefinition->replaceArgument(5, $config['use_relative_path']); } public function getAlias(): string diff --git a/src/Resources/config/verify_email_services.xml b/src/Resources/config/verify_email_services.xml index bdb93ea..d1985cb 100644 --- a/src/Resources/config/verify_email_services.xml +++ b/src/Resources/config/verify_email_services.xml @@ -28,6 +28,7 @@ + diff --git a/src/VerifyEmailHelper.php b/src/VerifyEmailHelper.php index dcb8339..ae1f611 100644 --- a/src/VerifyEmailHelper.php +++ b/src/VerifyEmailHelper.php @@ -38,14 +38,16 @@ final class VerifyEmailHelper implements VerifyEmailHelperInterface * @var int The length of time in seconds that a signed URI is valid for after it is created */ private $lifetime; + private $useRelativePath; - public function __construct(UrlGeneratorInterface $router, /* no typehint for BC with legacy PHP */ $uriSigner, VerifyEmailQueryUtility $queryUtility, VerifyEmailTokenGenerator $generator, int $lifetime) + public function __construct(UrlGeneratorInterface $router, /* no typehint for BC with legacy PHP */ $uriSigner, VerifyEmailQueryUtility $queryUtility, VerifyEmailTokenGenerator $generator, int $lifetime, bool $useRelativePath) { $this->router = $router; $this->uriSigner = $uriSigner; $this->queryUtility = $queryUtility; $this->tokenGenerator = $generator; $this->lifetime = $lifetime; + $this->useRelativePath = $useRelativePath; if (!$uriSigner instanceof UriSigner) { /** @psalm-suppress UndefinedFunction */ @@ -63,10 +65,8 @@ public function generateSignature(string $routeName, string $userId, string $use $uri = $this->router->generate($routeName, $extraParams, UrlGeneratorInterface::ABSOLUTE_URL); - $signature = $this->uriSigner->sign($uri); - /** @psalm-suppress PossiblyFalseArgument */ - return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $signature, $generatedAt); + return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $this->getSignedUrl($uri), $generatedAt); } public function validateEmailConfirmation(string $signedUrl, string $userId, string $userEmail): void @@ -111,4 +111,57 @@ public function validateEmailConfirmationFromRequest(Request $request, string $u throw new WrongEmailVerifyException(); } } + + private function generateAbsolutePath(string $absoluteUri): string + { + $parsedUri = parse_url($absoluteUri); + \assert(\is_array($parsedUri), 'Could not parse the provided URI.'); + + $path = $parsedUri['path'] ?? ''; + $query = $this->getQueryStringFromParsedUrl($parsedUri); + $fragment = isset($parsedUri['fragment']) ? '#'.$parsedUri['fragment'] : ''; + + return $path.$query.$fragment; + } + + public function generateSigningString(string $uri): string + { + if (!$this->useRelativePath) { + return $uri; + } + + return $this->generateAbsolutePath($uri); + } + + private function generateBaseUrl(string $absoluteUri): string + { + $parsedUri = parse_url($absoluteUri); + $scheme = isset($parsedUri['scheme']) ? $parsedUri['scheme'].'://' : ''; + $host = $parsedUri['host'] ?? ''; + + return $scheme.$host; + } + + private function getSignedUrl(string $uri): string + { + $signature = $this->uriSigner->sign($this->generateSigningString($uri)); + + if (false === $this->useRelativePath) { + return $signature; + } + + return $this->generateBaseUrl($uri).$signature; + } + + /** + * @param array{scheme?: string, host?: string, port?: int, user?: string, pass?: string, query?: string, path?: string, fragment?: string} $parsedUrl + */ + private function getQueryStringFromParsedUrl(array $parsedUrl): string + { + if (!\array_key_exists('query', $parsedUrl)) { + return ''; + } + + return $parsedUrl['query'] ? ('?'.$parsedUrl['query']) : ''; + } } diff --git a/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php b/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php index 0bf34d8..8662545 100644 --- a/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php +++ b/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php @@ -117,9 +117,83 @@ public function testValidateUsingRequestObject(): void $this->assertTrue(true, 'Test correctly does not throw an exception'); } - private function getBootedKernel(): KernelInterface + public function testGenerateSignatureWithRelativePath(): void + { + $kernel = $this->getBootedKernel(['use_relative_path' => true]); + + $container = $kernel->getContainer(); + + /** @var VerifyEmailHelper $helper */ + $helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper; + + $components = $helper->generateSignature('verify-test', '1234', 'jr@rushlow.dev'); + + $signature = $components->getSignedUrl(); + + $expiresAt = $components->getExpiresAt()->getTimestamp(); + + $expectedUserData = json_encode(['1234', 'jr@rushlow.dev']); + + $expectedToken = base64_encode(hash_hmac('sha256', $expectedUserData, 'foo', true)); + + $expectedSignature = base64_encode(hash_hmac( + 'sha256', + \sprintf('/verify/user?expires=%s&token=%s', $expiresAt, urlencode($expectedToken)), + 'foo', + true + )); + + $parsed = parse_url($signature); + + if (!\is_array($parsed) || !isset($parsed['query'])) { + throw new \RuntimeException('Invalid signature URL'); + } + + parse_str($parsed['query'], $result); + + self::assertIsString($result['signature']); + self::assertTrue(hash_equals($expectedSignature, $result['signature'])); + self::assertSame( + \sprintf('/verify/user?expires=%s&signature=%s&token=%s', $expiresAt, urlencode($expectedSignature), urlencode($expectedToken)), + strstr($signature, '/verify/user') + ); + } + + public function testValidateEmailSignatureWithRelativePath(): void + { + $kernel = $this->getBootedKernel(['use_relative_path' => true]); + + $container = $kernel->getContainer(); + + /** @var VerifyEmailHelper $helper */ + $helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper; + $expires = new \DateTimeImmutable('+1 hour'); + + $uriToTest = \sprintf( + '/verify/user?%s', + http_build_query([ + 'expires' => $expires->getTimestamp(), + 'token' => base64_encode(hash_hmac( + 'sha256', + json_encode(['1234', 'jr@rushlow.dev']), + 'foo', + true + )), + ]) + ); + + $signature = base64_encode(hash_hmac('sha256', $uriToTest, 'foo', true)); + + $test = \sprintf('%s&signature=%s', $uriToTest, urlencode($signature)); + + $helper->validateEmailConfirmation($test, '1234', 'jr@rushlow.dev'); + $this->assertTrue(true, 'Test correctly does not throw an exception'); + } + + private function getBootedKernel(array $customConfig = []): KernelInterface { $builder = new ContainerBuilder(); + $builder->autowire(VerifyEmailAcceptanceFixture::class) ->setPublic(true) ->setArgument(1, new Reference('symfonycasts.verify_email.uri_signer')) @@ -128,7 +202,9 @@ private function getBootedKernel(): KernelInterface $kernel = new VerifyEmailTestKernel( $builder, - ['verify-test' => '/verify/user'] + ['verify-test' => '/verify/user'], + [], + $customConfig ); $kernel->boot(); diff --git a/tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php b/tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php index 8d3902b..e0d5acf 100644 --- a/tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php +++ b/tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php @@ -106,7 +106,7 @@ private function getTestSignedUri(): string return \sprintf('/verify?%s', $sortedParams); } - private function getHelper(): VerifyEmailHelperInterface + private function getHelper(bool $useRelativePath = false): VerifyEmailHelperInterface { if (class_exists(UriSigner::class)) { $this->uriSigner = new UriSigner('foo', 'signature'); @@ -119,7 +119,8 @@ private function getHelper(): VerifyEmailHelperInterface $this->uriSigner, new VerifyEmailQueryUtility(), new VerifyEmailTokenGenerator('foo'), - 3600 + 3600, + $useRelativePath ); } } diff --git a/tests/UnitTests/VerifyEmailHelperTest.php b/tests/UnitTests/VerifyEmailHelperTest.php index 707cd9e..7f3dc5a 100644 --- a/tests/UnitTests/VerifyEmailHelperTest.php +++ b/tests/UnitTests/VerifyEmailHelperTest.php @@ -88,6 +88,38 @@ public function testSignatureIsGenerated(): void self::assertSame($expectedSignedUrl, $components->getSignedUrl()); } + public function testSignatureIsGeneratedWithRelativePath(): void + { + $expires = time() + 3600; + + $expectedSignedUrl = \sprintf('/verify?expires=%s&signature=1234&token=hashedToken', $expires); + + $this->tokenGenerator + ->expects($this->once()) + ->method('createToken') + ->with('1234', 'jr@rushlow.dev') + ->willReturn('hashedToken') + ; + + $this->mockRouter + ->expects($this->once()) + ->method('generate') + ->with('app_verify_route', ['token' => 'hashedToken', 'expires' => $expires]) + ->willReturn(\sprintf('/verify?expires=%s&token=hashedToken', $expires)) + ; + + $this->mockSigner + ->expects($this->once()) + ->method('sign') + ->with(\sprintf('/verify?expires=%s&token=hashedToken', $expires)) + ->willReturn($expectedSignedUrl) + ; + + $helper = $this->getHelper(true); + $components = $helper->generateSignature('app_verify_route', '1234', 'jr@rushlow.dev'); + self::assertSame($expectedSignedUrl, $components->getSignedUrl()); + } + /** @group legacy */ public function testValidationThrowsEarlyOnInvalidSignature(): void { @@ -122,6 +154,39 @@ public function testValidationThrowsEarlyOnInvalidSignature(): void $helper->validateEmailConfirmation($signedUrl, '1234', 'jr@rushlow.dev'); } + public function testValidationThrowsEarlyOnInvalidSignatureWithRelativePath(): void + { + $signedUrl = '/verify?expires=1&signature=1234%token=xyz'; + + $this->mockSigner + ->expects($this->once()) + ->method('check') + ->with($signedUrl) + ->willReturn(false) + ; + + $this->mockQueryUtility + ->expects($this->never()) + ->method('getExpiryTimestamp') + ; + + $this->mockQueryUtility + ->expects($this->never()) + ->method('getTokenFromQuery') + ; + + $this->tokenGenerator + ->expects($this->never()) + ->method('createToken') + ; + + $helper = $this->getHelper(true); + + $this->expectException(InvalidSignatureException::class); + + $helper->validateEmailConfirmation($signedUrl, '1234', 'jr@rushlow.dev'); + } + /** @group legacy */ public function testExceptionThrownWithExpiredSignature(): void { @@ -268,8 +333,8 @@ public function testValidationFromRequestThrowsWithInvalidToken(): void $helper->validateEmailConfirmationFromRequest($request, '1234', 'jr@rushlow.dev'); } - private function getHelper(): VerifyEmailHelperInterface + private function getHelper(bool $useRelativePath = false): VerifyEmailHelperInterface { - return new VerifyEmailHelper($this->mockRouter, $this->mockSigner, $this->mockQueryUtility, $this->tokenGenerator, 3600); + return new VerifyEmailHelper($this->mockRouter, $this->mockSigner, $this->mockQueryUtility, $this->tokenGenerator, 3600, $useRelativePath); } } diff --git a/tests/VerifyEmailTestKernel.php b/tests/VerifyEmailTestKernel.php index 3d4d2ac..f5d13ab 100644 --- a/tests/VerifyEmailTestKernel.php +++ b/tests/VerifyEmailTestKernel.php @@ -29,16 +29,20 @@ class VerifyEmailTestKernel extends Kernel private $builder; private $routes; private $extraBundles; + /** @var array */ + private $customConfig; /** - * @param array $routes Routes to be added to the container e.g. ['name' => 'path'] - * @param BundleInterface[] $bundles Additional bundles to be registered e.g. [new Bundle()] + * @param array $routes Routes to be added to the container e.g. ['name' => 'path'] + * @param BundleInterface[] $bundles Additional bundles to be registered e.g. [new Bundle()] + * @param array $customConfig Custom configuration to be loaded into the container */ - public function __construct(?ContainerBuilder $builder = null, array $routes = [], array $bundles = []) + public function __construct(?ContainerBuilder $builder = null, array $routes = [], array $bundles = [], array $customConfig = []) { $this->builder = $builder; $this->routes = $routes; $this->extraBundles = $bundles; + $this->customConfig = $customConfig; parent::__construct('test', true); } @@ -54,6 +58,7 @@ public function registerBundles(): iterable ); } + /** @noinspection PhpParamsInspection */ public function registerContainerConfiguration(LoaderInterface $loader): void { if (null === $this->builder) { @@ -77,6 +82,10 @@ public function registerContainerConfiguration(LoaderInterface $loader): void ] ); + if (!empty($this->customConfig)) { + $container->loadFromExtension('symfonycasts_verify_email', $this->customConfig); + } + $container->register('kernel', static::class) ->setPublic(true) ; From 3370f9a64ea1dd242e0a80b442f317dee05ab41b Mon Sep 17 00:00:00 2001 From: Michael Brauner Date: Sun, 8 Jun 2025 04:27:29 +0200 Subject: [PATCH 2/4] refactor(signature): simplify signature verification for relative URLs --- src/VerifyEmailHelper.php | 57 +++------------------------------------ 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/src/VerifyEmailHelper.php b/src/VerifyEmailHelper.php index ae1f611..d406bba 100644 --- a/src/VerifyEmailHelper.php +++ b/src/VerifyEmailHelper.php @@ -63,10 +63,11 @@ public function generateSignature(string $routeName, string $userId, string $use $extraParams['token'] = $this->tokenGenerator->createToken($userId, $userEmail); $extraParams['expires'] = $expiryTimestamp; - $uri = $this->router->generate($routeName, $extraParams, UrlGeneratorInterface::ABSOLUTE_URL); + $uri = $this->router->generate($routeName, $extraParams, $this->useRelativePath ? UrlGeneratorInterface::RELATIVE_PATH : UrlGeneratorInterface::ABSOLUTE_URL); + $signature = $this->uriSigner->sign($uri); /** @psalm-suppress PossiblyFalseArgument */ - return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $this->getSignedUrl($uri), $generatedAt); + return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $signature, $generatedAt); } public function validateEmailConfirmation(string $signedUrl, string $userId, string $userEmail): void @@ -112,56 +113,4 @@ public function validateEmailConfirmationFromRequest(Request $request, string $u } } - private function generateAbsolutePath(string $absoluteUri): string - { - $parsedUri = parse_url($absoluteUri); - \assert(\is_array($parsedUri), 'Could not parse the provided URI.'); - - $path = $parsedUri['path'] ?? ''; - $query = $this->getQueryStringFromParsedUrl($parsedUri); - $fragment = isset($parsedUri['fragment']) ? '#'.$parsedUri['fragment'] : ''; - - return $path.$query.$fragment; - } - - public function generateSigningString(string $uri): string - { - if (!$this->useRelativePath) { - return $uri; - } - - return $this->generateAbsolutePath($uri); - } - - private function generateBaseUrl(string $absoluteUri): string - { - $parsedUri = parse_url($absoluteUri); - $scheme = isset($parsedUri['scheme']) ? $parsedUri['scheme'].'://' : ''; - $host = $parsedUri['host'] ?? ''; - - return $scheme.$host; - } - - private function getSignedUrl(string $uri): string - { - $signature = $this->uriSigner->sign($this->generateSigningString($uri)); - - if (false === $this->useRelativePath) { - return $signature; - } - - return $this->generateBaseUrl($uri).$signature; - } - - /** - * @param array{scheme?: string, host?: string, port?: int, user?: string, pass?: string, query?: string, path?: string, fragment?: string} $parsedUrl - */ - private function getQueryStringFromParsedUrl(array $parsedUrl): string - { - if (!\array_key_exists('query', $parsedUrl)) { - return ''; - } - - return $parsedUrl['query'] ? ('?'.$parsedUrl['query']) : ''; - } } From 3feb180cbd0197647e7fac2a479aced23accccca Mon Sep 17 00:00:00 2001 From: Michael Brauner Date: Sun, 8 Jun 2025 04:29:12 +0200 Subject: [PATCH 3/4] fix(tests): adjust for relative URL signatures --- .../VerifyEmailAcceptanceTest.php | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php b/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php index 8662545..7411932 100644 --- a/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php +++ b/tests/AcceptanceTests/VerifyEmailAcceptanceTest.php @@ -120,43 +120,22 @@ public function testValidateUsingRequestObject(): void public function testGenerateSignatureWithRelativePath(): void { $kernel = $this->getBootedKernel(['use_relative_path' => true]); - $container = $kernel->getContainer(); - /** @var VerifyEmailHelper $helper */ - $helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper; + /** @var VerifyEmailAcceptanceFixture $testHelper */ + $testHelper = $container->get(VerifyEmailAcceptanceFixture::class); + $helper = $testHelper->helper; $components = $helper->generateSignature('verify-test', '1234', 'jr@rushlow.dev'); - - $signature = $components->getSignedUrl(); - $expiresAt = $components->getExpiresAt()->getTimestamp(); - - $expectedUserData = json_encode(['1234', 'jr@rushlow.dev']); - - $expectedToken = base64_encode(hash_hmac('sha256', $expectedUserData, 'foo', true)); - - $expectedSignature = base64_encode(hash_hmac( - 'sha256', - \sprintf('/verify/user?expires=%s&token=%s', $expiresAt, urlencode($expectedToken)), - 'foo', - true + $actual = $components->getSignedUrl(); + $expected = $testHelper->uriSigner->sign(\sprintf( + 'verify/user?expires=%s&token=%s', + $expiresAt, + $testHelper->generator->createToken('1234', 'jr@rushlow.dev') )); - $parsed = parse_url($signature); - - if (!\is_array($parsed) || !isset($parsed['query'])) { - throw new \RuntimeException('Invalid signature URL'); - } - - parse_str($parsed['query'], $result); - - self::assertIsString($result['signature']); - self::assertTrue(hash_equals($expectedSignature, $result['signature'])); - self::assertSame( - \sprintf('/verify/user?expires=%s&signature=%s&token=%s', $expiresAt, urlencode($expectedSignature), urlencode($expectedToken)), - strstr($signature, '/verify/user') - ); + self::assertSame($expected, $actual); } public function testValidateEmailSignatureWithRelativePath(): void From 5b355854d3e893453d1ecd9362b013a578a0aeaa Mon Sep 17 00:00:00 2001 From: Victor Bocharsky Date: Fri, 15 Aug 2025 16:34:21 +0200 Subject: [PATCH 4/4] Trigger the CI