diff --git a/composer.json b/composer.json index 1127e5f2..5884655e 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0", "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0", "doctrine/doctrine-bundle": "^2.8", - "doctrine/annotations": "^1.0" + "doctrine/annotations": "^1.0", + "symfony/maker-bundle": "^1.53" }, "autoload": { "psr-4": { diff --git a/src/MakerBundle/MakeResetPassword.php b/src/MakerBundle/MakeResetPassword.php new file mode 100644 index 00000000..c2f6c837 --- /dev/null +++ b/src/MakerBundle/MakeResetPassword.php @@ -0,0 +1,72 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SymfonyCasts\Bundle\ResetPassword\MakerBundle; + +use Symfony\Bridge\Twig\Mime\TemplatedEmail; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\MakerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Form\AbstractType; +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\Form\FormBuilderInterface; +use Symfony\Component\Mailer\MailerInterface; +use Symfony\Component\Mime\Address; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Translation\Translator; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Yaml\Yaml; +use Symfony\Contracts\Translation\TranslatorInterface; + +class MakeResetPassword implements MakerInterface +{ + public static function getCommandName(): string + { + return 'make:rp-bundle'; + } + + public static function getCommandDescription(): string + { + return 'Some Description'; + } + + /** + * Configure the command: set description, input arguments, options, etc. + * + * By default, all arguments will be asked interactively. If you want + * to avoid that, use the $inputConfig->setArgumentAsNonInteractive() method. + */ + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + } + + /** + * Configure any library dependencies that your maker requires. + */ + public function configureDependencies(DependencyBuilder $dependencies): void + { + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + $io->title('Let\'s make a password reset feature!'); + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + } +} diff --git a/src/MakerBundle/Templates/ChangePasswordFormType.tpl.php b/src/MakerBundle/Templates/ChangePasswordFormType.tpl.php new file mode 100644 index 00000000..b72d2580 --- /dev/null +++ b/src/MakerBundle/Templates/ChangePasswordFormType.tpl.php @@ -0,0 +1,48 @@ + + +namespace ; + + + +class extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->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' => 6, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + ], + '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/MakerBundle/Templates/ResetPasswordController.tpl.php b/src/MakerBundle/Templates/ResetPasswordController.tpl.php new file mode 100644 index 00000000..2762e5f8 --- /dev/null +++ b/src/MakerBundle/Templates/ResetPasswordController.tpl.php @@ -0,0 +1,160 @@ + + +namespace ; + + + +#[Route('/reset-password')] +class extends AbstractController +{ + use ResetPasswordControllerTrait; + + public function __construct( + private ResetPasswordHelperInterface $resetPasswordHelper, + private EntityManagerInterface $entityManager + ) { + } + + /** + * Display & process form to request a password reset. + */ + #[Route('', name: 'app_forgot_password_request')] + public function request(Request $request, MailerInterface $mailer, TranslatorInterface $translator): Response + { + $form = $this->createForm(::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + return $this->processSendingPasswordResetEmail( + $form->get('')->getData(), + $mailer, + $translator + ); + } + + return $this->render('reset_password/request.html.twig', [ + 'requestForm' => $form->createView(), + ]); + } + + /** + * 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('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 { + $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('reset_password_error', sprintf( + '%s - %s', + $translator->trans(, [], 'ResetPasswordBundle'), + $translator->trans($e->getReason(), [], 'ResetPasswordBundle')$e->getReason() + )); + + return $this->redirectToRoute('app_forgot_password_request'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + $this->resetPasswordHelper->removeResetRequest($token); + + // Encode(hash) the plain password, and set it. + $encodedPassword = $passwordHasher->hashPassword( + $user, + $form->get('plainPassword')->getData() + ); + + $user->($encodedPassword); + $this->entityManager->flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + return $this->redirectToRoute(''); + } + + return $this->render('reset_password/reset.html.twig', [ + 'resetForm' => $form->createView(), + ]); + } + + private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse + { + $user = $this->entityManager->getRepository(::class)->findOneBy([ + '' => $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(, [], 'ResetPasswordBundle'), + // $translator->trans($e->getReason(), [], 'ResetPasswordBundle')$e->getReason() + // )); + + return $this->redirectToRoute('app_check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('', '')) + ->to($user->()) + ->subject('Your password reset request') + ->htmlTemplate('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/MakerBundle/Templates/ResetPasswordRequest.tpl.php b/src/MakerBundle/Templates/ResetPasswordRequest.tpl.php new file mode 100644 index 00000000..0bce2832 --- /dev/null +++ b/src/MakerBundle/Templates/ResetPasswordRequest.tpl.php @@ -0,0 +1,36 @@ + + +namespace ; + + + +#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)] +class ResetPasswordRequest implements ResetPasswordRequestInterface +{ + use ResetPasswordRequestTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\ManyToOne] + #[ORM\JoinColumn(nullable: false)] + private ?= user_short_class ?> $user = null; + + public function __construct(= user_short_class ?> $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken) + { + $this->user = $user; + $this->initialize($expiresAt, $selector, $hashedToken); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): = user_short_class ?> + { + return $this->user; + } +} diff --git a/src/MakerBundle/Templates/ResetPasswordRequestFormType.tpl.php b/src/MakerBundle/Templates/ResetPasswordRequestFormType.tpl.php new file mode 100644 index 00000000..51d2299f --- /dev/null +++ b/src/MakerBundle/Templates/ResetPasswordRequestFormType.tpl.php @@ -0,0 +1,27 @@ + + +namespace ; + + + +class extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('', 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/MakerBundle/Templates/twig_check_email.tpl.php b/src/MakerBundle/Templates/twig_check_email.tpl.php new file mode 100644 index 00000000..786819b3 --- /dev/null +++ b/src/MakerBundle/Templates/twig_check_email.tpl.php @@ -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/src/MakerBundle/Templates/twig_email.tpl.php b/src/MakerBundle/Templates/twig_email.tpl.php new file mode 100644 index 00000000..824a2186 --- /dev/null +++ b/src/MakerBundle/Templates/twig_email.tpl.php @@ -0,0 +1,9 @@ +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/src/MakerBundle/Templates/twig_request.tpl.php b/src/MakerBundle/Templates/twig_request.tpl.php new file mode 100644 index 00000000..edfd28e2 --- /dev/null +++ b/src/MakerBundle/Templates/twig_request.tpl.php @@ -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') %} +