From 8bb7f08074635681e8b95762234c26f5cd447e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 18 May 2026 11:48:07 +0200 Subject: [PATCH] feat: local password reset --- migrations/Version20260518120000.php | 26 ++ src/Controller/ResetPasswordController.php | 64 +++- src/Entity/User.php | 15 + src/Repository/UserRepository.php | 9 + src/Service/PasswordResetManager.php | 138 +++++++++ templates/email/password_reset.html.twig | 27 ++ templates/reset_password/confirm.html.twig | 30 ++ templates/reset_password/index.html.twig | 2 +- templates/security/login.html.twig | 2 +- .../ResetPasswordControllerTest.php | 103 +++++++ tests/Service/PasswordResetManagerTest.php | 291 ++++++++++++++++++ 11 files changed, 688 insertions(+), 19 deletions(-) create mode 100644 migrations/Version20260518120000.php create mode 100644 src/Service/PasswordResetManager.php create mode 100644 templates/email/password_reset.html.twig create mode 100644 templates/reset_password/confirm.html.twig create mode 100644 tests/Controller/ResetPasswordControllerTest.php create mode 100644 tests/Service/PasswordResetManagerTest.php diff --git a/migrations/Version20260518120000.php b/migrations/Version20260518120000.php new file mode 100644 index 00000000..941035e9 --- /dev/null +++ b/migrations/Version20260518120000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE user ADD password_reset_nonce VARCHAR(64) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user DROP password_reset_nonce'); + } +} diff --git a/src/Controller/ResetPasswordController.php b/src/Controller/ResetPasswordController.php index 56c3fc4b..f023263b 100644 --- a/src/Controller/ResetPasswordController.php +++ b/src/Controller/ResetPasswordController.php @@ -2,11 +2,11 @@ namespace App\Controller; -use App\BusProNet\ApiClient; -use App\BusProNet\Exception\ApiClientException; -use Psr\Log\LoggerInterface; +use App\Service\PasswordResetManager; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank; class ResetPasswordController extends AbstractController { - public function __construct(private readonly ApiClient $apiClient, private readonly LoggerInterface $logger) + public function __construct(private readonly PasswordResetManager $passwordResetManager) { } @@ -45,19 +45,8 @@ class ResetPasswordController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - try { - $this->apiClient->resetPassword($form->get('email')->getData()); - $this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts – vorausgesetzt, bei deiner Buchung wurde eine E-Mail-Adresse angegeben.'); - $this->logger->info('Initiated password reset', [ - 'email' => $form->get('email')->getData(), - ]); - } catch (ApiClientException $e) { - $this->addFlash('error', $e->getMessage()); - $this->logger->error('Error initiating password reset', [ - 'email' => $form->get('email')->getData(), - 'error' => $e->getMessage(), - ]); - } + $this->passwordResetManager->requestPasswordReset($form->get('email')->getData()); + $this->addFlash('info', 'Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts, sofern zu deinem Konto eine E-Mail-Adresse hinterlegt ist.'); return $this->redirectToRoute('app_login'); } @@ -66,4 +55,45 @@ class ResetPasswordController extends AbstractController 'form' => $form, ]); } + + #[Route('/reset-password/confirm', name: 'app_reset_password_confirm', methods: ['GET', 'POST'])] + #[IsGranted('PUBLIC_ACCESS')] + public function confirm(Request $request): Response + { + $user = $this->passwordResetManager->validateResetRequest($request); + + $form = $this + ->createFormBuilder(null, [ + 'action' => $request->getUri(), + ]) + ->add('password', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => [ + 'label' => 'Neues Passwort', + ], + 'second_options' => [ + 'label' => 'Neues Passwort wiederholen', + ], + 'invalid_message' => 'Die beiden Passwortangaben müssen identisch sein.', + 'constraints' => [ + new NotBlank([ + 'message' => 'Bitte angeben', + ]), + ], + ]) + ->getForm() + ; + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->passwordResetManager->resetPassword($user, $form->get('password')->getData()); + $this->addFlash('info', 'Dein Passwort wurde aktualisiert. Du kannst dich jetzt mit dem neuen Passwort anmelden.'); + + return $this->redirectToRoute('app_login'); + } + + return $this->render('reset_password/confirm.html.twig', [ + 'form' => $form, + ]); + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 1a20a83b..d7c52428 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -45,6 +45,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'boolean')] private bool $profileComplete = false; + #[ORM\Column(type: 'string', length: 64, nullable: true)] + private ?string $passwordResetNonce = null; + public function __construct(string $email) { $this->email = $email; @@ -172,6 +175,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getPasswordResetNonce(): ?string + { + return $this->passwordResetNonce; + } + + public function setPasswordResetNonce(?string $passwordResetNonce): static + { + $this->passwordResetNonce = $passwordResetNonce; + + return $this; + } + public function eraseCredentials(): void { } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index e85e4bf0..2be7c051 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -17,4 +17,13 @@ class UserRepository extends ServiceEntityRepository { parent::__construct($registry, User::class); } + + public function findOneByNormalizedEmail(string $email): ?User + { + return $this->createQueryBuilder('u') + ->where('LOWER(u.email) = :email') + ->setParameter('email', mb_strtolower(trim($email))) + ->getQuery() + ->getOneOrNullResult(); + } } diff --git a/src/Service/PasswordResetManager.php b/src/Service/PasswordResetManager.php new file mode 100644 index 00000000..ec6fe629 --- /dev/null +++ b/src/Service/PasswordResetManager.php @@ -0,0 +1,138 @@ +normalizeEmail($email); + $user = $this->userRepository->findOneByNormalizedEmail($normalizedEmail); + + if (null === $user) { + return false; + } + + $previousNonce = $user->getPasswordResetNonce(); + $nonce = $this->generateNonce(); + $user->setPasswordResetNonce($nonce); + + $this->entityManager->flush(); + + $resetUrl = $this->buildResetUrl($normalizedEmail, $nonce); + + try { + $this->mailer->createAndSendEmail( + [ + 'resetUrl' => $resetUrl, + ], + [ + 'to' => $normalizedEmail, + 'subject' => 'Dein MyE&P Passwort zurücksetzen', + 'template' => 'email/password_reset.html.twig', + ], + ); + } catch (\Throwable $exception) { + $user->setPasswordResetNonce($previousNonce); + $this->entityManager->flush(); + + $this->logger->error('Could not send password reset email', [ + 'email' => $normalizedEmail, + 'error' => $exception->getMessage(), + ]); + + return false; + } + + $this->logger->info('Password reset requested', [ + 'email' => $normalizedEmail, + ]); + + return true; + } + + public function validateResetRequest(Request $request): User + { + if (false === $this->uriSigner->checkRequest($request)) { + throw new NotFoundHttpException(); + } + + $expires = $request->query->getInt('expires'); + if (0 >= $expires || $expires < time()) { + throw new NotFoundHttpException(); + } + + $email = $this->normalizeEmail($request->query->getString('email')); + $nonce = trim($request->query->getString('nonce')); + + if ('' === $email || '' === $nonce) { + throw new NotFoundHttpException(); + } + + $user = $this->userRepository->findOneByNormalizedEmail($email); + if (null === $user || null === $user->getPasswordResetNonce() || false === hash_equals($user->getPasswordResetNonce(), $nonce)) { + throw new NotFoundHttpException(); + } + + return $user; + } + + public function resetPassword(User $user, string $plainPassword): void + { + $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); + $user->setPasswordResetNonce($this->generateNonce()); + + $this->entityManager->flush(); + + $this->logger->info('Password reset completed', [ + 'email' => $user->getUserIdentifier(), + ]); + } + + private function buildResetUrl(string $normalizedEmail, string $nonce): string + { + $url = $this->urlGenerator->generate('app_reset_password_confirm', [ + 'email' => $normalizedEmail, + 'nonce' => $nonce, + 'expires' => time() + self::RESET_TTL_SECONDS, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + return $this->uriSigner->sign($url); + } + + private function normalizeEmail(string $email): string + { + return mb_strtolower(trim($email)); + } + + private function generateNonce(): string + { + return bin2hex(random_bytes(32)); + } +} diff --git a/templates/email/password_reset.html.twig b/templates/email/password_reset.html.twig new file mode 100644 index 00000000..29d8d2ce --- /dev/null +++ b/templates/email/password_reset.html.twig @@ -0,0 +1,27 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Passwort zurücksetzen +

