Skip to content

Commit 90ec899

Browse files
michaelbrauner_mysterymindsMichaelBrauner
authored andcommitted
Signiture verification with relative path
1 parent 936b4b6 commit 90ec899

File tree

10 files changed

+252
-60
lines changed

10 files changed

+252
-60
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,13 @@ _Optional_ - Defaults to `3600` seconds
273273
This is the length of time a signed URL is valid for in seconds after it has
274274
been created.
275275

276+
#### `use_relative_path`
277+
278+
_Optional_ – Defaults to `false`
279+
280+
If set to `true`, the generated verification URL will use a relative path instead of an absolute URL.
281+
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.
282+
276283
## Reserved Query Parameters
277284

278285
If you add any extra query parameters in the 5th argument of `verifyEmailHelper::generateSignature()`,

phpstan-baseline.neon

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,6 @@ parameters:
55
count: 1
66
path: src/DependencyInjection/Configuration.php
77

8-
-
9-
message: "#^Instantiated class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#"
10-
count: 1
11-
path: src/Factory/UriSignerFactory.php
12-
13-
-
14-
message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Factory\\\\UriSignerFactory\\:\\:createUriSigner\\(\\) has invalid return type Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
15-
count: 1
16-
path: src/Factory/UriSignerFactory.php
17-
188
-
199
message: "#^Parameter \\#2 \\$data of function hash_hmac expects string, string\\|false given\\.$#"
2010
count: 1
@@ -45,16 +35,6 @@ parameters:
4535
count: 1
4636
path: src/Util/VerifyEmailQueryUtility.php
4737

48-
-
49-
message: "#^Call to method check\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
50-
count: 1
51-
path: src/VerifyEmailHelper.php
52-
53-
-
54-
message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
55-
count: 1
56-
path: src/VerifyEmailHelper.php
57-
5838
-
5939
message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:__construct\\(\\) has parameter \\$uriSigner with no type specified\\.$#"
6040
count: 1
@@ -86,7 +66,12 @@ parameters:
8666
path: src/VerifyEmailHelper.php
8767

8868
-
89-
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#"
69+
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\VerifyEmailHelper\\:\\:\\$useRelativePath has no type specified\\.$#"
70+
count: 1
71+
path: src/VerifyEmailHelper.php
72+
73+
-
74+
message: "#^Instanceof between Symfony\\\\Component\\\\HttpFoundation\\\\UriSigner and Symfony\\\\Component\\\\HttpFoundation\\\\UriSigner will always evaluate to true\\.$#"
9075
count: 1
9176
path: src/VerifyEmailHelper.php
9277

@@ -97,44 +82,29 @@ parameters:
9782

9883
-
9984
message: "#^Access to an undefined property object\\:\\:\\$helper\\.$#"
100-
count: 2
101-
path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
102-
103-
-
104-
message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
105-
count: 1
85+
count: 4
10686
path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
10787

10888
-
10989
message: "#^Parameter \\#2 \\$data of function hash_hmac expects string, string\\|false given\\.$#"
110-
count: 2
90+
count: 4
11191
path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
11292

11393
-
114-
message: "#^Parameter \\$uriSigner of method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceFixture\\:\\:__construct\\(\\) has invalid type Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
115-
count: 1
94+
message: "#^Call to method PHPUnit\\\\Framework\\\\Assert::assertTrue\\(\\) with true and 'Test correctly does.*' will always evaluate to true\\.$#"
95+
count: 3
11696
path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
11797

11898
-
119-
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceFixture\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#"
99+
message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\AcceptanceTests\\\\VerifyEmailAcceptanceTest\\:\\:getBootedKernel\\(\\) has parameter \\$customConfig with no value type specified in iterable type array\\.$#"
120100
count: 1
121101
path: tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
122102

123-
-
124-
message: "#^Call to method sign\\(\\) on an unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner\\.$#"
125-
count: 1
126-
path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
127-
128103
-
129104
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\\.$#"
130105
count: 1
131106
path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
132107

