Skip to content

Commit 93d5392

Browse files
michaelbrauner_mysterymindsMichaelBrauner
authored andcommitted
Signiture verification with relative path
1 parent 936b4b6 commit 93d5392

File tree

8 files changed

+210
-11
lines changed

8 files changed

+210
-11
lines changed

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: 53 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,53 @@ 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+
119+
$path = $parsedUri['path'] ?? '';
120+
$query = $this->getQueryStringFromParsedUrl($parsedUri);
121+
$fragment = isset($parsedUri['fragment']) ? '#'.$parsedUri['fragment'] : '';
122+
123+
return $path.$query.$fragment;
124+
}
125+
126+
public function generateSigningString(string $uri): string
127+
{
128+
if (!$this->useRelativePath) {
129+
return $uri;
130+
}
131+
132+
return $this->generateAbsolutePath($uri);
133+
}
134+
135+
private function generateBaseUrl(string $absoluteUri): string
136+
{
137+
$parsedUri = parse_url($absoluteUri);
138+
$scheme = isset($parsedUri['scheme']) ? $parsedUri['scheme'].'://' : '';
139+
$host = $parsedUri['host'] ?? '';
140+
141+
return $scheme.$host;
142+
}
143+
144+
private function getSignedUrl(string $uri): string
145+
{
146+
$signature = $this->uriSigner->sign($this->generateSigningString($uri));
147+
148+
if ($this->useRelativePath === false) {
149+
return $signature;
150+
}
151+
152+
return $this->generateBaseUrl($uri).$signature;
153+
}
154+
155+
private function getQueryStringFromParsedUrl(array $parsedUrl): string
156+
{
157+
if (!\array_key_exists('query', $parsedUrl)) {
158+
return '';
159+
}
160+
161+
return $parsedUrl['query'] ? ('?'.$parsedUrl['query']) : '';
162+
}
114163
}

tests/AcceptanceTests/VerifyEmailAcceptanceTest.php

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,77 @@ 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', '[email protected]');
130+
131+
$signature = $components->getSignedUrl();
132+
133+
$expiresAt = $components->getExpiresAt()->getTimestamp();
134+
135+
$expectedUserData = json_encode(['1234', '[email protected]']);
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+
parse_str($parsed['query'], $result);
148+
149+
self::assertTrue(hash_equals($expectedSignature, $result['signature']));
150+
self::assertSame(
151+
sprintf('/verify/user?expires=%s&signature=%s&token=%s', $expiresAt, urlencode($expectedSignature), urlencode($expectedToken)),
152+
strstr($signature, '/verify/user')
153+
);
154+
}
155+
156+
public function testValidateEmailSignatureWithRelativePath(): void
157+
{
158+
$kernel = $this->getBootedKernel(['use_relative_path' => true]);
159+
160+
$container = $kernel->getContainer();
161+
162+
/** @var VerifyEmailHelper $helper */
163+
$helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper;
164+
$expires = new \DateTimeImmutable('+1 hour');
165+
166+
$uriToTest = sprintf(
167+
'/verify/user?%s',
168+
http_build_query([
169+
'expires' => $expires->getTimestamp(),
170+
'token' => base64_encode(hash_hmac(
171+
'sha256',
172+
json_encode(['1234', '[email protected]']),
173+
'foo',
174+
true
175+
)),
176+
])
177+
);
178+
179+
$signature = base64_encode(hash_hmac('sha256', $uriToTest, 'foo', true));
180+
181+
$test = sprintf('%s&signature=%s', $uriToTest, urlencode($signature));
182+
183+
$helper->validateEmailConfirmation($test, '1234', '[email protected]');
184+
$this->assertTrue(true, 'Test correctly does not throw an exception');
185+
}
186+
187+
private function getBootedKernel(array $customConfig = []): KernelInterface
121188
{
122189
$builder = new ContainerBuilder();
190+
123191
$builder->autowire(VerifyEmailAcceptanceFixture::class)
124192
->setPublic(true)
125193
->setArgument(1, new Reference('symfonycasts.verify_email.uri_signer'))
@@ -128,7 +196,9 @@ private function getBootedKernel(): KernelInterface
128196

129197
$kernel = new VerifyEmailTestKernel(
130198
$builder,
131-
['verify-test' => '/verify/user']
199+
['verify-test' => '/verify/user'],
200+
[],
201+
$customConfig
132202
);
133203

134204
$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
}

tests/UnitTests/VerifyEmailHelperTest.php

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,38 @@ public function testSignatureIsGenerated(): void
8888
self::assertSame($expectedSignedUrl, $components->getSignedUrl());
8989
}
9090