+

+ Du hast ein neues Passwort für dein MyE&P Konto angefordert. +

+

+ Mit dem folgenden Link kannst du dein Passwort einmalig zurücksetzen. Der Link ist aus Sicherheitsgründen + nur für kurze Zeit gültig. +

+

+ + Passwort jetzt zurücksetzen + +

+

+ Wenn du diese Anfrage nicht ausgelöst hast, kannst du diese E-Mail einfach ignorieren. +

+

+ + Zu MyE&P + +

+{% endblock %} diff --git a/templates/reset_password/confirm.html.twig b/templates/reset_password/confirm.html.twig new file mode 100644 index 00000000..1eb576f4 --- /dev/null +++ b/templates/reset_password/confirm.html.twig @@ -0,0 +1,30 @@ +{% extends 'layout.html.twig' %} + +{% block content %} +
+
+

+ Neues Passwort +
+ festlegen +

+ {% include '_partials/_flashes.html.twig' %} +
+ {{ form_start(form) }} + {{ form_row(form.password.first, { 'label_attr': { 'class': 'text-white' } }) }} + {{ form_row(form.password.second, { 'label_attr': { 'class': 'text-white' } }) }} + + {{ form_rest(form) }} + {{ form_end(form) }} +
+ +
+
+{% endblock %} diff --git a/templates/reset_password/index.html.twig b/templates/reset_password/index.html.twig index 978c10b7..d3d4b35d 100644 --- a/templates/reset_password/index.html.twig +++ b/templates/reset_password/index.html.twig @@ -13,7 +13,7 @@ {{ form_start(form) }} {{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_rest(form) }} {{ form_end(form) }} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 199af871..cafb8446 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -69,7 +69,7 @@