133-
-
134-
message: "#^Instantiated class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#"
135-
count: 1
136-
path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
137-
138108
-
139109
message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\FunctionalTests\\\\VerifyEmailHelperFunctionalTest\\:\\:getTestSignature\\(\\) is unused\\.$#"
140110
count: 1
@@ -156,7 +126,7 @@ parameters:
156126
path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
157127

158128
-
159-
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\FunctionalTests\\\\VerifyEmailHelperFunctionalTest\\:\\:\\$uriSigner has unknown class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner as its type\\.$#"
129+
message: "#^Call to method PHPUnit\\\\Framework\\\\Assert::assertTrue\\(\\) with true and 'Test correctly does.*' will always evaluate to true\\.$#"
160130
count: 1
161131
path: tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
162132

@@ -195,11 +165,6 @@ parameters:
195165
count: 2
196166
path: tests/UnitTests/Model/VerifyEmailSignatureComponentsTest.php
197167

198-
-
199-
message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\UriSigner not found\\.$#"
200-
count: 1
201-
path: tests/UnitTests/VerifyEmailHelperTest.php
202-
203168
-
204169
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\UnitTests\\\\VerifyEmailHelperTest\\:\\:\\$mockQueryUtility has no type specified\\.$#"
205170
count: 1
@@ -225,6 +190,11 @@ parameters:
225190
count: 1
226191
path: tests/VerifyEmailTestKernel.php
227192

193+
-
194+
message: "#^Method SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:__construct\\(\\) has parameter \\$customConfig with no value type specified in iterable type array\\.$#"
195+
count: 1
196+
path: tests/VerifyEmailTestKernel.php
197+
228198
-
229199
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$builder has no type specified\\.$#"
230200
count: 1
@@ -239,3 +209,8 @@ parameters:
239209
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$routes has no type specified\\.$#"
240210
count: 1
241211
path: tests/VerifyEmailTestKernel.php
212+
213+
-
214+
message: "#^Property SymfonyCasts\\\\Bundle\\\\VerifyEmail\\\\Tests\\\\VerifyEmailTestKernel\\:\\:\\$customConfig type has no value type specified in iterable type array\\.$#"
215+
count: 1
216+
path: tests/VerifyEmailTestKernel.php

src/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public function getConfigTreeBuilder(): TreeBuilder
2929
->defaultValue(3600)
3030
->info('The length of time in seconds that a signed URI is valid for after it is created.')
3131
->end()
32+
->booleanNode('use_relative_path')
33+
->defaultValue(false)
34+
->info('Decides whether to use an absolute url or a relative path for signing.')
35+
->end()
3236
->end();
3337

3438
return $treeBuilder;

src/DependencyInjection/SymfonyCastsVerifyEmailExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public function load(array $configs, ContainerBuilder $container): void
3434

3535
$helperDefinition = $container->getDefinition('symfonycasts.verify_email.helper');
3636
$helperDefinition->replaceArgument(4, $config['lifetime']);
37+
$helperDefinition->replaceArgument(5, $config['use_relative_path']);
3738
}
3839

3940
public function getAlias(): string

src/Resources/config/verify_email_services.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<argument type="service" id="symfonycasts.verify_email.query_utility" />
2929
<argument type="service" id="symfonycasts.verify_email.token_generator" />
3030
<argument /> <!-- verify user signature lifetime -->
31+
<argument /> <!-- verify user signature path generation method -->
3132
</service>
3233
</services>
3334
</container>

src/VerifyEmailHelper.php

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ final class VerifyEmailHelper implements VerifyEmailHelperInterface
3838
* @var int The length of time in seconds that a signed URI is valid for after it is created
3939
*/
4040
private $lifetime;
41+
private $useRelativePath;
4142

