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;
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,
]);
}
}
+15
View File
@@ -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
{
}
+9
View File
@@ -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();
}
}
+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_row(form.email, { 'label_attr': { 'class': 'text-white' } }) }}
<button type="submit" class="button button--primary">
Passwort zurücksetzen
Passwort-Reset-Link anfordern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
+1 -1
View File
@@ -69,7 +69,7 @@
</h3>
<p class="mb-2 text-white">
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 class="text-white">
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);
}
}