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,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