91+
public function testSignatureIsGeneratedWithRelativePath(): void
92+
{
93+
$expires = time() + 3600;
94+
95+
$expectedSignedUrl = sprintf('/verify?expires=%s&signature=1234&token=hashedToken', $expires);
96+
97+
$this->tokenGenerator
98+
->expects($this->once())
99+
->method('createToken')
100+
->with('1234', '[email protected]')
101+
->willReturn('hashedToken')
102+
;
103+
104+
$this->mockRouter
105+
->expects($this->once())
106+
->method('generate')
107+
->with('app_verify_route', ['token' => 'hashedToken', 'expires' => $expires])
108+
->willReturn(sprintf('/verify?expires=%s&token=hashedToken', $expires))
109+
;
110+
111+
$this->mockSigner
112+
->expects($this->once())
113+
->method('sign')
114+
->with(sprintf('/verify?expires=%s&token=hashedToken', $expires))
115+
->willReturn($expectedSignedUrl)
116+
;
117+
118+
$helper = $this->getHelper(true);
119+
$components = $helper->generateSignature('app_verify_route', '1234', '[email protected]');
120+
self::assertSame($expectedSignedUrl, $components->getSignedUrl());
121+
}
122+
91123
/** @group legacy */
92124
public function testValidationThrowsEarlyOnInvalidSignature(): void
93125
{
@@ -122,6 +154,39 @@ public function testValidationThrowsEarlyOnInvalidSignature(): void
122154
$helper->validateEmailConfirmation($signedUrl, '1234', '[email protected]');
123155
}
124156

157+
public function testValidationThrowsEarlyOnInvalidSignatureWithRelativePath(): void
158+
{
159+
$signedUrl = '/verify?expires=1&signature=1234%token=xyz';
160+
161+
$this->mockSigner
162+
->expects($this->once())
163+
->method('check')
164+
->with($signedUrl)
165+
->willReturn(false)
166+
;
167+
168+
$this->mockQueryUtility
169+
->expects($this->never())
170+
->method('getExpiryTimestamp')
171+
;
172+
173+
$this->mockQueryUtility
174+
->expects($this->never())
175+
->method('getTokenFromQuery')
176+
;
177+
178+
$this->tokenGenerator
179+
->expects($this->never())
180+
->method('createToken')
181+
;
182+
183+
$helper = $this->getHelper(true);
184+
185+
$this->expectException(InvalidSignatureException::class);
186+
187+
$helper->validateEmailConfirmation($signedUrl, '1234', '[email protected]');
188+
}
189+
125190
/** @group legacy */
126191
public function testExceptionThrownWithExpiredSignature(): void
127192
{
@@ -268,8 +333,8 @@ public function testValidationFromRequestThrowsWithInvalidToken(): void
268333
$helper->validateEmailConfirmationFromRequest($request, '1234', '[email protected]');
269334
}
270335

271-
private function getHelper(): VerifyEmailHelperInterface
336+
private function getHelper(?bool $useRelativePath = false): VerifyEmailHelperInterface
272337
{
273-
return new VerifyEmailHelper($this->mockRouter, $this->mockSigner, $this->mockQueryUtility, $this->tokenGenerator, 3600);
338+
return new VerifyEmailHelper($this->mockRouter, $this->mockSigner, $this->mockQueryUtility, $this->tokenGenerator, 3600, $useRelativePath);
274339
}
275340
}

tests/VerifyEmailTestKernel.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,19 @@ class VerifyEmailTestKernel extends Kernel
2929
private $builder;
3030
private $routes;
3131
private $extraBundles;
32+
/** @var array */
33+
private $customConfig;
3234

3335
/**
3436
* @param array $routes Routes to be added to the container e.g. ['name' => 'path']
3537
* @param BundleInterface[] $bundles Additional bundles to be registered e.g. [new Bundle()]
3638
*/
37-
public function __construct(?ContainerBuilder $builder = null, array $routes = [], array $bundles = [])
39+
public function __construct(?ContainerBuilder $builder = null, array $routes = [], array $bundles = [], array $customConfig = [])
3840
{
3941
$this->builder = $builder;
4042
$this->routes = $routes;
4143
$this->extraBundles = $bundles;
44+
$this->customConfig = $customConfig;
4245

4346
parent::__construct('test', true);
4447
}
@@ -54,6 +57,7 @@ public function registerBundles(): iterable
5457
);
5558
}
5659

60+
/** @noinspection PhpParamsInspection */
5761
public function registerContainerConfiguration(LoaderInterface $loader): void
5862
{
5963
if (null === $this->builder) {
@@ -77,6 +81,10 @@ public function registerContainerConfiguration(LoaderInterface $loader): void
7781
]
7882
);
7983

84+
if (!empty($this->customConfig)) {
85+
$container->loadFromExtension('symfonycasts_verify_email', $this->customConfig);
86+
}
87+
8088
$container->register('kernel', static::class)
8189
->setPublic(true)
8290
;

0 commit comments

Comments
 (0)