42-
public function __construct(UrlGeneratorInterface $router, /* no typehint for BC with legacy PHP */ $uriSigner, VerifyEmailQueryUtility $queryUtility, VerifyEmailTokenGenerator $generator, int $lifetime)
43+
public function __construct(UrlGeneratorInterface $router, /* no typehint for BC with legacy PHP */ $uriSigner, VerifyEmailQueryUtility $queryUtility, VerifyEmailTokenGenerator $generator, int $lifetime, bool $useRelativePath)
4344
{
4445
$this->router = $router;
4546
$this->uriSigner = $uriSigner;
4647
$this->queryUtility = $queryUtility;
4748
$this->tokenGenerator = $generator;
4849
$this->lifetime = $lifetime;
50+
$this->useRelativePath = $useRelativePath;
4951

5052
if (!$uriSigner instanceof UriSigner) {
5153
/** @psalm-suppress UndefinedFunction */
@@ -63,10 +65,8 @@ public function generateSignature(string $routeName, string $userId, string $use
6365

6466
$uri = $this->router->generate($routeName, $extraParams, UrlGeneratorInterface::ABSOLUTE_URL);
6567

66-
$signature = $this->uriSigner->sign($uri);
67-
6868
/** @psalm-suppress PossiblyFalseArgument */
69-
return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $signature, $generatedAt);
69+
return new VerifyEmailSignatureComponents(\DateTimeImmutable::createFromFormat('U', (string) $expiryTimestamp), $this->getSignedUrl($uri), $generatedAt);
7070
}
7171

7272
public function validateEmailConfirmation(string $signedUrl, string $userId, string $userEmail): void
@@ -111,4 +111,57 @@ public function validateEmailConfirmationFromRequest(Request $request, string $u
111111
throw new WrongEmailVerifyException();
112112
}
113113
}
114+
115+
private function generateAbsolutePath(string $absoluteUri): string
116+
{
117+
$parsedUri = parse_url($absoluteUri);
118+
\assert(\is_array($parsedUri), 'Could not parse the provided URI.');
119+
120+
$path = $parsedUri['path'] ?? '';
121+
$query = $this->getQueryStringFromParsedUrl($parsedUri);
122+
$fragment = isset($parsedUri['fragment']) ? '#'.$parsedUri['fragment'] : '';
123+
124+
return $path.$query.$fragment;
125+
}
126+
127+
public function generateSigningString(string $uri): string
128+
{
129+
if (!$this->useRelativePath) {
130+
return $uri;
131+
}
132+
133+
return $this->generateAbsolutePath($uri);
134+
}
135+
136+
private function generateBaseUrl(string $absoluteUri): string
137+
{
138+
$parsedUri = parse_url($absoluteUri);
139+
$scheme = isset($parsedUri['scheme']) ? $parsedUri['scheme'].'://' : '';
140+
$host = $parsedUri['host'] ?? '';
141+
142+
return $scheme.$host;
143+
}
144+
145+
private function getSignedUrl(string $uri): string
146+
{
147+
$signature = $this->uriSigner->sign($this->generateSigningString($uri));
148+
149+
if (false === $this->useRelativePath) {
150+
return $signature;
151+
}
152+
153+
return $this->generateBaseUrl($uri).$signature;
154+
}
155+
156+
/**
157+
* @param array{scheme?: string, host?: string, port?: int, user?: string, pass?: string, query?: string, path?: string, fragment?: string} $parsedUrl
158+
*/
159+
private function getQueryStringFromParsedUrl(array $parsedUrl): string
160+
{
161+
if (!\array_key_exists('query', $parsedUrl)) {
162+
return '';
163+
}
164+
165+
return $parsedUrl['query'] ? ('?'.$parsedUrl['query']) : '';
166+
}
114167
}

tests/AcceptanceTests/VerifyEmailAcceptanceTest.php

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,83 @@ public function testValidateUsingRequestObject(): void
117117
$this->assertTrue(true, 'Test correctly does not throw an exception');
118118
}
119119

