From 499e699dbd61264f20f95d9bc9736c91123a4502 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 3 Jan 2026 22:57:45 +0100 Subject: [PATCH] Forgot password --- .idea/escapepage.iml | 1 + .idea/php.xml | 1 + composer.json | 1 + composer.lock | 49 ++++- config/bundles.php | 1 + config/packages/reset_password.yaml | 2 + config/packages/security.yaml | 4 +- migrations/Version20260103214856.php | 33 ++++ migrations/Version20260103215543.php | 33 ++++ .../Controller/ResetPasswordController.php | 176 ++++++++++++++++++ src/Tech/Entity/ResetPasswordRequest.php | 40 ++++ src/Tech/Entity/User.php | 18 +- src/Tech/Form/ChangePasswordFormType.php | 58 ++++++ src/Tech/Form/RegistrationFormType.php | 8 + .../Form/ResetPasswordRequestFormType.php | 31 +++ .../ResetPasswordRequestRepository.php | 32 ++++ symfony.lock | 12 ++ .../tech/registration/register.html.twig | 1 + .../tech/reset_password/check_email.html.twig | 11 ++ templates/tech/reset_password/email.html.twig | 9 + .../tech/reset_password/request.html.twig | 22 +++ templates/tech/reset_password/reset.html.twig | 12 ++ templates/tech/security/login.html.twig | 7 +- 23 files changed, 556 insertions(+), 6 deletions(-) create mode 100644 config/packages/reset_password.yaml create mode 100644 migrations/Version20260103214856.php create mode 100644 migrations/Version20260103215543.php create mode 100644 src/Tech/Controller/ResetPasswordController.php create mode 100644 src/Tech/Entity/ResetPasswordRequest.php create mode 100644 src/Tech/Form/ChangePasswordFormType.php create mode 100644 src/Tech/Form/ResetPasswordRequestFormType.php create mode 100644 src/Tech/Repository/ResetPasswordRequestRepository.php create mode 100644 templates/tech/reset_password/check_email.html.twig create mode 100644 templates/tech/reset_password/email.html.twig create mode 100644 templates/tech/reset_password/request.html.twig create mode 100644 templates/tech/reset_password/reset.html.twig diff --git a/.idea/escapepage.iml b/.idea/escapepage.iml index c67296b..5dca623 100644 --- a/.idea/escapepage.iml +++ b/.idea/escapepage.iml @@ -138,6 +138,7 @@ + diff --git a/.idea/php.xml b/.idea/php.xml index 89d06de..26b72af 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -152,6 +152,7 @@ + diff --git a/composer.json b/composer.json index 79b894f..62d0f03 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,7 @@ "symfony/web-link": "7.3.*", "symfony/webpack-encore-bundle": "^2.1", "symfony/yaml": "7.3.*", + "symfonycasts/reset-password-bundle": "^1.24", "symfonycasts/verify-email-bundle": "^1.18", "twig/extra-bundle": "^2.12|^3.0", "twig/twig": "^2.12|^3.0" diff --git a/composer.lock b/composer.lock index 03309f0..05f4ca8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "663544ea81c4031dcd096a2db68ba5f8", + "content-hash": "8e2419832c0841e325a5b748bde61a48", "packages": [ { "name": "composer/semver", @@ -7969,6 +7969,53 @@ ], "time": "2025-12-04T18:07:52+00:00" }, + { + "name": "symfonycasts/reset-password-bundle", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/reset-password-bundle.git", + "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/8e5f8f821260ccfe8085563a93b418d3ef9af29f", + "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f", + "shasum": "" + }, + "require": { + "php": ">=8.1.10", + "symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/deprecation-contracts": "^2.2 | ^3.0", + "symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^1.0", + "doctrine/doctrine-bundle": "^2.8", + "doctrine/orm": "^2.13", + "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/process": "^6.4 | ^7.0 | ^8.0", + "symfonycasts/internal-test-helpers": "dev-main" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\ResetPassword\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Symfony bundle that adds password reset functionality.", + "support": { + "issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues", + "source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.24.0" + }, + "time": "2025-11-29T13:26:50+00:00" + }, { "name": "symfonycasts/verify-email-bundle", "version": "v1.18.0", diff --git a/config/bundles.php b/config/bundles.php index be3ef62..a28579b 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -16,4 +16,5 @@ return [ Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], + SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], ]; diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml new file mode 100644 index 0000000..e53f102 --- /dev/null +++ b/config/packages/reset_password.yaml @@ -0,0 +1,2 @@ +symfonycasts_reset_password: + request_password_repository: App\Tech\Repository\ResetPasswordRequestRepository diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b1d4cb0..6c029ed 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -7,7 +7,7 @@ security: app_user_provider: entity: class: App\Tech\Entity\User - property: email + property: username firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -20,7 +20,7 @@ security: login_path: app_login check_path: app_login enable_csrf: true - username_parameter: email + username_parameter: username password_parameter: password logout: path: app_logout diff --git a/migrations/Version20260103214856.php b/migrations/Version20260103214856.php new file mode 100644 index 0000000..9e6d1bd --- /dev/null +++ b/migrations/Version20260103214856.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE user ADD username VARCHAR(180) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON user (username)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_IDENTIFIER_USERNAME ON `user`'); + $this->addSql('ALTER TABLE `user` DROP username'); + } +} diff --git a/migrations/Version20260103215543.php b/migrations/Version20260103215543.php new file mode 100644 index 0000000..acd9f3c --- /dev/null +++ b/migrations/Version20260103215543.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395'); + $this->addSql('DROP TABLE reset_password_request'); + } +} diff --git a/src/Tech/Controller/ResetPasswordController.php b/src/Tech/Controller/ResetPasswordController.php new file mode 100644 index 0000000..7652561 --- /dev/null +++ b/src/Tech/Controller/ResetPasswordController.php @@ -0,0 +1,176 @@ +createForm(ResetPasswordRequestFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $email */ + $email = $form->get('email')->getData(); + + return $this->processSendingPasswordResetEmail($email, $mailer, $translator + ); + } + + return $this->render('tech/reset_password/request.html.twig', [ + 'requestForm' => $form, + ]); + } + + /** + * Confirmation page after a user has requested a password reset. + */ + #[Route('/check-email', name: 'app_check_email')] + public function checkEmail(): Response + { + // Generate a fake token if the user does not exist or someone hit this page directly. + // This prevents exposing whether or not a user was found with the given email address or not + if (null === ($resetToken = $this->getTokenObjectFromSession())) { + $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); + } + + return $this->render('tech/reset_password/check_email.html.twig', [ + 'resetToken' => $resetToken, + ]); + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + #[Route('/reset/{token}', name: 'app_reset_password')] + public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response + { + if ($token) { + // We store the token in session and remove it from the URL, to avoid the URL being + // loaded in a browser and potentially leaking the token to 3rd party JavaScript. + $this->storeTokenInSession($token); + + return $this->redirectToRoute('app_reset_password'); + } + + $token = $this->getTokenFromSession(); + + if (null === $token) { + throw $this->createNotFoundException('No reset password token found in the URL or in the session.'); + } + + try { + /** @var User $user */ + $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('reset_password_error', sprintf( + '%s - %s', + $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'), + $translator->trans($e->getReason(), [], 'ResetPasswordBundle') + )); + + return $this->redirectToRoute('app_forgot_password_request'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + $this->resetPasswordHelper->removeResetRequest($token); + + /** @var string $plainPassword */ + $plainPassword = $form->get('plainPassword')->getData(); + + // Encode(hash) the plain password, and set it. + $user->setPassword($passwordHasher->hashPassword($user, $plainPassword)); + $this->entityManager->flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + return $this->redirectToRoute('website_home'); + } + + return $this->render('tech/reset_password/reset.html.twig', [ + 'resetForm' => $form, + ]); + } + + private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse + { + $user = $this->entityManager->getRepository(User::class)->findOneBy([ + 'email' => $emailFormData, + ]); + + // Do not reveal whether a user account was found or not. + if (!$user) { + return $this->redirectToRoute('app_check_email'); + } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (ResetPasswordExceptionInterface $e) { + // If you want to tell the user why a reset email was not sent, uncomment + // the lines below and change the redirect to 'app_forgot_password_request'. + // Caution: This may reveal if a user is registered or not. + // + // $this->addFlash('reset_password_error', sprintf( + // '%s - %s', + // $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'), + // $translator->trans($e->getReason(), [], 'ResetPasswordBundle') + // )); + + return $this->redirectToRoute('app_check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('noreply@escapepage.com', 'Escapepage')) + ->to((string) $user->getEmail()) + ->subject('Your password reset request') + ->htmlTemplate('tech/reset_password/email.html.twig') + ->context([ + 'resetToken' => $resetToken, + ]) + ; + + $mailer->send($email); + + // Store the token object in session for retrieval in check-email route. + $this->setTokenObjectInSession($resetToken); + + return $this->redirectToRoute('app_check_email'); + } +} diff --git a/src/Tech/Entity/ResetPasswordRequest.php b/src/Tech/Entity/ResetPasswordRequest.php new file mode 100644 index 0000000..111ab70 --- /dev/null +++ b/src/Tech/Entity/ResetPasswordRequest.php @@ -0,0 +1,40 @@ +user = $user; + $this->initialize($expiresAt, $selector, $hashedToken); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Tech/Entity/User.php b/src/Tech/Entity/User.php index 47150f0..c862ac2 100644 --- a/src/Tech/Entity/User.php +++ b/src/Tech/Entity/User.php @@ -10,6 +10,7 @@ use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] +#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] @@ -20,6 +21,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 180)] private ?string $email = null; + #[ORM\Column(length: 180)] + private ?string $username = null; + /** * @var list The user roles */ @@ -52,6 +56,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + /** * A visual identifier that represents this user. * @@ -59,7 +75,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface */ public function getUserIdentifier(): string { - return (string) $this->email; + return (string) $this->username; } /** diff --git a/src/Tech/Form/ChangePasswordFormType.php b/src/Tech/Form/ChangePasswordFormType.php new file mode 100644 index 0000000..b2bd7f6 --- /dev/null +++ b/src/Tech/Form/ChangePasswordFormType.php @@ -0,0 +1,58 @@ +add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'options' => [ + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'first_options' => [ + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 12, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + new PasswordStrength(), + new NotCompromisedPassword(), + ], + 'label' => 'New password', + ], + 'second_options' => [ + 'label' => 'Repeat Password', + ], + 'invalid_message' => 'The password fields must match.', + // Instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Tech/Form/RegistrationFormType.php b/src/Tech/Form/RegistrationFormType.php index 9f77432..1120017 100644 --- a/src/Tech/Form/RegistrationFormType.php +++ b/src/Tech/Form/RegistrationFormType.php @@ -5,6 +5,7 @@ namespace App\Tech\Form; use App\Tech\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; @@ -18,6 +19,13 @@ class RegistrationFormType extends AbstractType { $builder ->add('email', EmailType::class) + ->add('username', TextType::class, [ + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a username', + ]), + ], + ]) ->add('plainPassword', RepeatedType::class, [ 'type' => PasswordType::class, 'mapped' => false, diff --git a/src/Tech/Form/ResetPasswordRequestFormType.php b/src/Tech/Form/ResetPasswordRequestFormType.php new file mode 100644 index 0000000..26f51a4 --- /dev/null +++ b/src/Tech/Form/ResetPasswordRequestFormType.php @@ -0,0 +1,31 @@ +add('email', EmailType::class, [ + 'attr' => ['autocomplete' => 'email'], + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter your email', + ]), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Tech/Repository/ResetPasswordRequestRepository.php b/src/Tech/Repository/ResetPasswordRequestRepository.php new file mode 100644 index 0000000..78f14e3 --- /dev/null +++ b/src/Tech/Repository/ResetPasswordRequestRepository.php @@ -0,0 +1,32 @@ + + */ +class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface +{ + use ResetPasswordRequestRepositoryTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResetPasswordRequest::class); + } + + /** + * @param User $user + */ + public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface + { + return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken); + } +} diff --git a/symfony.lock b/symfony.lock index c8acec2..ac5be9a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -356,6 +356,18 @@ "./webpack.config.js" ] }, + "symfonycasts/reset-password-bundle": { + "version": "1.24", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "97c1627c0384534997ae1047b93be517ca16de43" + }, + "files": [ + "./config/packages/reset_password.yaml" + ] + }, "symfonycasts/verify-email-bundle": { "version": "v1.18.0" }, diff --git a/templates/tech/registration/register.html.twig b/templates/tech/registration/register.html.twig index c0dc234..05752e4 100644 --- a/templates/tech/registration/register.html.twig +++ b/templates/tech/registration/register.html.twig @@ -9,6 +9,7 @@ {{ form_start(registrationForm) }} {{ form_row(registrationForm.email) }} + {{ form_row(registrationForm.username) }} {{ form_row(registrationForm.plainPassword) }} diff --git a/templates/tech/reset_password/check_email.html.twig b/templates/tech/reset_password/check_email.html.twig new file mode 100644 index 0000000..786819b --- /dev/null +++ b/templates/tech/reset_password/check_email.html.twig @@ -0,0 +1,11 @@ +{% extends 'base.html.twig' %} + +{% block title %}Password Reset Email Sent{% endblock %} + +{% block body %} +

