Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make signature generation more flexible by allowing relative URLs #142

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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
Expand Down
1 change: 1 addition & 0 deletions src/Resources/config/verify_email_services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<argument type="service" id="symfonycasts.verify_email.query_utility" />
<argument type="service" id="symfonycasts.verify_email.token_generator" />
<argument /> <!-- verify user signature lifetime -->
<argument /> <!-- verify user signature path generation method -->
</service>
</services>
</container>
57 changes: 53 additions & 4 deletions src/VerifyEmailHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,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, UriSigner $uriSigner, VerifyEmailQueryUtility $queryUtility, VerifyEmailTokenGenerator $generator, int $lifetime)
public function __construct(UrlGeneratorInterface $router, UriSigner $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;
}

/**
Expand All @@ -56,10 +58,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);
}

/**
Expand All @@ -82,4 +82,53 @@ public function validateEmailConfirmation(string $signedUrl, string $userId, str
throw new WrongEmailVerifyException();
}
}

private function generateAbsolutePath(string $absoluteUri): string
{
$parsedUri = parse_url($absoluteUri);

$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 (!$this->useRelativePath) {
return $signature;
}

return $this->generateBaseUrl($uri).$signature;
}

private function getQueryStringFromParsedUrl(array $parsedUrl): string
{
if (!\array_key_exists('query', $parsedUrl)) {
return '';
}

return $parsedUrl['query'] ? ('?'.$parsedUrl['query']) : '';
}
}
74 changes: 72 additions & 2 deletions tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,86 @@ public function testValidateEmailSignature(): 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', '[email protected]');

$signature = $components->getSignedUrl();

$expiresAt = $components->getExpiresAt()->getTimestamp();

$expectedUserData = json_encode(['1234', '[email protected]']);

$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);
parse_str($parsed['query'], $result);

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', '[email protected]']),
'foo',
true
)),
])
);

$signature = base64_encode(hash_hmac('sha256', $uriToTest, 'foo', true));

$test = sprintf('%s&signature=%s', $uriToTest, urlencode($signature));

$helper->validateEmailConfirmation($test, '1234', '[email protected]');
$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)
;

$kernel = new VerifyEmailTestKernel(
$builder,
['verify-test' => '/verify/user']
['verify-test' => '/verify/user'],
[],
$customConfig
);

$kernel->boot();
Expand Down
11 changes: 7 additions & 4 deletions tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ public function testGenerateSignature(): void
->expects($this->once())
->method('generate')
->with('app_verify_route', ['expires' => $this->expiryTimestamp, 'token' => $token])
->willReturn(sprintf('/verify?expires=%s&token=%s', $this->expiryTimestamp, urlencode($token)))
;
->willReturn(sprintf('/verify?expires=%s&token=%s', $this->expiryTimestamp, urlencode($token)));

$result = $this->getHelper()->generateSignature('app_verify_route', '1234', '[email protected]');

Expand All @@ -73,6 +72,9 @@ public function testValidSignature(): void

$this->getHelper()->validateEmailConfirmation($testSignature, '1234', '[email protected]');
$this->assertTrue(true, 'Test correctly does not throw an exception');

$this->getHelper(true)->validateEmailConfirmation($testSignature, '1234', '[email protected]');
$this->assertTrue(true, 'Test correctly does not throw an exception');
}

private function getTestToken(): string
Expand Down Expand Up @@ -106,14 +108,15 @@ private function getTestSignedUri(): string
return sprintf('/verify?%s', $sortedParams);
}

private function getHelper(): VerifyEmailHelperInterface
private function getHelper(?bool $useRelativePath = false): VerifyEmailHelperInterface
{
return new VerifyEmailHelper(
$this->mockRouter,
new UriSigner('foo', 'signature'),
new VerifyEmailQueryUtility(),
new VerifyEmailTokenGenerator('foo'),
3600
3600,
$useRelativePath
);
}
}
69 changes: 67 additions & 2 deletions tests/UnitTests/VerifyEmailHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,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', '[email protected]')
->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', '[email protected]');
self::assertSame($expectedSignedUrl, $components->getSignedUrl());
}

public function testValidationThrowsEarlyOnInvalidSignature(): void
{
$signedUrl = '/verify?expires=1&signature=1234%token=xyz';
Expand Down Expand Up @@ -110,6 +142,39 @@ public function testValidationThrowsEarlyOnInvalidSignature(): void
$helper->validateEmailConfirmation($signedUrl, '1234', '[email protected]');
}

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', '[email protected]');
}

public function testExceptionThrownWithExpiredSignature(): void
{
$timestamp = (new \DateTimeImmutable('-1 seconds'))->getTimestamp();
Expand Down Expand Up @@ -172,8 +237,8 @@ public function testValidationThrowsWithInvalidToken(): void
$helper->validateEmailConfirmation($signedUrl, '1234', '[email protected]');
}

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);
}
}
10 changes: 9 additions & 1 deletion tests/VerifyEmailTestKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,19 @@ 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()]
*/
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);
}
Expand All @@ -54,6 +57,7 @@ public function registerBundles(): iterable
);
}

/** @noinspection PhpParamsInspection */
public function registerContainerConfiguration(LoaderInterface $loader): void
{
if (null === $this->builder) {
Expand All @@ -77,6 +81,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)
;
Expand Down