120-
private function getBootedKernel(): KernelInterface
120+
public function testGenerateSignatureWithRelativePath(): void
121+
{
122+
$kernel = $this->getBootedKernel(['use_relative_path' => true]);
123+
124+
$container = $kernel->getContainer();
125+
126+
/** @var VerifyEmailHelper $helper */
127+
$helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper;
128+
129+
$components = $helper->generateSignature('verify-test', '1234', 'jr@rushlow.dev');
130+
131+
$signature = $components->getSignedUrl();
132+
133+
$expiresAt = $components->getExpiresAt()->getTimestamp();
134+
135+
$expectedUserData = json_encode(['1234', 'jr@rushlow.dev']);
136+
137+
$expectedToken = base64_encode(hash_hmac('sha256', $expectedUserData, 'foo', true));
138+
139+
$expectedSignature = base64_encode(hash_hmac(
140+
'sha256',
141+
\sprintf('/verify/user?expires=%s&token=%s', $expiresAt, urlencode($expectedToken)),
142+
'foo',
143+
true
144+
));
145+
146+
$parsed = parse_url($signature);
147+
148+
if (!\is_array($parsed) || !isset($parsed['query'])) {
149+
throw new \RuntimeException('Invalid signature URL');
150+
}
151+
152+
parse_str($parsed['query'], $result);
153+
154+
self::assertIsString($result['signature']);
155+
self::assertTrue(hash_equals($expectedSignature, $result['signature']));
156+
self::assertSame(
157+
\sprintf('/verify/user?expires=%s&signature=%s&token=%s', $expiresAt, urlencode($expectedSignature), urlencode($expectedToken)),
158+
strstr($signature, '/verify/user')
159+
);
160+
}
161+
162+
public function testValidateEmailSignatureWithRelativePath(): void
163+
{
164+
$kernel = $this->getBootedKernel(['use_relative_path' => true]);
165+
166+
$container = $kernel->getContainer();
167+
168+
/** @var VerifyEmailHelper $helper */
169+
$helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper;
170+
$expires = new \DateTimeImmutable('+1 hour');
171+
172+
$uriToTest = \sprintf(
173+
'/verify/user?%s',
174+
http_build_query([
175+
'expires' => $expires->getTimestamp(),
176+
'token' => base64_encode(hash_hmac(
177+
'sha256',
178+
json_encode(['1234', 'jr@rushlow.dev']),
179+
'foo',
180+
true
181+
)),
182+
])
183+
);
184+
185+
$signature = base64_encode(hash_hmac('sha256', $uriToTest, 'foo', true));
186+
187+
$test = \sprintf('%s&signature=%s', $uriToTest, urlencode($signature));
188+
189+
$helper->validateEmailConfirmation($test, '1234', 'jr@rushlow.dev');
190+
$this->assertTrue(true, 'Test correctly does not throw an exception');
191+
}
192+
193+
private function getBootedKernel(array $customConfig = []): KernelInterface
121194
{
122195
$builder = new ContainerBuilder();
196+
123197
$builder->autowire(VerifyEmailAcceptanceFixture::class)
124198
->setPublic(true)
125199
->setArgument(1, new Reference('symfonycasts.verify_email.uri_signer'))
@@ -128,7 +202,9 @@ private function getBootedKernel(): KernelInterface
128202

129203
$kernel = new VerifyEmailTestKernel(
130204
$builder,
131-
['verify-test' => '/verify/user']
205+
['verify-test' => '/verify/user'],
206+
[],
207+
$customConfig
132208
);
133209

134210
$kernel->boot();

tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ private function getTestSignedUri(): string
106106
return \sprintf('/verify?%s', $sortedParams);
107107
}
108108

109-
private function getHelper(): VerifyEmailHelperInterface
109+
private function getHelper(bool $useRelativePath = false): VerifyEmailHelperInterface
110110
{
111111
if (class_exists(UriSigner::class)) {
112112
$this->uriSigner = new UriSigner('foo', 'signature');
@@ -119,7 +119,8 @@ private function getHelper(): VerifyEmailHelperInterface
119119
$this->uriSigner,
120120
new VerifyEmailQueryUtility(),
121121
new VerifyEmailTokenGenerator('foo'),
122-
3600
122+
3600,
123+
$useRelativePath
123124
);
124125
}
125126
}

0 commit comments

Comments
 (0)