feat: local password reset
This commit is contained in:
1 parent
fe562e5e73
commit
8bb7f08074
11 files changed
+688
-19
No files matched your search
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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&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&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 %}
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user