294 lines
13 KiB
PHP
294 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Service;
|
|
|
|
use App\Email\Mailer;
|
|
use App\Entity\User;
|
|
use App\Repository\UserRepository;
|
|
use App\Service\PasswordResetManager;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Log\NullLogger;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\UriSigner;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class PasswordResetManagerTest extends TestCase
|
|
{
|
|
public function testRequestPasswordResetStoresNonceAndSendsSignedLink(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('old-nonce');
|
|
$now = time();
|
|
|
|
$userRepository = $this->createMock(UserRepository::class);
|
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
|
$mailer = $this->createMock(Mailer::class);
|
|
$uriSigner = new UriSigner('test-secret');
|
|
$generatedRoute = null;
|
|
$generatedParameters = null;
|
|
$generatedReferenceType = null;
|
|
$capturedContext = null;
|
|
$capturedOptions = null;
|
|
|
|
$userRepository
|
|
->expects(self::once())
|
|
->method('findOneByNormalizedEmail')
|
|
->with('[email protected]')
|
|
->willReturn($user);
|
|
$entityManager->expects(self::once())->method('flush');
|
|
$passwordHasher->expects(self::never())->method('hashPassword');
|
|
$urlGenerator
|
|
->expects(self::once())
|
|
->method('generate')
|
|
->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string {
|
|
$generatedRoute = $route;
|
|
$generatedParameters = $parameters;
|
|
$generatedReferenceType = $referenceType;
|
|
|
|
return 'https://example.test/reset-password/confirm?'.http_build_query($parameters);
|
|
});
|
|
$mailer
|
|
->expects(self::once())
|
|
->method('createAndSendEmail')
|
|
->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void {
|
|
$capturedContext = $context;
|
|
$capturedOptions = $options;
|
|
});
|
|
|
|
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
|
|
|
self::assertTrue($manager->requestPasswordReset(' [email protected] '));
|
|
self::assertSame('app_reset_password_confirm', $generatedRoute);
|
|
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
|
|
self::assertSame('[email protected]', $generatedParameters['email']);
|
|
self::assertSame($user->getPasswordResetNonce(), $generatedParameters['nonce']);
|
|
self::assertIsInt($generatedParameters['expires']);
|
|
self::assertGreaterThan($now, $generatedParameters['expires']);
|
|
self::assertLessThanOrEqual($now + 3600, $generatedParameters['expires']);
|
|
self::assertSame('[email protected]', $capturedOptions['to']);
|
|
self::assertSame('Dein MyE&P Passwort zurücksetzen', $capturedOptions['subject']);
|
|
self::assertSame('email/password_reset.html.twig', $capturedOptions['template']);
|
|
self::assertArrayHasKey('resetUrl', $capturedContext);
|
|
self::assertTrue($uriSigner->check($capturedContext['resetUrl']));
|
|
|
|
$parts = parse_url($capturedContext['resetUrl']);
|
|
parse_str($parts['query'] ?? '', $query);
|
|
self::assertSame('[email protected]', $query['email']);
|
|
self::assertSame($user->getPasswordResetNonce(), $query['nonce']);
|
|
self::assertArrayHasKey('expires', $query);
|
|
self::assertNotSame('old-nonce', $user->getPasswordResetNonce());
|
|
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce());
|
|
}
|
|
|
|
public function testValidateResetRequestAcceptsValidSignedRequest(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('current-nonce');
|
|
|
|
$manager = $this->createManagerForValidation($user);
|
|
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() + 3600), 'GET');
|
|
|
|
self::assertSame($user, $manager->validateResetRequest($request));
|
|
}
|
|
|
|
public function testValidateResetRequestRejectsTamperedLink(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('current-nonce');
|
|
|
|
$manager = $this->createManagerForValidation($user);
|
|
$signedUrl = $this->buildResetUrl('[email protected]', 'current-nonce', time() + 3600);
|
|
$parts = parse_url($signedUrl);
|
|
parse_str($parts['query'] ?? '', $query);
|
|
$query['nonce'] = 'tampered-nonce';
|
|
$request = Request::create(
|
|
'https://example.test/reset-password/confirm?'.http_build_query($query),
|
|
'GET',
|
|
);
|
|
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$manager->validateResetRequest($request);
|
|
}
|
|
|
|
public function testValidateResetRequestRejectsExpiredLink(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('current-nonce');
|
|
|
|
$manager = $this->createManagerForValidation($user);
|
|
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() - 60), 'GET');
|
|
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$manager->validateResetRequest($request);
|
|
}
|
|
|
|
public function testValidateResetRequestRejectsNonceMismatch(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('different-nonce');
|
|
|
|
$manager = $this->createManagerForValidation($user);
|
|
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() + 3600), 'GET');
|
|
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$manager->validateResetRequest($request);
|
|
}
|
|
|
|
public function testResetPasswordHashesPasswordAndRotatesNonce(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('current-nonce');
|
|
$user->setPreviousPassword('legacy-backup');
|
|
|
|
$userRepository = $this->createMock(UserRepository::class);
|
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
|
$mailer = $this->createMock(Mailer::class);
|
|
|
|
$passwordHasher
|
|
->expects(self::once())
|
|
->method('hashPassword')
|
|
->with($user, 'new-password')
|
|
->willReturn('hashed-password');
|
|
$entityManager->expects(self::once())->method('flush');
|
|
$userRepository->expects(self::never())->method('findOneByNormalizedEmail');
|
|
$urlGenerator->expects(self::never())->method('generate');
|
|
$mailer->expects(self::never())->method('createAndSendEmail');
|
|
|
|
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
|
|
|
$manager->resetPassword($user, 'new-password');
|
|
|
|
self::assertSame('hashed-password', $user->getPassword());
|
|
self::assertSame('legacy-backup', $user->getPreviousPassword());
|
|
self::assertNotSame('current-nonce', $user->getPasswordResetNonce());
|
|
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce());
|
|
}
|
|
|
|
public function testNewPasswordResetRequestInvalidatesPreviousLink(): void
|
|
{
|
|
$user = new User('[email protected]');
|
|
$user->setPasswordResetNonce('old-nonce');
|
|
|
|
$userRepository = $this->createMock(UserRepository::class);
|
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
|
$mailer = $this->createMock(Mailer::class);
|
|
$uriSigner = new UriSigner('test-secret');
|
|
$generatedRoutes = [];
|
|
$generatedParametersList = [];
|
|
|
|
$userRepository
|
|
->expects(self::exactly(3))
|
|
->method('findOneByNormalizedEmail')
|
|
->willReturnCallback(static function (string $email) use ($user): ?User {
|
|
return '[email protected]' === $email ? $user : null;
|
|
});
|
|
$entityManager->expects(self::exactly(2))->method('flush');
|
|
$passwordHasher->expects(self::never())->method('hashPassword');
|
|
$urlGenerator
|
|
->expects(self::exactly(2))
|
|
->method('generate')
|
|
->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoutes, &$generatedParametersList): string {
|
|
$generatedRoutes[] = $route;
|
|
$generatedParametersList[] = $parameters;
|
|
|
|
return 'https://example.test/reset-password/confirm?'.http_build_query($parameters);
|
|
});
|
|
|
|
$capturedUrls = [];
|
|
$mailer
|
|
->expects(self::exactly(2))
|
|
->method('createAndSendEmail')
|
|
->willReturnCallback(static function (array $context, array $options) use (&$capturedUrls): void {
|
|
$capturedUrls[] = $context['resetUrl'];
|
|
});
|
|
|
|
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
|
|
|
self::assertTrue($manager->requestPasswordReset('[email protected]'));
|
|
self::assertTrue($manager->requestPasswordReset('[email protected]'));
|
|
|
|
self::assertCount(2, $generatedRoutes);
|
|
self::assertSame(['app_reset_password_confirm', 'app_reset_password_confirm'], $generatedRoutes);
|
|
self::assertCount(2, $generatedParametersList);
|
|
self::assertSame('[email protected]', $generatedParametersList[0]['email']);
|
|
self::assertSame('[email protected]', $generatedParametersList[1]['email']);
|
|
self::assertNotSame($generatedParametersList[0]['nonce'], $generatedParametersList[1]['nonce']);
|
|
|
|
self::assertCount(2, $capturedUrls);
|
|
self::assertTrue($uriSigner->check($capturedUrls[0]));
|
|
self::assertTrue($uriSigner->check($capturedUrls[1]));
|
|
self::assertNotSame($capturedUrls[0], $capturedUrls[1]);
|
|
$firstQuery = [];
|
|
$secondQuery = [];
|
|
parse_str(parse_url($capturedUrls[0], PHP_URL_QUERY) ?? '', $firstQuery);
|
|
parse_str(parse_url($capturedUrls[1], PHP_URL_QUERY) ?? '', $secondQuery);
|
|
self::assertSame('[email protected]', $firstQuery['email']);
|
|
self::assertSame('[email protected]', $secondQuery['email']);
|
|
self::assertNotSame($firstQuery['nonce'], $secondQuery['nonce']);
|
|
$this->expectException(NotFoundHttpException::class);
|
|
$manager->validateResetRequest(Request::create($capturedUrls[0], 'GET'));
|
|
}
|
|
|
|
private function createManager(
|
|
UserRepository $userRepository,
|
|
EntityManagerInterface $entityManager,
|
|
UserPasswordHasherInterface $passwordHasher,
|
|
UrlGeneratorInterface $urlGenerator,
|
|
Mailer $mailer,
|
|
): PasswordResetManager {
|
|
return new PasswordResetManager(
|
|
$userRepository,
|
|
$entityManager,
|
|
$passwordHasher,
|
|
$urlGenerator,
|
|
new UriSigner('test-secret'),
|
|
$mailer,
|
|
new NullLogger(),
|
|
);
|
|
}
|
|
|
|
private function createManagerForValidation(User $user): PasswordResetManager
|
|
{
|
|
$userRepository = $this->createMock(UserRepository::class);
|
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
|
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
|
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
|
$mailer = $this->createMock(Mailer::class);
|
|
|
|
$userRepository
|
|
->method('findOneByNormalizedEmail')
|
|
->willReturnCallback(static function (string $email) use ($user): ?User {
|
|
return '[email protected]' === $email ? $user : null;
|
|
});
|
|
$entityManager->expects(self::never())->method('flush');
|
|
$passwordHasher->expects(self::never())->method('hashPassword');
|
|
$urlGenerator->expects(self::never())->method('generate');
|
|
$mailer->expects(self::never())->method('createAndSendEmail');
|
|
|
|
return $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
|
}
|
|
|
|
private function buildResetUrl(string $email, string $nonce, int $expires): string
|
|
{
|
|
$uriSigner = new UriSigner('test-secret');
|
|
$url = 'https://example.test/reset-password/confirm?'.http_build_query([
|
|
'email' => $email,
|
|
'nonce' => $nonce,
|
|
'expires' => $expires,
|
|
]);
|
|
|
|
return $uriSigner->sign($url);
|
|
}
|
|
}
|