+ If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password. + This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}. +

+

If you don't receive an email please check your spam folder or try again.

+{% endblock %} diff --git a/templates/tech/reset_password/email.html.twig b/templates/tech/reset_password/email.html.twig new file mode 100644 index 0000000..824a218 --- /dev/null +++ b/templates/tech/reset_password/email.html.twig @@ -0,0 +1,9 @@ +

Hi!

+ +

To reset your password, please visit the following link

+ +{{ url('app_reset_password', {token: resetToken.token}) }} + +

This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.

+ +

Cheers!

diff --git a/templates/tech/reset_password/request.html.twig b/templates/tech/reset_password/request.html.twig new file mode 100644 index 0000000..0a118aa --- /dev/null +++ b/templates/tech/reset_password/request.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} + {% for flash_error in app.flashes('reset_password_error') %} + + {% endfor %} +

Reset your password

+ + {{ form_start(requestForm) }} + {{ form_row(requestForm.email) }} +
+ + Enter your email address, and we will send you a + link to reset your password. + +
+ + + {{ form_end(requestForm) }} +{% endblock %} diff --git a/templates/tech/reset_password/reset.html.twig b/templates/tech/reset_password/reset.html.twig new file mode 100644 index 0000000..799aa10 --- /dev/null +++ b/templates/tech/reset_password/reset.html.twig @@ -0,0 +1,12 @@ +{% extends 'base.html.twig' %} + +{% block title %}Reset your password{% endblock %} + +{% block body %} +

Reset your password

+ + {{ form_start(resetForm) }} + {{ form_row(resetForm.plainPassword) }} + + {{ form_end(resetForm) }} +{% endblock %} diff --git a/templates/tech/security/login.html.twig b/templates/tech/security/login.html.twig index efc5c40..8d4fb16 100644 --- a/templates/tech/security/login.html.twig +++ b/templates/tech/security/login.html.twig @@ -15,8 +15,8 @@ {% endif %}

Please sign in

- - + + @@ -38,5 +38,8 @@ + {% endblock %}