+
+{% 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 @@