From cf832f27f952880399195ab18ad736058d6fafc4 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Wed, 14 Aug 2024 14:28:49 +0200 Subject: [PATCH 1/3] Provide the Token entity --- migrations/Version20240814123823AddToken.php | 66 +++++++++++ src/Entity/Token.php | 115 +++++++++++++++++++ src/Entity/User.php | 16 +++ src/Repository/TokenRepository.php | 25 ++++ tests/Factory/TokenFactory.php | 40 +++++++ 5 files changed, 262 insertions(+) create mode 100644 migrations/Version20240814123823AddToken.php create mode 100644 src/Entity/Token.php create mode 100644 src/Repository/TokenRepository.php create mode 100644 tests/Factory/TokenFactory.php diff --git a/migrations/Version20240814123823AddToken.php b/migrations/Version20240814123823AddToken.php new file mode 100644 index 00000000..60210f35 --- /dev/null +++ b/migrations/Version20240814123823AddToken.php @@ -0,0 +1,66 @@ +connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('CREATE TABLE token (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, value VARCHAR(250) NOT NULL, expired_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, description VARCHAR(250) NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_5F37A13B1D775834 ON token (value)'); + $this->addSql('CREATE INDEX IDX_5F37A13BB03A8386 ON token (created_by_id)'); + $this->addSql('CREATE INDEX IDX_5F37A13B896DBBDE ON token (updated_by_id)'); + $this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13BB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13B896DBBDE FOREIGN KEY (updated_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE users ADD reset_password_token_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E92C3F5AA9 FOREIGN KEY (reset_password_token_id) REFERENCES token (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E92C3F5AA9 ON users (reset_password_token_id)'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('CREATE TABLE token (id INT AUTO_INCREMENT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, value VARCHAR(250) NOT NULL, expired_at DATETIME NOT NULL, description VARCHAR(250) NOT NULL, created_by_id INT DEFAULT NULL, updated_by_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_5F37A13B1D775834 (value), INDEX IDX_5F37A13BB03A8386 (created_by_id), INDEX IDX_5F37A13B896DBBDE (updated_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13BB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id)'); + $this->addSql('ALTER TABLE token ADD CONSTRAINT FK_5F37A13B896DBBDE FOREIGN KEY (updated_by_id) REFERENCES `users` (id)'); + $this->addSql('ALTER TABLE users ADD reset_password_token_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD CONSTRAINT FK_1483A5E92C3F5AA9 FOREIGN KEY (reset_password_token_id) REFERENCES token (id) ON DELETE SET NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E92C3F5AA9 ON users (reset_password_token_id)'); + } + } + + public function down(Schema $schema): void + { + $dbPlatform = $this->connection->getDatabasePlatform(); + if ($dbPlatform instanceof PostgreSQLPlatform) { + $this->addSql('ALTER TABLE token DROP CONSTRAINT FK_5F37A13BB03A8386'); + $this->addSql('ALTER TABLE token DROP CONSTRAINT FK_5F37A13B896DBBDE'); + $this->addSql('DROP TABLE token'); + $this->addSql('ALTER TABLE "users" DROP CONSTRAINT FK_1483A5E92C3F5AA9'); + $this->addSql('DROP INDEX UNIQ_1483A5E92C3F5AA9'); + $this->addSql('ALTER TABLE "users" DROP reset_password_token_id'); + } elseif ($dbPlatform instanceof MariaDBPlatform) { + $this->addSql('ALTER TABLE token DROP FOREIGN KEY FK_5F37A13BB03A8386'); + $this->addSql('ALTER TABLE token DROP FOREIGN KEY FK_5F37A13B896DBBDE'); + $this->addSql('DROP TABLE token'); + $this->addSql('ALTER TABLE `users` DROP FOREIGN KEY FK_1483A5E92C3F5AA9'); + $this->addSql('DROP INDEX UNIQ_1483A5E92C3F5AA9 ON `users`'); + $this->addSql('ALTER TABLE `users` DROP reset_password_token_id'); + } + } +} diff --git a/src/Entity/Token.php b/src/Entity/Token.php new file mode 100644 index 00000000..5ffe4c63 --- /dev/null +++ b/src/Entity/Token.php @@ -0,0 +1,115 @@ +setValue($value); + + $expiredAt = Utils\Time::fromNow($number, $unit); + $token->setExpiredAt($expiredAt); + + $token->setDescription($description); + + return $token; + } + + public function __construct() + { + $this->value = ''; + $this->description = ''; + $this->expiredAt = Utils\Time::now(); + } + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(string $value): static + { + $this->value = $value; + + return $this; + } + + public function getExpiredAt(): ?\DateTimeImmutable + { + return $this->expiredAt; + } + + public function setExpiredAt(\DateTimeImmutable $expiredAt): static + { + $this->expiredAt = $expiredAt; + + return $this; + } + + public function isValid(): bool + { + return Utils\Time::now() < $this->expiredAt; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index d7787f84..9b2e8cb1 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -119,6 +119,10 @@ class User implements #[ORM\ManyToMany(targetEntity: Team::class, mappedBy: 'agents')] private Collection $teams; + #[ORM\OneToOne(cascade: ['persist', 'remove'])] + #[ORM\JoinColumn(onDelete: 'SET NULL')] + private ?Token $resetPasswordToken = null; + public function __construct() { $this->password = ''; @@ -301,4 +305,16 @@ public function getTeams(): Collection { return $this->teams; } + + public function getResetPasswordToken(): ?Token + { + return $this->resetPasswordToken; + } + + public function setResetPasswordToken(?Token $resetPasswordToken): static + { + $this->resetPasswordToken = $resetPasswordToken; + + return $this; + } } diff --git a/src/Repository/TokenRepository.php b/src/Repository/TokenRepository.php new file mode 100644 index 00000000..42af0be3 --- /dev/null +++ b/src/Repository/TokenRepository.php @@ -0,0 +1,25 @@ + + */ +class TokenRepository extends ServiceEntityRepository +{ + /** @phpstan-use CommonTrait */ + use CommonTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Entity\Token::class); + } +} diff --git a/tests/Factory/TokenFactory.php b/tests/Factory/TokenFactory.php new file mode 100644 index 00000000..f1a9a1ee --- /dev/null +++ b/tests/Factory/TokenFactory.php @@ -0,0 +1,40 @@ + + */ +final class TokenFactory extends PersistentProxyObjectFactory +{ + /** + * @return mixed[] + */ + protected function defaults(): array + { + $number = self::faker()->numberBetween(-100, 100); + + return [ + 'value' => Utils\Random::hex(20), + 'expiredAt' => Utils\Time::relative("{$number} days"), + ]; + } + + protected function initialize(): static + { + return $this; + } + + public static function class(): string + { + return Entity\Token::class; + } +} From 414bbe51922e7f1c7d2d1f38817002f18d795f64 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Wed, 14 Aug 2024 17:40:47 +0200 Subject: [PATCH 2/3] Allow to send a reset password email --- config/packages/security.yaml | 1 + src/Controller/PasswordsController.php | 55 +++++++ src/Form/Password/ResetForm.php | 77 +++++++++ src/Message/SendResetPasswordEmail.php | 20 +++ .../SendResetPasswordEmailHandler.php | 61 +++++++ templates/emails/reset_password.html.twig | 21 +++ templates/login/new.html.twig | 6 + templates/passwords/reset.html.twig | 79 +++++++++ tests/Controller/PasswordsControllerTest.php | 152 ++++++++++++++++++ translations/errors+intl-icu.en_GB.yaml | 3 + translations/errors+intl-icu.fr_FR.yaml | 3 + translations/messages+intl-icu.en_GB.yaml | 11 ++ translations/messages+intl-icu.fr_FR.yaml | 11 ++ 13 files changed, 500 insertions(+) create mode 100644 src/Controller/PasswordsController.php create mode 100644 src/Form/Password/ResetForm.php create mode 100644 src/Message/SendResetPasswordEmail.php create mode 100644 src/MessageHandler/SendResetPasswordEmailHandler.php create mode 100644 templates/emails/reset_password.html.twig create mode 100644 templates/passwords/reset.html.twig create mode 100644 tests/Controller/PasswordsControllerTest.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index b51b5f15..330ffe94 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -43,6 +43,7 @@ security: access_control: - { path: ^/about, role: PUBLIC_ACCESS } - { path: ^/login, role: PUBLIC_ACCESS } + - { path: ^/passwords/reset, role: PUBLIC_ACCESS } - { path: ^/session/locale, role: PUBLIC_ACCESS } - { path: ^/app.manifest, role: PUBLIC_ACCESS } - { path: ^/, role: ROLE_USER } diff --git a/src/Controller/PasswordsController.php b/src/Controller/PasswordsController.php new file mode 100644 index 00000000..b777e6f3 --- /dev/null +++ b/src/Controller/PasswordsController.php @@ -0,0 +1,55 @@ +query->getBoolean('sent'); + + $form = $this->createNamedForm('reset_password', Form\Password\ResetForm::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + $user = $data['user']; + + $token = Entity\Token::create( + 2, + 'hours', + length: 20, + description: "Reset password token for {$user->getEmail()}" + ); + $user->setResetPasswordToken($token); + + $userRepository->save($user, true); + + $bus->dispatch(new Message\SendResetPasswordEmail($user->getId())); + + return $this->redirectToRoute('reset password', ['sent' => true]); + } + + return $this->render('passwords/reset.html.twig', [ + 'form' => $form, + 'emailSent' => $sent, + ]); + } +} diff --git a/src/Form/Password/ResetForm.php b/src/Form/Password/ResetForm.php new file mode 100644 index 00000000..dc8bd6af --- /dev/null +++ b/src/Form/Password/ResetForm.php @@ -0,0 +1,77 @@ +add('user', Type\EmailType::class); + + $transformer = new CallbackTransformer( + function (?Entity\User $user): string { + if (!$user) { + return ''; + } + + return $user->getEmail(); + }, + function (string $email): Entity\User { + $user = $this->userRepository->findOneBy([ + 'email' => $email, + ]); + + if (!$user) { + $failure = new TransformationFailedException('User with given email does not exist'); + $failureMessage = new TranslatableMessage('reset_password.user.unknown', [], 'errors'); + $failure->setInvalidMessage($failureMessage); + throw $failure; + } + + if ($this->ldapEnabled && $user->getAuthType() === 'ldap') { + $failure = new TransformationFailedException('User with given email does not exist'); + $failureMessage = new TranslatableMessage('reset_password.user.managed_by_ldap', [], 'errors'); + $failure->setInvalidMessage($failureMessage); + throw $failure; + } + + return $user; + } + ); + + $builder->get('user')->addModelTransformer($transformer); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_token_id' => 'reset password', + 'csrf_message' => 'csrf.invalid', + ]); + } +} diff --git a/src/Message/SendResetPasswordEmail.php b/src/Message/SendResetPasswordEmail.php new file mode 100644 index 00000000..5e1bb242 --- /dev/null +++ b/src/Message/SendResetPasswordEmail.php @@ -0,0 +1,20 @@ +userId; + } +} diff --git a/src/MessageHandler/SendResetPasswordEmailHandler.php b/src/MessageHandler/SendResetPasswordEmailHandler.php new file mode 100644 index 00000000..288335f0 --- /dev/null +++ b/src/MessageHandler/SendResetPasswordEmailHandler.php @@ -0,0 +1,61 @@ +getUserId(); + $user = $this->userRepository->find($userId); + + if (!$user) { + $this->logger->error("User {$userId} cannot be found in SendResetPasswordEmailHandler."); + return; + } + + $resetToken = $user->getResetPasswordToken(); + + if (!$resetToken || !$resetToken->isValid()) { + $this->logger->error("User {$userId} resetPasswordToken is invalid in SendResetPasswordEmailHandler."); + return; + } + + $locale = $user->getLocale(); + $subject = $this->translator->trans('emails.reset_password.subject', locale: $locale); + + $email = new TemplatedEmail(); + $email->to($user->getEmail()); + $email->subject($subject); + $email->locale($locale); + $email->context([ + 'user' => $user, + 'token' => $resetToken, + ]); + $email->htmlTemplate('emails/reset_password.html.twig'); + + $this->transportInterface->send($email); + } +} diff --git a/templates/emails/reset_password.html.twig b/templates/emails/reset_password.html.twig new file mode 100644 index 00000000..a286ad28 --- /dev/null +++ b/templates/emails/reset_password.html.twig @@ -0,0 +1,21 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2024 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +

