feat: local password reset

This commit is contained in:
Björn Fromme committed 2026-06-23 16:27:08 +02:00
1 parent fe562e5e73
commit 8bb7f08074
11 files changed
+688 -19

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260518120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add password reset nonce to users';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+47 -17
View File
@@ -2,11 +2,11 @@
namespace App\Controller; namespace App\Controller;
use App\BusProNet\ApiClient; use App\Service\PasswordResetManager;
use App\BusProNet\Exception\ApiClientException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\EmailType; 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\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -16,7 +16,7 @@ use Symfony\Component\Validator\Constraints\NotBlank;
class ResetPasswordController extends AbstractController 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); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { $this->passwordResetManager->requestPasswordReset($form->get('email')->getData());
$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, sofern zu deinem Konto eine E-Mail-Adresse hinterlegt ist.');
$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(),
]);
}
return $this->redirectToRoute('app_login'); return $this->redirectToRoute('app_login');
} }
@@ -66,4 +55,45 @@ class ResetPasswordController extends AbstractController
'form' => $form, '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,
]);
}
} }
+15
View File
@@ -45,6 +45,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'boolean')] #[ORM\Column(type: 'boolean')]
private bool $profileComplete = false; private bool $profileComplete = false;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $passwordResetNonce = null;
public function __construct(string $email) public function __construct(string $email)
{ {
$this->email = $email; $this->email = $email;
@@ -172,6 +175,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getPasswordResetNonce(): ?string
{
return $this->passwordResetNonce;
}
public function setPasswordResetNonce(?string $passwordResetNonce): static
{
$this->passwordResetNonce = $passwordResetNonce;
return $this;
}
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
} }
+9
View File
@@ -17,4 +17,13 @@ class UserRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, User::class); 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();
}
} }
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Psr\Log\LoggerInterface;
class PasswordResetManager
{
private const int RESET_TTL_SECONDS = 3600;
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly UriSigner $uriSigner,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
public function requestPasswordReset(string $email): bool
{
$normalizedEmail = $this->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));
}
}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Passwort zurücksetzen
</h1>
<p>
Du hast ein neues Passwort für dein MyE&amp;P Konto angefordert.
</p>
<p>
Mit dem folgenden Link kannst du dein Passwort einmalig zurücksetzen. Der Link ist aus Sicherheitsgründen
nur für kurze Zeit gültig.
</p>
<p>
<a href="{{ resetUrl }}" class="button">
Passwort jetzt zurücksetzen
</a>
</p>
<p>
Wenn du diese Anfrage nicht ausgelöst hast, kannst du diese E-Mail einfach ignorieren.
</p>
<p>
<a href="{{ url('app_login') }}" class="button">
Zu MyE&amp;P
</a>
</p>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends 'layout.html.twig' %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Neues Passwort
<br>
festlegen
</h1>
{% include '_partials/_flashes.html.twig' %}
<div class="pb-4">
{{ form_start(form) }}
{{ form_row(form.password.first, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(form.password.second, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary">
Passwort speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
<div class="flex space-x-2">
<a href="{{ path('app_login') }}"
class="text-white underline">
Zum Login
</a>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -13,7 +13,7 @@
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }} {{ form_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary"> <button type="submit" class="button button--primary">
Passwort zurücksetzen Passwort-Reset-Link anfordern
</button> </button>
{{ form_rest(form) }} {{ form_rest(form) }}
{{ form_end(form) }} {{ form_end(form) }}
+1 -1
View File
@@ -69,7 +69,7 @@
</h3> </h3>
<p class="mb-2 text-white"> <p class="mb-2 text-white">
Dann gib deine E-Mail ein, mit der Du gebucht hast und klicke auf "Passwort vergessen"! Und wir 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!
</p> </p>
<p class="text-white"> <p class="text-white">
Ein LogIn ist nur möglich wenn Eure Daten schon durch eine Buchung oder Anfrage bei uns Ein LogIn ist nur möglich wenn Eure Daten schon durch eine Buchung oder Anfrage bei uns
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\User;
use App\Service\PasswordResetManager;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
class ResetPasswordControllerTest extends WebTestCase
{
public function testPasswordResetRequestUsesGenericSuccessMessageForExistingAccount(): void
{
$client = self::createClient([], [
'HTTPS' => 'on',
]);
$client->disableReboot();
$manager = $this->createMock(PasswordResetManager::class);
$manager
->expects(self::once())
->method('requestPasswordReset')
->with('[email protected]')
->willReturn(true);
self::getContainer()->set(PasswordResetManager::class, $manager);
$crawler = $client->request('GET', '/reset-password');
$form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([
'form[email]' => '[email protected]',
]);
$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('[email protected]')
->willReturn(false);
self::getContainer()->set(PasswordResetManager::class, $manager);
$crawler = $client->request('GET', '/reset-password');
$form = $crawler->selectButton('Passwort-Reset-Link anfordern')->form([
'form[email]' => '[email protected]',
]);
$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('[email protected]');
$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/[email protected]&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());
}
}
+291
View File
@@ -0,0 +1,291 @@
<?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');
$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('[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);
}
}