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

[1.x] use UriSigner::checkRequest() to validate signatures using a Request object #157

Merged
merged 12 commits into from
Mar 12, 2024
6 changes: 6 additions & 0 deletions src/Util/VerifyEmailQueryUtility.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ class VerifyEmailQueryUtility
{
public function getTokenFromQuery(string $uri): string
{
/** @psalm-suppress UndefinedFunction */
@trigger_deprecation('symfonycasts/verify-email-bundle', '1.17.0', 'This method is deprecated and will be removed in 2.0.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious that this needs the pslam-suppress... since the library does require the deprecations-contract

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya, I don't get it either - locally I don't get the error. But on CI, we do. I think it has something to do with psalm not picking up the autoload.

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check into this outside of this PR.


$params = $this->getQueryParams($uri);

return $params['token'];
}

public function getExpiryTimestamp(string $uri): int
{
/** @psalm-suppress UndefinedFunction */
@trigger_deprecation('symfonycasts/verify-email-bundle', '1.17.0', 'This method is deprecated and will be removed in 2.0.');

$params = $this->getQueryParams($uri);

if (empty($params['expires'])) {
Expand Down
31 changes: 31 additions & 0 deletions src/VerifyEmailHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace SymfonyCasts\Bundle\VerifyEmail;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\UriSigner as LegacyUriSigner;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
Expand Down Expand Up @@ -45,6 +46,11 @@ public function __construct(UrlGeneratorInterface $router, /* no typehint for BC
$this->queryUtility = $queryUtility;
$this->tokenGenerator = $generator;
$this->lifetime = $lifetime;

if (!$uriSigner instanceof UriSigner) {
/** @psalm-suppress UndefinedFunction */
@trigger_deprecation('symfonycasts/verify-email-bundle', '1.17.0', 'Not providing an instance of %s is deprecated. It will be required in v2.0', UriSigner::class);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also deprecated "passing $queryUtility as 3rd argument is deprecated. The arg will be removed in the future..." but usually when we do this - the param is = nullable. We can't do that here because the last 2 arg's are not nullable.

Do we let this ride as is and document this "non-deprecated" change in Upgrade.md or add the deprecation anyway without providing a way for the user to "silence" it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe we lave the argument as is & not deprecated for now. Then we deprecate it in v2, once it's fully unused. There may be a better way to handle this, but I can't see it at the moment.

}

public function generateSignature(string $routeName, string $userId, string $userEmail, array $extraParams = []): VerifyEmailSignatureComponents
Expand All @@ -65,6 +71,9 @@ public function generateSignature(string $routeName, string $userId, string $use

public function validateEmailConfirmation(string $signedUrl, string $userId, string $userEmail): void
{
/** @psalm-suppress UndefinedFunction */
@trigger_deprecation('symfonycasts/verify-email-bundle', '1.17.0', '%s() is deprecated and will be removed in v2.0, use validateEmailConfirmationFromRequest() instead.', __METHOD__);

if (!$this->uriSigner->check($signedUrl)) {
throw new InvalidSignatureException();
}
Expand All @@ -80,4 +89,26 @@ public function validateEmailConfirmation(string $signedUrl, string $userId, str
throw new WrongEmailVerifyException();
}
}

public function validateEmailConfirmationFromRequest(Request $request, string $userId, string $userEmail): void
jrushlow marked this conversation as resolved.
Show resolved Hide resolved
{
/** @legacy - Remove in 2.0 */
if (!$this->uriSigner instanceof UriSigner) {
throw new \RuntimeException(sprintf('An instance of %s is required, provided by symfony/http-kernel >=6.4, to validate an email confirmation.', UriSigner::class));
}

if (!$this->uriSigner->checkRequest($request)) {
throw new InvalidSignatureException();
}

if ($request->query->getInt('expires') <= time()) {
throw new ExpiredSignatureException();
}

$knownToken = $this->tokenGenerator->createToken($userId, $userEmail);

if (!hash_equals($knownToken, $request->query->getString('token'))) {
throw new WrongEmailVerifyException();
}
}
}
5 changes: 5 additions & 0 deletions src/VerifyEmailHelperInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace SymfonyCasts\Bundle\VerifyEmail;

use Symfony\Component\HttpFoundation\Request;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\Model\VerifyEmailSignatureComponents;

Expand All @@ -17,6 +18,8 @@
*
* @author Jesse Rushlow <jr@rushlow.dev>
* @author Ryan Weaver <ryan@symfonycasts.com>
*
* @method void validateEmailConfirmationFromRequest(Request $request, string $userId, string $userEmail)
*/
interface VerifyEmailHelperInterface
{
Expand All @@ -33,6 +36,8 @@ interface VerifyEmailHelperInterface
public function generateSignature(string $routeName, string $userId, string $userEmail, array $extraParams = []): VerifyEmailSignatureComponents;

/**
* @deprecated since v1.17.0, use validateEmailConfirmationFromRequest instead.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this even the right way to deprecate an interface method?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To the best of my knowledge, yes - but not sure.

*
* Validate a signed an email confirmation request.
*
* If something is wrong with the email confirmation, a
Expand Down
40 changes: 40 additions & 0 deletions tests/AcceptanceTests/VerifyEmailAcceptanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\KernelInterface;
use SymfonyCasts\Bundle\VerifyEmail\Tests\VerifyEmailTestKernel;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelper;
Expand All @@ -22,6 +24,11 @@
*/
final class VerifyEmailAcceptanceTest extends TestCase
{
/**
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testGenerateSignature(): void
{
$kernel = $this->getBootedKernel();
Expand Down Expand Up @@ -57,6 +64,7 @@ public function testGenerateSignature(): void
);
}

/** @group legacy */
public function testValidateEmailSignature(): void
{
$kernel = $this->getBootedKernel();
Expand Down Expand Up @@ -88,6 +96,38 @@ public function testValidateEmailSignature(): void
$this->assertTrue(true, 'Test correctly does not throw an exception');
}

public function testValidateUsingRequestObject(): void
{
if (!class_exists(UriSigner::class)) {
$this->markTestSkipped('Requires symfony/http-foundation 6.4+');
}
$container = $this->getBootedKernel()->getContainer();

/** @var VerifyEmailHelper $helper */
$helper = $container->get(VerifyEmailAcceptanceFixture::class)->helper;
$expires = new \DateTimeImmutable('+1 hour');

$uriToTest = sprintf(
'http://localhost/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->validateEmailConfirmationFromRequest(Request::create(uri: $test), '1234', 'jr@rushlow.dev');
$this->assertTrue(true, 'Test correctly does not throw an exception');
}

private function getBootedKernel(): KernelInterface
{
$builder = new ContainerBuilder();
Expand Down
10 changes: 10 additions & 0 deletions tests/FunctionalTests/VerifyEmailHelperFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ protected function setUp(): void
$this->mockRouter = $this->createMock(UrlGeneratorInterface::class);
}

/**
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testGenerateSignature(): void
{
$token = $this->getTestToken();
Expand All @@ -65,6 +70,11 @@ public function testGenerateSignature(): void
self::assertTrue(hash_equals($knownSignature, $testSignature));
}

/**
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testValidSignature(): void
{
$testSignature = $this->getTestSignedUri();
Expand Down
5 changes: 5 additions & 0 deletions tests/IntegrationTests/VerifyEmailBundleAutowireTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
*/
final class VerifyEmailBundleAutowireTest extends TestCase
{
/**
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testVerifyEmailBundleInterfaceIsAutowiredByContainer(): void
{
$builder = new ContainerBuilder();
Expand Down
4 changes: 4 additions & 0 deletions tests/IntegrationTests/VerifyEmailServiceDefinitionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public function bundleServiceDefinitionDataProvider(): \Generator

/**
* @dataProvider bundleServiceDefinitionDataProvider
*
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testBundleServiceDefinitions(string $definition): void
{
Expand Down
2 changes: 2 additions & 0 deletions tests/UnitTests/Util/VerifyEmailQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
/**
* @author Jesse Rushlow <jr@rushlow.dev>
* @author Ryan Weaver <ryan@symfonycasts.com>
*
* @group legacy
*/
final class VerifyEmailQueryTest extends TestCase
{
Expand Down
91 changes: 91 additions & 0 deletions tests/UnitTests/VerifyEmailHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ClockMock;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\UriSigner as LegacyUriSigner;
use Symfony\Component\Routing\RouterInterface;
Expand Down Expand Up @@ -49,6 +50,11 @@ protected function setUp(): void
$this->tokenGenerator = $this->createMock(VerifyEmailTokenGenerator::class);
}

/**
* @legacy - Remove annotation in 2.0
*
* @group legacy
*/
public function testSignatureIsGenerated(): void
{
$expires = time() + 3600;
Expand Down Expand Up @@ -82,6 +88,7 @@ public function testSignatureIsGenerated(): void
self::assertSame($expectedSignedUrl, $components->getSignedUrl());
}

/** @group legacy */
public function testValidationThrowsEarlyOnInvalidSignature(): void
{
$signedUrl = '/verify?expires=1&signature=1234%token=xyz';
Expand Down Expand Up @@ -115,6 +122,7 @@ public function testValidationThrowsEarlyOnInvalidSignature(): void
$helper->validateEmailConfirmation($signedUrl, '1234', 'jr@rushlow.dev');
}

/** @group legacy */
public function testExceptionThrownWithExpiredSignature(): void
{
$timestamp = (new \DateTimeImmutable('-1 seconds'))->getTimestamp();
Expand All @@ -139,6 +147,7 @@ public function testExceptionThrownWithExpiredSignature(): void
$helper->validateEmailConfirmation($signedUrl, '1234', 'jr@rushlow.dev');
}

/** @group legacy */
public function testValidationThrowsWithInvalidToken(): void
{
$signedUrl = '/verify?token=badToken';
Expand Down Expand Up @@ -177,6 +186,88 @@ public function testValidationThrowsWithInvalidToken(): void
$helper->validateEmailConfirmation($signedUrl, '1234', 'jr@rushlow.dev');
}

public function testValidationWithRequestThrowsEarlyOnInvalidSignature(): void
{
/** @legacy - Remove conditional in 2.0 */
if (!class_exists(UriSigner::class)) {
$this->markTestSkipped('Requires symfony/http-foundation 6.4+');
}

$request = Request::create('/verify?expires=1&signature=1234%token=xyz');

$this->mockSigner
->expects($this->once())
->method('checkRequest')
->with($request)
->willReturn(false)
;

$this->tokenGenerator
->expects($this->never())
->method('createToken')
;

$helper = $this->getHelper();

$this->expectException(InvalidSignatureException::class);

$helper->validateEmailConfirmationFromRequest($request, '1234', 'jr@rushlow.dev');
}

public function testExceptionThrownWithExpiredSignatureFromRequest(): void
{
/** @legacy - Remove conditional in 2.0 */
if (!class_exists(UriSigner::class)) {
$this->markTestSkipped('Requires symfony/http-foundation 6.4+');
}

$timestamp = (new \DateTimeImmutable('-1 seconds'))->getTimestamp();
$signedUrl = '/?expires='.$timestamp;

$request = Request::create($signedUrl);

$this->mockSigner
->expects($this->once())
->method('checkRequest')
->with($request)
->willReturn(true)
;

$this->expectException(ExpiredSignatureException::class);

$helper = $this->getHelper();
$helper->validateEmailConfirmationFromRequest($request, '1234', 'jr@rushlow.dev');
}

public function testValidationFromRequestThrowsWithInvalidToken(): void
{
/** @legacy - Remove conditional in 2.0 */
if (!class_exists(UriSigner::class)) {
$this->markTestSkipped('Requires symfony/http-foundation 6.4+');
}

$request = Request::create('/verify?expires=99999999999999&token=badToken');

$this->mockSigner
->expects($this->once())
->method('checkRequest')
->with($request)
->willReturn(true)
;

$this->tokenGenerator
->expects($this->once())
->method('createToken')
->with('1234', 'jr@rushlow.dev')
->willReturn(base64_encode(hash_hmac('sha256', 'data', 'foo', true)))
;

$this->expectException(WrongEmailVerifyException::class);

$helper = $this->getHelper();
$helper->validateEmailConfirmationFromRequest($request, '1234', 'jr@rushlow.dev');
}

private function getHelper(): VerifyEmailHelperInterface
{
return new VerifyEmailHelper($this->mockRouter, $this->mockSigner, $this->mockQueryUtility, $this->tokenGenerator, 3600);
Expand Down
Loading