Dann gib deine E-Mail ein, mit der Du gebucht hast und klicke auf "Passwort vergessen"! Und wir - senden dir ein E-Mail mit einem Passwort-Link! + senden dir einen einmaligen Passwort-Reset-Link!

Ein LogIn ist nur möglich wenn Eure Daten schon durch eine Buchung oder Anfrage bei uns diff --git a/tests/Controller/ResetPasswordControllerTest.php b/tests/Controller/ResetPasswordControllerTest.php new file mode 100644 index 00000000..00aa1b94 --- /dev/null +++ b/tests/Controller/ResetPasswordControllerTest.php @@ -0,0 +1,103 @@ + 'on', + ]); + $client->disableReboot(); + + $manager = $this->createMock(PasswordResetManager::class); + $manager + ->expects(self::once()) + ->method('requestPasswordReset') + ->with('customer@example.com') + ->willReturn(true); + self::getContainer()->set(PasswordResetManager::class, $manager); + + $crawler = $client->request('GET', '/reset-password'); + $form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([ + 'form[email]' => 'customer@example.com', + ]); + $client->submit($form); + + self::assertResponseRedirects('/'); + $client->followRedirect(); + + self::assertStringContainsString('Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts', $client->getResponse()->getContent()); + } + + public function testPasswordResetRequestUsesSameGenericSuccessMessageForMissingAccount(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $manager = $this->createMock(PasswordResetManager::class); + $manager + ->expects(self::once()) + ->method('requestPasswordReset') + ->with('missing@example.com') + ->willReturn(false); + self::getContainer()->set(PasswordResetManager::class, $manager); + + $crawler = $client->request('GET', '/reset-password'); + $form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([ + 'form[email]' => 'missing@example.com', + ]); + $client->submit($form); + + self::assertResponseRedirects('/'); + $client->followRedirect(); + + self::assertStringContainsString('Du erhältst in Kürze eine E-Mail mit einem Link zum Zurücksetzen deines Passworts', $client->getResponse()->getContent()); + } + + public function testPasswordResetConfirmationDisplaysFormAndUpdatesPassword(): void + { + $client = self::createClient([], [ + 'HTTPS' => 'on', + ]); + $client->disableReboot(); + + $user = new User('customer@example.com'); + $manager = $this->createMock(PasswordResetManager::class); + $manager + ->expects(self::exactly(2)) + ->method('validateResetRequest') + ->with(self::isInstanceOf(Request::class)) + ->willReturn($user); + $manager + ->expects(self::once()) + ->method('resetPassword') + ->with($user, 'new-password'); + self::getContainer()->set(PasswordResetManager::class, $manager); + + $crawler = $client->request('GET', '/reset-password/confirm?email=customer@example.com&nonce=token&expires=9999999999&_hash=signature'); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Neues Passwort'); + + $form = $crawler->selectButton('Passwort speichern')->form([ + 'form[password][first]' => 'new-password', + 'form[password][second]' => 'new-password', + ]); + $client->submit($form); + + self::assertResponseRedirects('/'); + $client->followRedirect(); + + self::assertStringContainsString('Dein Passwort wurde aktualisiert', $client->getResponse()->getContent()); + } +} diff --git a/tests/Service/PasswordResetManagerTest.php b/tests/Service/PasswordResetManagerTest.php new file mode 100644 index 00000000..985cc42f --- /dev/null +++ b/tests/Service/PasswordResetManagerTest.php @@ -0,0 +1,291 @@ +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('customer@example.com') + ->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(' Customer@Example.COM ')); + self::assertSame('app_reset_password_confirm', $generatedRoute); + self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType); + self::assertSame('customer@example.com', $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('customer@example.com', $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('customer@example.com', $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('customer@example.com'); + $user->setPasswordResetNonce('current-nonce'); + + $manager = $this->createManagerForValidation($user); + $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() + 3600), 'GET'); + + self::assertSame($user, $manager->validateResetRequest($request)); + } + + public function testValidateResetRequestRejectsTamperedLink(): void + { + $user = new User('customer@example.com'); + $user->setPasswordResetNonce('current-nonce'); + + $manager = $this->createManagerForValidation($user); + $signedUrl = $this->buildResetUrl('customer@example.com', '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('customer@example.com'); + $user->setPasswordResetNonce('current-nonce'); + + $manager = $this->createManagerForValidation($user); + $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() - 60), 'GET'); + + $this->expectException(NotFoundHttpException::class); + $manager->validateResetRequest($request); + } + + public function testValidateResetRequestRejectsNonceMismatch(): void + { + $user = new User('customer@example.com'); + $user->setPasswordResetNonce('different-nonce'); + + $manager = $this->createManagerForValidation($user); + $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() + 3600), 'GET'); + + $this->expectException(NotFoundHttpException::class); + $manager->validateResetRequest($request); + } + + public function testResetPasswordHashesPasswordAndRotatesNonce(): void + { + $user = new User('customer@example.com'); + $user->setPasswordResetNonce('current-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); + + $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::assertNotSame('current-nonce', $user->getPasswordResetNonce()); + self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce()); + } + + public function testNewPasswordResetRequestInvalidatesPreviousLink(): void + { + $user = new User('customer@example.com'); + $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 'customer@example.com' === $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('customer@example.com')); + self::assertTrue($manager->requestPasswordReset('customer@example.com')); + + self::assertCount(2, $generatedRoutes); + self::assertSame(['app_reset_password_confirm', 'app_reset_password_confirm'], $generatedRoutes); + self::assertCount(2, $generatedParametersList); + self::assertSame('customer@example.com', $generatedParametersList[0]['email']); + self::assertSame('customer@example.com', $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('customer@example.com', $firstQuery['email']); + self::assertSame('customer@example.com', $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 'customer@example.com' === $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); + } +}