{{ 'emails.reset_password.subject' | trans }}

+ +

+ {{ 'emails.reset_password.intro' | trans }} +

+ +

+ + {{ 'emails.reset_password.reset' | trans }} + +

+ +

+ {{ 'emails.reset_password.expiration' | trans }} +

diff --git a/templates/login/new.html.twig b/templates/login/new.html.twig index 9f9256dd..83c17072 100644 --- a/templates/login/new.html.twig +++ b/templates/login/new.html.twig @@ -142,6 +142,12 @@ + +

+ + {{ 'login.forgot_password' | trans }} + +

diff --git a/templates/passwords/reset.html.twig b/templates/passwords/reset.html.twig new file mode 100644 index 00000000..182ef323 --- /dev/null +++ b/templates/passwords/reset.html.twig @@ -0,0 +1,79 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2024 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +{% extends 'base.html.twig' %} + +{% set currentPage = 'reset password' %} + +{% block title %}{{ 'passwords.reset.title' | trans }}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block body %} +
+
+

{{ 'passwords.reset.title' | trans }}

+ +

+ {{ 'passwords.reset.short' | trans }} +

+
+ +
+
+ {% if emailSent %} + {{ include('alerts/_alert.html.twig', { + type: 'info', + title: 'passwords.reset.sent.title' | trans, + message: 'passwords.reset.sent.message' | trans, + }, with_context = false) }} + {% else %} + {{ form_start(form, { attr: { + 'class': 'form--standard', + }}) }} + {{ form_errors(form) }} + +
+ + + {{ form_errors(form.user) }} + + +
+ +
+ +
+ {{ form_end(form) }} + {% endif %} +
+
+
+{% endblock %} diff --git a/tests/Controller/PasswordsControllerTest.php b/tests/Controller/PasswordsControllerTest.php new file mode 100644 index 00000000..0af63092 --- /dev/null +++ b/tests/Controller/PasswordsControllerTest.php @@ -0,0 +1,152 @@ +request(Request::METHOD_GET, '/passwords/reset'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h1', 'Reset your password'); + } + + public function testGetResetRendersWhenEmailIsSent(): void + { + $client = static::createClient(); + $user = Factory\UserFactory::createOne(); + + $client->request(Request::METHOD_GET, '/passwords/reset', [ + 'sent' => true, + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('[data-test="alert-info"]', 'Email sent'); + } + + public function testPostResetRedirectsAndSendsAnEmail(): void + { + $client = static::createClient(); + $email = 'alix@example.com'; + $user = Factory\UserFactory::createOne([ + 'email' => $email, + 'ldapIdentifier' => null, + 'resetPasswordToken' => null, + ]); + + $client->request(Request::METHOD_POST, '/passwords/reset', [ + 'reset_password' => [ + '_token' => $this->generateCsrfToken($client, 'reset password'), + 'user' => $email, + ], + ]); + + $this->assertResponseRedirects('/passwords/reset?sent=1', 302); + $user->_refresh(); + $resetPasswordToken = $user->getResetPasswordToken(); + $this->assertNotNull($resetPasswordToken); + $this->assertTrue($resetPasswordToken->isValid()); + $this->assertEmailCount(1); + $email = $this->getMailerMessage(); + $this->assertEmailHtmlBodyContains($email, 'Reset your password'); + } + + public function testPostResetFailsIfEmailIsUnknown(): void + { + $client = static::createClient(); + $email = 'alix@example.com'; + $user = Factory\UserFactory::createOne([ + 'email' => $email, + 'ldapIdentifier' => null, + 'resetPasswordToken' => null, + ]); + + $client->request(Request::METHOD_POST, '/passwords/reset', [ + 'reset_password' => [ + '_token' => $this->generateCsrfToken($client, 'reset password'), + 'user' => 'not-the-email@example.com', + ], + ]); + + $this->assertSelectorTextContains( + '#reset_password_user-error', + 'The email address is not associated to a user account' + ); + $user->_refresh(); + $resetPasswordToken = $user->getResetPasswordToken(); + $this->assertNull($resetPasswordToken); + $this->assertEmailCount(0); + } + + public function testPostResetFailsIfUserIsManagedByLdap(): void + { + $client = static::createClient(); + $email = 'alix@example.com'; + $user = Factory\UserFactory::createOne([ + 'email' => $email, + 'ldapIdentifier' => 'alix', + 'resetPasswordToken' => null, + ]); + + $client->request(Request::METHOD_POST, '/passwords/reset', [ + 'reset_password' => [ + '_token' => $this->generateCsrfToken($client, 'reset password'), + 'user' => $email, + ], + ]); + + $this->assertSelectorTextContains( + '#reset_password_user-error', + 'The email address is associated to a user account managed by LDAP' + ); + $user->_refresh(); + $resetPasswordToken = $user->getResetPasswordToken(); + $this->assertNull($resetPasswordToken); + $this->assertEmailCount(0); + } + + public function testPostResetFailsIfCsrfTokenIsInvalid(): void + { + $client = static::createClient(); + $email = 'alix@example.com'; + $user = Factory\UserFactory::createOne([ + 'email' => $email, + 'ldapIdentifier' => null, + 'resetPasswordToken' => null, + ]); + + $client->request(Request::METHOD_POST, '/passwords/reset', [ + 'reset_password' => [ + '_token' => 'not the token', + 'user' => $email, + ], + ]); + + $this->assertSelectorTextContains('#reset_password-error', 'The security token is invalid'); + $user->_refresh(); + $resetPasswordToken = $user->getResetPasswordToken(); + $this->assertNull($resetPasswordToken); + $this->assertEmailCount(0); + } +} diff --git a/translations/errors+intl-icu.en_GB.yaml b/translations/errors+intl-icu.en_GB.yaml index edbf4bac..58f81d55 100644 --- a/translations/errors+intl-icu.en_GB.yaml +++ b/translations/errors+intl-icu.en_GB.yaml @@ -37,6 +37,8 @@ organization.name.already_used: 'Enter a different name, an organization already organization.name.max_chars: 'Enter a name of less than {limit} characters.' organization.name.required: 'Enter a name.' organization.observers.not_authorized: 'The user is not authorized to access this organization.' +reset_password.user.managed_by_ldap: 'The email address is associated to a user account managed by LDAP, enter a different address.' +reset_password.user.unknown: 'The email address is not associated to a user account, enter a different address.' role.description.max_chars: 'Enter a description of less than {limit} characters.' role.description.required: 'Enter a description.' role.name.already_used: 'Enter a different name, a role already has this name.' @@ -56,6 +58,7 @@ ticket.type.invalid: 'Select a type from the list.' ticket.urgency.invalid: 'Select an urgency from the list.' time_spent.time.greater_than_zero: 'Enter a number greater than zero.' time_spent.time.required: 'Enter a number.' +token.value.already_used: 'A token already exists with the same value.' user.color_scheme.invalid: 'Select a color scheme from the list.' user.color_scheme.required: 'Select a color scheme.' user.email.already_used: 'Enter a different email address, this one is already in use.' diff --git a/translations/errors+intl-icu.fr_FR.yaml b/translations/errors+intl-icu.fr_FR.yaml index d4807fc2..2c07f36a 100644 --- a/translations/errors+intl-icu.fr_FR.yaml +++ b/translations/errors+intl-icu.fr_FR.yaml @@ -37,6 +37,8 @@ organization.name.already_used: 'Saisissez un nom différent, une organisation p organization.name.max_chars: 'Saisissez un nom de moins de {limit} caractères.' organization.name.required: 'Saisissez un nom.' organization.observers.not_authorized: 'L’utilisateur n’est pas autorisé à accéder à cette organisation.' +reset_password.user.managed_by_ldap: 'L’adresse email est associée avec un compte utilisateur géré par LDAP, saisissez une adresse différente.' +reset_password.user.unknown: 'L’adresse email n’est pas associée à un compte utilisateur connu, saisissez une adresse différente.' role.description.max_chars: 'Saisissez une description de moins de {limit} caractères.' role.description.required: 'Saisissez une description.' role.name.already_used: 'Saisissez un nom différent, un rôle porte déjà ce même nom.' @@ -56,6 +58,7 @@ ticket.type.invalid: 'Sélectionnez un type de la liste.' ticket.urgency.invalid: 'Sélectionnez une urgence de la liste.' time_spent.time.greater_than_zero: 'Saisissez un nombre supérieur à zéro.' time_spent.time.required: 'Saisissez un nombre.' +token.value.already_used: 'Un token existe déjà avec cette valeur.' user.color_scheme.invalid: 'Sélectionnez un schéma de couleurs de la liste.' user.color_scheme.required: 'Sélectionnez un schéma de couleurs.' user.email.already_used: 'Saisissez une adresse email différente, celle-ci est déjà utilisée.' diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index 817c9ce1..810a8150 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -114,6 +114,10 @@ contracts.status: Status contracts.status.coming: Coming contracts.status.finished: Finished contracts.status.ongoing: Ongoing +emails.reset_password.expiration: 'If you don’t use it before then, this link will expire in 2 hours.' +emails.reset_password.intro: 'You are receiving this email because a password reset request has been made for your account.' +emails.reset_password.reset: 'Reset your password' +emails.reset_password.subject: 'Reset your Bileto password' errors.404.description: 'The requested page couldn’t be located. Checkout for any URL misspelling.' errors.404.title: 'Page not found!' errors.back_home: 'Back to the homepage' @@ -170,6 +174,7 @@ layout.scroll_to_top: 'Scroll to top' layout.skip_to_main_content: 'Skip to main content' layout.tickets: Tickets login.intro: 'Manage your support
with ease' +login.forgot_password: 'Forgot your password?' login.remember_me: 'Remember me' login.submit: Login login.title: 'Log in' @@ -231,6 +236,12 @@ organizations.settings.deletion.title: 'Delete an organization' organizations.settings.title: Settings pagination.next: Next pagination.previous: Previous +passwords.reset.form.email: 'Email address' +passwords.reset.form.submit: 'Send reset email' +passwords.reset.sent.message: 'We have just sent you an email containing a link to reset your password. If you haven’t received it in a few minutes, please check your spam folder.' +passwords.reset.sent.title: 'Email sent' +passwords.reset.short: 'Enter your email address and we will send you a link to reset your password.' +passwords.reset.title: 'Reset your password' preferences.title: Preferences profile.current_password: 'Current password' profile.ldap.information: Information diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index ad9a3dbb..54ce6dd5 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -114,6 +114,10 @@ contracts.status: Statut contracts.status.coming: 'À venir' contracts.status.finished: Terminé contracts.status.ongoing: 'En cours' +emails.reset_password.expiration: 'Si vous ne l’utilisez pas avant, ce lien expirera dans 2 heures.' +emails.reset_password.intro: 'Vous recevez cet email car une demande de réinialisation de mot de passe a été faite pour votre compte.' +emails.reset_password.reset: 'Réinitialiser votre mot de passe' +emails.reset_password.subject: 'Réinitialisez votre mot de passe Bileto' errors.404.description: 'La page demandée n’a pas été trouvée. Vérifiez qu’il n’y a pas de faute dans l’URL.' errors.404.title: "Page non trouvée\_!" errors.back_home: 'Retour à la page d’accueil' @@ -170,6 +174,7 @@ layout.scroll_to_top: 'Défiler en haut' layout.skip_to_main_content: 'Accéder au contenu principal' layout.tickets: Tickets login.intro: 'Gérez votre support
en toute simplicité' +login.forgot_password: "Mot de passe oublié\_?" login.remember_me: 'Se souvenir de moi' login.submit: 'Se connecter' login.title: Connexion @@ -231,6 +236,12 @@ organizations.settings.deletion.title: Suppression organizations.settings.title: Gestion pagination.next: Suivant pagination.previous: Précédent +passwords.reset.form.email: 'Adresse email' +passwords.reset.form.submit: 'Envoyer le mail de réinitialisation' +passwords.reset.sent.message: 'Nous venons de vous envoyer un email contenant un lien pour réinitialiser votre mot de passe. Si vous ne l’avez pas reçu d’ici quelques minutes, veuillez vérifier vos spams.' +passwords.reset.sent.title: 'Email envoyé' +passwords.reset.short: 'Saisissez votre adresse email et nous vous enverrons un lien pour réinitialiser votre mot de passe.' +passwords.reset.title: 'Réinitialisation de votre mot de passe' preferences.title: Préférences profile.current_password: 'Mot de passe actuel' profile.ldap.information: Information From 4e7cb7afcec2e5c0e7c045a727a16d7880d0e356 Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Fri, 16 Aug 2024 11:49:34 +0200 Subject: [PATCH 3/3] Allow to edit the password with a token --- config/packages/security.yaml | 1 + src/Controller/PasswordsController.php | 51 ++++++++ src/Form/Password/EditForm.php | 34 +++++ src/Repository/UserRepository.php | 16 +++ templates/emails/reset_password.html.twig | 2 +- templates/login/new.html.twig | 8 ++ templates/passwords/edit.html.twig | 89 +++++++++++++ tests/Controller/PasswordsControllerTest.php | 130 +++++++++++++++++++ translations/messages+intl-icu.en_GB.yaml | 6 + translations/messages+intl-icu.fr_FR.yaml | 6 + 10 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/Form/Password/EditForm.php create mode 100644 templates/passwords/edit.html.twig diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 330ffe94..e6ebc9e2 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -44,6 +44,7 @@ security: - { path: ^/about, role: PUBLIC_ACCESS } - { path: ^/login, role: PUBLIC_ACCESS } - { path: ^/passwords/reset, role: PUBLIC_ACCESS } + - { path: ^/passwords/(\w+)/edit, role: PUBLIC_ACCESS } - { path: ^/session/locale, role: PUBLIC_ACCESS } - { path: ^/app.manifest, role: PUBLIC_ACCESS } - { path: ^/, role: ROLE_USER } diff --git a/src/Controller/PasswordsController.php b/src/Controller/PasswordsController.php index b777e6f3..41c7eca2 100644 --- a/src/Controller/PasswordsController.php +++ b/src/Controller/PasswordsController.php @@ -10,6 +10,7 @@ use App\Form; use App\Message; use App\Repository; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Messenger\MessageBusInterface; @@ -52,4 +53,54 @@ public function reset( 'emailSent' => $sent, ]); } + + #[Route('/passwords/{token}/edit', name: 'edit password')] + public function edit( + string $token, + Request $request, + Repository\TokenRepository $tokenRepository, + Repository\UserRepository $userRepository, + #[Autowire(env: 'bool:LDAP_ENABLED')] + bool $ldapEnabled, + ): Response { + $user = $userRepository->findOneByResetPasswordToken($token); + + if (!$user) { + throw $this->createNotFoundException('The token does not exist.'); + } + + $resetPasswordToken = $user->getResetPasswordToken(); + + if (!$resetPasswordToken || !$resetPasswordToken->isValid()) { + throw $this->createNotFoundException('The token does not exist.'); + } + + $managedByLdap = $ldapEnabled && $user->getAuthType() === 'ldap'; + + if ($managedByLdap) { + throw $this->createNotFoundException('The user is managed by LDAP.'); + } + + $form = $this->createNamedForm('edit_password', Form\Password\EditForm::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user = $form->getData(); + + $user->setResetPasswordToken(null); + + $userRepository->save($user, true); + + $tokenRepository->remove($resetPasswordToken, true); + + $this->addFlash('password_changed', true); + + return $this->redirectToRoute('login'); + } + + return $this->render('passwords/edit.html.twig', [ + 'form' => $form, + 'user' => $user, + ]); + } } diff --git a/src/Form/Password/EditForm.php b/src/Form/Password/EditForm.php new file mode 100644 index 00000000..6c25a6fe --- /dev/null +++ b/src/Form/Password/EditForm.php @@ -0,0 +1,34 @@ +add('plainPassword', Type\PasswordType::class, [ + 'empty_data' => '', + 'hash_property_path' => 'password', + 'mapped' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Entity\User::class, + 'csrf_token_id' => 'edit password', + 'csrf_message' => 'csrf.invalid', + ]); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index ee6e2ab4..f5208942 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -130,6 +130,22 @@ public function findByOrganizationIds(array $orgaIds, string $roleType = 'any'): return $query->getResult(); } + public function findOneByResetPasswordToken(string $token): ?User + { + $entityManager = $this->getEntityManager(); + + $query = $entityManager->createQuery(<<setParameter('token', $token); + + return $query->getOneOrNullResult(); + } + /** * Used to upgrade (rehash) the user's password automatically over time. */ diff --git a/templates/emails/reset_password.html.twig b/templates/emails/reset_password.html.twig index a286ad28..ae0c6ec0 100644 --- a/templates/emails/reset_password.html.twig +++ b/templates/emails/reset_password.html.twig @@ -11,7 +11,7 @@

- + {{ 'emails.reset_password.reset' | trans }}

diff --git a/templates/login/new.html.twig b/templates/login/new.html.twig index 83c17072..5b4384ca 100644 --- a/templates/login/new.html.twig +++ b/templates/login/new.html.twig @@ -67,6 +67,14 @@ {{ 'login.title' | trans }} + {% if app.flashes('password_changed') %} + {{ include('alerts/_alert.html.twig', { + type: 'success', + title: 'login.password_changed.title' | trans, + message: 'login.password_changed.message' | trans, + }, with_context = false) }} + {% endif %} +
diff --git a/templates/passwords/edit.html.twig b/templates/passwords/edit.html.twig new file mode 100644 index 00000000..ef9abd6f --- /dev/null +++ b/templates/passwords/edit.html.twig @@ -0,0 +1,89 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2024 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +{% extends 'base.html.twig' %} + +{% set currentPage = 'edit password' %} + +{% block title %}{{ 'passwords.edit.title' | trans }}{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block body %} +
+
+

{{ 'passwords.edit.title' | trans }}

+ +

+ {{ 'passwords.edit.short' | trans({ email: user.email }) | raw }} +

+
+ +
+
+ {{ form_start(form, { attr: { + 'class': 'form--standard', + }}) }} + {{ form_errors(form) }} + +
+ + + {{ form_errors(form.plainPassword) }} + +
+ + + +
+
+ +
+ +
+ {{ form_end(form) }} +
+
+
+{% endblock %} diff --git a/tests/Controller/PasswordsControllerTest.php b/tests/Controller/PasswordsControllerTest.php index 0af63092..09df645d 100644 --- a/tests/Controller/PasswordsControllerTest.php +++ b/tests/Controller/PasswordsControllerTest.php @@ -8,8 +8,10 @@ use App\Tests; use App\Tests\Factory; +use App\Utils; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -149,4 +151,132 @@ public function testPostResetFailsIfCsrfTokenIsInvalid(): void $this->assertNull($resetPasswordToken); $this->assertEmailCount(0); } + + public function testGetEditRendersCorrectly(): void + { + $client = static::createClient(); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::fromNow(2, 'hours'), + ]); + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => $token, + 'ldapIdentifier' => null, + ]); + + $client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit"); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h1', 'Changing password'); + } + + public function testGetEditFailsIfTokenIsNotAssociatedToAUser(): void + { + $this->expectException(NotFoundHttpException::class); + + $client = static::createClient(); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::fromNow(2, 'hours'), + ]); + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => null, + 'ldapIdentifier' => null, + ]); + + $client->catchExceptions(false); + $client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit"); + } + + public function testGetEditFailsIfTokenIsExpired(): void + { + $this->expectException(NotFoundHttpException::class); + + $client = static::createClient(); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::ago(2, 'hours'), + ]); + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => $token, + 'ldapIdentifier' => null, + ]); + + $client->catchExceptions(false); + $client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit"); + } + + public function testGetEditFailsIfUserIsManagedByLdap(): void + { + $this->expectException(NotFoundHttpException::class); + + $client = static::createClient(); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::fromNow(2, 'hours'), + ]); + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => $token, + 'ldapIdentifier' => 'alix', + ]); + + $client->catchExceptions(false); + $client->request(Request::METHOD_GET, "/passwords/{$token->getValue()}/edit"); + } + + public function testPostEditChangesThePasswordAndRedirects(): void + { + $client = static::createClient(); + /** @var \Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface */ + $passwordHasher = self::getContainer()->get('security.user_password_hasher'); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::fromNow(2, 'hours'), + ]); + $initialPassword = 'a password'; + $newPassword = 'secret'; + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => $token, + 'ldapIdentifier' => null, + 'password' => $initialPassword, + ]); + + $client->request(Request::METHOD_POST, "/passwords/{$token->getValue()}/edit", [ + 'edit_password' => [ + '_token' => $this->generateCsrfToken($client, 'edit password'), + 'plainPassword' => $newPassword, + ], + ]); + + $this->assertResponseRedirects('/login', 302); + $user->_refresh(); + $this->assertFalse($passwordHasher->isPasswordValid($user->_real(), $initialPassword)); + $this->assertTrue($passwordHasher->isPasswordValid($user->_real(), $newPassword)); + Factory\TokenFactory::assert()->notExists(['id' => $token->getId()]); + } + + public function testPostEditFailsIfCsrfTokenIsInvalid(): void + { + $client = static::createClient(); + /** @var \Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface */ + $passwordHasher = self::getContainer()->get('security.user_password_hasher'); + $token = Factory\TokenFactory::createOne([ + 'expiredAt' => Utils\Time::fromNow(2, 'hours'), + ]); + $initialPassword = 'a password'; + $newPassword = 'secret'; + $user = Factory\UserFactory::createOne([ + 'resetPasswordToken' => $token, + 'ldapIdentifier' => null, + 'password' => $initialPassword, + ]); + + $client->request(Request::METHOD_POST, "/passwords/{$token->getValue()}/edit", [ + 'edit_password' => [ + '_token' => 'not a token', + 'plainPassword' => $newPassword, + ], + ]); + + $this->assertSelectorTextContains('#edit_password-error', 'The security token is invalid'); + $user->_refresh(); + $this->assertTrue($passwordHasher->isPasswordValid($user->_real(), $initialPassword)); + $this->assertFalse($passwordHasher->isPasswordValid($user->_real(), $newPassword)); + Factory\TokenFactory::assert()->exists(['id' => $token->getId()]); + } } diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index 810a8150..2aab0ba5 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -175,6 +175,8 @@ layout.skip_to_main_content: 'Skip to main content' layout.tickets: Tickets login.intro: 'Manage your support
with ease' login.forgot_password: 'Forgot your password?' +login.password_changed.message: 'Your password has been changed. All you have to do now is log in.' +login.password_changed.title: 'Password changed' login.remember_me: 'Remember me' login.submit: Login login.title: 'Log in' @@ -236,6 +238,10 @@ organizations.settings.deletion.title: 'Delete an organization' organizations.settings.title: Settings pagination.next: Next pagination.previous: Previous +passwords.edit.form.password: 'New password' +passwords.edit.form.submit: 'Change the password' +passwords.edit.short: 'You are about to change the password for the account alix@example.com. ' +passwords.edit.title: 'Changing password' passwords.reset.form.email: 'Email address' passwords.reset.form.submit: 'Send reset email' passwords.reset.sent.message: 'We have just sent you an email containing a link to reset your password. If you haven’t received it in a few minutes, please check your spam folder.' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 54ce6dd5..929290ed 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -175,6 +175,8 @@ layout.skip_to_main_content: 'Accéder au contenu principal' layout.tickets: Tickets login.intro: 'Gérez votre support
en toute simplicité' login.forgot_password: "Mot de passe oublié\_?" +login.password_changed.message: 'Votre mot de passe a bien été changé. Vous n’avez plus qu’à vous connecter.' +login.password_changed.title: 'Mot de passe changé' login.remember_me: 'Se souvenir de moi' login.submit: 'Se connecter' login.title: Connexion @@ -236,6 +238,10 @@ organizations.settings.deletion.title: Suppression organizations.settings.title: Gestion pagination.next: Suivant pagination.previous: Précédent +passwords.edit.form.password: 'Nouveau mot de passe' +passwords.edit.form.submit: 'Changer le mot de passe' +passwords.edit.short: 'Vous êtes sur le point de changer le mot de passe du compte {email}.' +passwords.edit.title: 'Changement de mot de passe' passwords.reset.form.email: 'Adresse email' passwords.reset.form.submit: 'Envoyer le mail de réinitialisation' passwords.reset.sent.message: 'Nous venons de vous envoyer un email contenant un lien pour réinitialiser votre mot de passe. Si vous ne l’avez pas reçu d’ici quelques minutes, veuillez vérifier vos spams.'