diff --git a/.env b/.env index 63c72abd7..f640111a5 100644 --- a/.env +++ b/.env @@ -18,6 +18,7 @@ APP_ENV=dev APP_SERVER_TIMEZONE=UTC APP_CLIENT_TIMEZONE=Europe/Paris APP_SECRET=abc +BASE_URL=https://dialog.beta.gouv.fr APP_EUDONET_PARIS_BASE_URL=https://eudonet-partage.apps.paris.fr APP_BAC_IDF_DECREES_FILE=data/bac_idf/decrees.json APP_BAC_IDF_CITIES_FILE=data/bac_idf/cities.csv diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 69ea6e298..51e2d9b67 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -3,6 +3,7 @@ twig: form_themes: ['common/form/dsfr_theme.html.twig'] globals: dialogOrgId: '%env(DIALOG_ORG_ID)%' + baseUrl: '%env(BASE_URL)%' when@test: twig: strict_variables: true diff --git a/docker-compose.yml b/docker-compose.yml index 0031f6206..f633f05b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,8 @@ services: volumes: - ./:/var/www/dialog - ./docker/php/supervisor:/etc/supervisor - - ./var/log/supervisor:/var/log/supervisor + environment: + DATABASE_URL: ${DATABASE_URL} depends_on: - database - redis @@ -95,3 +96,4 @@ services: - REDIS_HOSTS=local:redis:6379 depends_on: - redis + - database diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 21f656272..f7d81cd96 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -55,6 +55,7 @@ RUN apt-get install -y wget && \ # Install Supervisor +RUN mkdir -p /var/log/supervisor && chown www-data:www-data /var/log/supervisor RUN apt-get update && apt-get install -y supervisor RUN mkdir -p /var/log/supervisor COPY supervisor/supervisord.conf /etc/supervisor/supervisord.conf diff --git a/docker/php/supervisor/conf.d/messenger-worker.conf b/docker/php/supervisor/conf.d/messenger-worker.conf index 1d59cbd5c..bbefa0eb5 100644 --- a/docker/php/supervisor/conf.d/messenger-worker.conf +++ b/docker/php/supervisor/conf.d/messenger-worker.conf @@ -5,3 +5,5 @@ autostart=true autorestart=true process_name=%(program_name)s_%(process_num)02d user=www-data +stdout_logfile=/var/log/supervisor/messenger-worker_%(process_num)02d.log +stderr_logfile=/var/log/supervisor/messenger-worker_%(process_num)02d.error.log diff --git a/docker/php/supervisor/supervisord.conf b/docker/php/supervisor/supervisord.conf index bb5a27d7a..8ba68c1ff 100644 --- a/docker/php/supervisor/supervisord.conf +++ b/docker/php/supervisor/supervisord.conf @@ -15,4 +15,4 @@ supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface serverurl=unix:///var/run/supervisor.sock [include] -files = /etc/suggpervisor/conf.d/*.conf +files = /etc/supervisor/conf.d/*.conf diff --git a/src/Application/MailerInterface.php b/src/Application/MailerInterface.php new file mode 100644 index 000000000..fa97cff21 --- /dev/null +++ b/src/Application/MailerInterface.php @@ -0,0 +1,12 @@ +email)); + + try { + $token = $this->commandBus->handle( + new CreateTokenCommand( + $email, + TokenTypeEnum::FORGOT_PASSWORD->value, + ), + ); + + $this->mailer->send( + new Mail( + address: $email, + subject: 'forgot_password.subjet', + template: 'email/user/forgot-password.html.twig', + payload: [ + 'token' => $token, + ], + ), + ); + } catch (UserNotFoundException) { + // Do nothing. + } + } +} diff --git a/src/Application/User/Command/ResetPasswordCommand.php b/src/Application/User/Command/ResetPasswordCommand.php new file mode 100644 index 000000000..6847b500d --- /dev/null +++ b/src/Application/User/Command/ResetPasswordCommand.php @@ -0,0 +1,17 @@ +tokenRepository->findOneByTokenAndType( + $command->token, + TokenTypeEnum::FORGOT_PASSWORD->value, + ); + + if (!$token instanceof Token) { + throw new TokenNotFoundException(); + } + + if ($this->isTokenExpired->isSatisfiedBy($token)) { + throw new TokenExpiredException(); + } + + $password = $this->passwordHasher->hash($command->password); + $token->getUser()->getPasswordUser()->setPassword($password); + + $this->tokenRepository->remove($token); + } +} diff --git a/src/Domain/Mail.php b/src/Domain/Mail.php new file mode 100644 index 000000000..2bf48bed9 --- /dev/null +++ b/src/Domain/Mail.php @@ -0,0 +1,16 @@ +dateUtils->getNow() > $token->getExpirationDate(); + } +} diff --git a/src/Infrastructure/Adapter/Mailer.php b/src/Infrastructure/Adapter/Mailer.php new file mode 100644 index 000000000..bc24063ca --- /dev/null +++ b/src/Infrastructure/Adapter/Mailer.php @@ -0,0 +1,32 @@ +mailer->send( + (new TemplatedEmail()) + ->to(new Address($mail->address)) + ->subject($this->translator->trans($mail->subject, [], 'emails')) + ->htmlTemplate($mail->template) + ->context($mail->payload), + ); + } +} diff --git a/src/Infrastructure/Controller/Security/ForgotPasswordController.php b/src/Infrastructure/Controller/Security/ForgotPasswordController.php index c1d56cf3e..ed51a8d67 100644 --- a/src/Infrastructure/Controller/Security/ForgotPasswordController.php +++ b/src/Infrastructure/Controller/Security/ForgotPasswordController.php @@ -4,21 +4,56 @@ namespace App\Infrastructure\Controller\Security; +use App\Application\CommandBusInterface; +use App\Application\User\Command\Mail\SendForgotPasswordMailCommand; +use App\Infrastructure\Form\User\ForgotPasswordFormType; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; -final class ForgotPasswordController +final readonly class ForgotPasswordController { public function __construct( private \Twig\Environment $twig, + private FormFactoryInterface $formFactory, + private CommandBusInterface $commandBus, + private UrlGeneratorInterface $urlGenerator, + private TranslatorInterface $translator, ) { } - #[Route('/mot-de-passe-oublie', name: 'app_forgot_password', methods: ['GET'])] - public function __invoke(): Response + #[Route('/forgot-password', name: 'app_forgot_password', methods: ['GET', 'POST'])] + public function __invoke(Request $request): Response { + $command = new SendForgotPasswordMailCommand(); + $form = $this->formFactory->create(ForgotPasswordFormType::class, $command); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->commandBus->dispatchAsync($command); + + /** @var FlashBagAwareSessionInterface */ + $session = $request->getSession(); + $session->getFlashBag()->add('success', $this->translator->trans('forgot_password.succeeded')); + + return new RedirectResponse($this->urlGenerator->generate('app_forgot_password')); + } + return new Response( - $this->twig->render('forgot-password.html.twig'), + content: $this->twig->render( + name: 'forgot-password.html.twig', + context: [ + 'form' => $form->createView(), + ], + ), + status: ($form->isSubmitted() && !$form->isValid()) + ? Response::HTTP_UNPROCESSABLE_ENTITY + : Response::HTTP_OK, ); } } diff --git a/src/Infrastructure/Controller/Security/ResetPasswordController.php b/src/Infrastructure/Controller/Security/ResetPasswordController.php new file mode 100644 index 000000000..934083bdf --- /dev/null +++ b/src/Infrastructure/Controller/Security/ResetPasswordController.php @@ -0,0 +1,67 @@ +getSession(); + + $command = new ResetPasswordCommand($token); + $form = $this->formFactory->create(ResetPasswordFormType::class, $command); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->commandBus->handle($command); + $session->getFlashBag()->add('success', $this->translator->trans('reset_password.succeeded')); + + return new RedirectResponse($this->urlGenerator->generate('app_login')); + } catch (TokenNotFoundException|TokenExpiredException) { + $session->getFlashBag()->add('error', $this->translator->trans('reset_password.token.error')); + + return new RedirectResponse($this->urlGenerator->generate('app_forgot_password')); + } + } + + return new Response( + content: $this->twig->render( + name: 'reset-password.html.twig', + context: [ + 'form' => $form->createView(), + ], + ), + status: ($form->isSubmitted() && !$form->isValid()) + ? Response::HTTP_UNPROCESSABLE_ENTITY + : Response::HTTP_OK, + ); + } +} diff --git a/src/Infrastructure/Form/User/ForgotPasswordFormType.php b/src/Infrastructure/Form/User/ForgotPasswordFormType.php new file mode 100644 index 000000000..f7825e409 --- /dev/null +++ b/src/Infrastructure/Form/User/ForgotPasswordFormType.php @@ -0,0 +1,28 @@ +add('email', EmailType::class, [ + 'label' => 'register.email', + ]) + ->add('save', SubmitType::class, + options: [ + 'label' => 'common.submit', + 'attr' => ['class' => 'fr-btn'], + ], + ) + ; + } +} diff --git a/src/Infrastructure/Form/User/ResetPasswordFormType.php b/src/Infrastructure/Form/User/ResetPasswordFormType.php new file mode 100644 index 000000000..f33b506b8 --- /dev/null +++ b/src/Infrastructure/Form/User/ResetPasswordFormType.php @@ -0,0 +1,36 @@ +add('password', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => [ + 'label' => 'reset_password.password', + 'help' => 'register.password.help', + ], + 'second_options' => [ + 'label' => 'reset_password.repeated_password', + ], + ]) + ->add('save', SubmitType::class, + options: [ + 'label' => 'reset_password.submit', + 'attr' => ['class' => 'fr-btn'], + ], + ) + ; + } +} diff --git a/templates/email/base.html.twig b/templates/email/base.html.twig new file mode 100644 index 000000000..0b8686af3 --- /dev/null +++ b/templates/email/base.html.twig @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/templates/email/user/forgot-password.html.twig b/templates/email/user/forgot-password.html.twig new file mode 100644 index 000000000..ca2ed8c29 --- /dev/null +++ b/templates/email/user/forgot-password.html.twig @@ -0,0 +1,11 @@ +{% extends 'email/base.html.twig' %} +{% trans_default_domain 'emails' %} + +{% block body %} +

{{ 'forgot_password.title'|trans }}

+

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

+{% endblock %} diff --git a/templates/forgot-password.html.twig b/templates/forgot-password.html.twig index b5a4e8cac..a3180a93e 100644 --- a/templates/forgot-password.html.twig +++ b/templates/forgot-password.html.twig @@ -12,9 +12,13 @@

{{ 'forgot_password.title'|trans }}

-

{{ 'forgot_password.instructions'|trans }}

-

- {{ 'forgot_password.go_back'|trans }} +

{{ 'forgot_password.description'|trans }}

+ {{ form_start(form) }} + {{ form_row(form.email, {group_class: 'fr-input-group', attr: {class: 'fr-input'} }) }} + {{ form_row(form.save) }} + {{ form_end(form) }} +

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

diff --git a/templates/reset-password.html.twig b/templates/reset-password.html.twig new file mode 100644 index 000000000..1effc517a --- /dev/null +++ b/templates/reset-password.html.twig @@ -0,0 +1,38 @@ +{% extends 'layouts/layout.html.twig' %} + +{% block title %} + {{'register.meta.title'|trans}} - {{ parent() }} +{% endblock %} + +{% block body %} +
+
+
+

{{ 'register.meta.title'|trans }}

+

{{ 'register.meta.subtitle'|trans }}

+
+
+
+
+
+
+
+
+
+ {{ form_start(form) }} + {{ form_row(form.password.first, {group_class: 'fr-input-group', attr: {class: 'fr-input'} }) }} + {{ form_row(form.password.second, {group_class: 'fr-input-group', attr: {class: 'fr-input'} }) }} + {{ form_widget(form.save) }} + {{ form_end(form) }} +
+
+
+ +
+
+
+{% endblock %} diff --git a/tests/Unit/Application/User/Command/Mail/SendForgotPasswordMailCommandHandlerTest.php b/tests/Unit/Application/User/Command/Mail/SendForgotPasswordMailCommandHandlerTest.php new file mode 100644 index 000000000..91d916657 --- /dev/null +++ b/tests/Unit/Application/User/Command/Mail/SendForgotPasswordMailCommandHandlerTest.php @@ -0,0 +1,69 @@ +createMock(CommandBusInterface::class); + $commandBus + ->expects(self::once()) + ->method('handle') + ->with(new CreateTokenCommand('mathieu@fairness.coop', TokenTypeEnum::FORGOT_PASSWORD->value)) + ->willReturn('myToken'); + + $mail = $this->createMock(MailerInterface::class); + $mail + ->expects(self::once()) + ->method('send') + ->with( + $this->equalTo( + new Mail( + address: 'mathieu@fairness.coop', + subject: 'forgot_password.subjet', + template: 'email/user/forgot-password.html.twig', + payload: [ + 'token' => 'myToken', + ], + ), + ), + ); + + $handler = new SendForgotPasswordMailCommandHandler($commandBus, $mail); + $command = new SendForgotPasswordMailCommand(); + $command->email = ' Mathieu@Fairness.coop '; + ($handler)($command); + } + + public function testUserNotFound(): void + { + $commandBus = $this->createMock(CommandBusInterface::class); + $commandBus + ->expects(self::once()) + ->method('handle') + ->willThrowException(new UserNotFoundException()); + + $mail = $this->createMock(MailerInterface::class); + $mail + ->expects(self::never()) + ->method('send'); + + $handler = new SendForgotPasswordMailCommandHandler($commandBus, $mail); + $command = new SendForgotPasswordMailCommand(); + $command->email = ' Mathieu@Fairness.coop '; + ($handler)($command); + } +} diff --git a/tests/Unit/Application/User/Command/ResetPasswordCommandHandlerTest.php b/tests/Unit/Application/User/Command/ResetPasswordCommandHandlerTest.php new file mode 100644 index 000000000..fbf818cd8 --- /dev/null +++ b/tests/Unit/Application/User/Command/ResetPasswordCommandHandlerTest.php @@ -0,0 +1,128 @@ +passwordHasher = $this->createMock(PasswordHasherInterface::class); + $this->tokenRepository = $this->createMock(TokenRepositoryInterface::class); + $this->isTokenExpired = $this->createMock(IsTokenExpired::class); + } + + public function testResetPassword(): void + { + $user = $this->createMock(User::class); + $user + ->expects(self::once()) + ->method('updatePassword') + ->with('newPasswordHash'); + + $token = $this->createMock(Token::class); + $token + ->expects(self::once()) + ->method('getUser') + ->willReturn($user); + + $this->tokenRepository + ->expects(self::once()) + ->method('findOneByTokenAndType') + ->with('myToken', TokenTypeEnum::FORGOT_PASSWORD->value) + ->willReturn($token); + + $this->isTokenExpired + ->expects(self::once()) + ->method('isSatisfiedBy') + ->with($token) + ->willReturn(false); + + $this->passwordHasher + ->expects(self::once()) + ->method('hash') + ->with('newPassword') + ->willReturn('newPasswordHash'); + + $this->tokenRepository + ->expects(self::once()) + ->method('remove') + ->with($token); + + $command = new ResetPasswordCommand('myToken'); + $command->password = 'newPassword'; + $handler = new ResetPasswordCommandHandler($this->tokenRepository, $this->isTokenExpired, $this->passwordHasher); + + ($handler)($command); + } + + public function testTokenExpired(): void + { + $this->expectException(TokenExpiredException::class); + $token = $this->createMock(Token::class); + + $this->tokenRepository + ->expects(self::once()) + ->method('findOneByTokenAndType') + ->with('myToken', TokenTypeEnum::FORGOT_PASSWORD->value) + ->willReturn($token); + + $this->isTokenExpired + ->expects(self::once()) + ->method('isSatisfiedBy') + ->with($token) + ->willReturn(true); + + $this->passwordHasher + ->expects(self::never()) + ->method('hash'); + + $command = new ResetPasswordCommand('myToken'); + $command->password = 'newPassword'; + $handler = new ResetPasswordCommandHandler($this->tokenRepository, $this->isTokenExpired, $this->passwordHasher); + + ($handler)($command); + } + + public function testTokenNotFound(): void + { + $this->expectException(TokenNotFoundException::class); + $this->tokenRepository + ->expects(self::once()) + ->method('findOneByTokenAndType') + ->with('myToken', TokenTypeEnum::FORGOT_PASSWORD->value) + ->willReturn(null); + + $this->isTokenExpired + ->expects(self::never()) + ->method('isSatisfiedBy'); + + $this->passwordHasher + ->expects(self::never()) + ->method('hash'); + + $command = new ResetPasswordCommand('myToken'); + $command->password = 'newPassword'; + $handler = new ResetPasswordCommandHandler($this->tokenRepository, $this->isTokenExpired, $this->passwordHasher); + + ($handler)($command); + } +} diff --git a/tests/Unit/Domain/User/Specification/IsTokenExpiredTest.php b/tests/Unit/Domain/User/Specification/IsTokenExpiredTest.php new file mode 100644 index 000000000..3c4f137bf --- /dev/null +++ b/tests/Unit/Domain/User/Specification/IsTokenExpiredTest.php @@ -0,0 +1,49 @@ +createMock(Token::class); + $token + ->expects(self::once()) + ->method('getExpirationDate') + ->willReturn(new \DateTime('2023-08-30 19:00:00')); + + $dateUtils = $this->createMock(DateUtilsInterface::class); + $dateUtils + ->expects(self::once()) + ->method('getNow') + ->willReturn(new \DateTimeImmutable('2023-08-31 09:00:00')); + + $pattern = new IsTokenExpired($dateUtils); + $this->assertTrue($pattern->isSatisfiedBy($token)); + } + + public function testTokenNotExpired(): void + { + $token = $this->createMock(Token::class); + $token + ->expects(self::once()) + ->method('getExpirationDate') + ->willReturn(new \DateTime('2023-08-30 19:30:00')); + + $dateUtils = $this->createMock(DateUtilsInterface::class); + $dateUtils + ->expects(self::once()) + ->method('getNow') + ->willReturn(new \DateTimeImmutable('2023-08-30 19:00:00')); + + $pattern = new IsTokenExpired($dateUtils); + $this->assertFalse($pattern->isSatisfiedBy($token)); + } +} diff --git a/translations/emails.fr.xlf b/translations/emails.fr.xlf new file mode 100644 index 000000000..f63e473c7 --- /dev/null +++ b/translations/emails.fr.xlf @@ -0,0 +1,91 @@ + + + + + + common.team + L’équipe Jeunot. + + + common.best_regards + Cordialement, + + + confirm_user_account.subjet + Confirmez votre compte + + + confirm_user_account.welcome + Bonjour, + + + confirm_user_account.thanks + Merci d’avoir rejoint Jeunot ! + + + confirm_user_account.confirm_email + Pour finaliser la création de votre compte, merci de cliquer sur le lien ci-dessous: + + + confirm_user_account.confirm_email_button + Je confirme mon adresse e-mail + + + confirm_user_account.help + Si vous rencontrez des difficultés pour vous connecter à votre compte, contactez-nous à contact@jeunot.com. + + + forgot_password.subjet + Changer de mot de passe + + + forgot_password.title + Vous avez demandé la réinitialisation du mot de passe associé à votre compte. Pour le modifier, cliquez ci-dessous : + + + forgot_password.reset + Modifier mon mot de passe + + + events.user_registration.subjet + Confirmation d'inscription à l'événement + + + events.user_registration.confirmed + Votre inscription à l'événement "%title%" est confirmé ! + + + events.user_registration.information + Vous retrouverez toutes les informations relatives à votre événement ci-dessous : + + + events.user_registration.information.location + Où : %location% + + + events.user_registration.information.when + Quand : + + + events.user_registration.information.owner + Organisé par : %firstName% + + + events.comment.subjet + Vous avez reçu un commentaire concernant votre événement + + + events.comment.title + Vous avez reçu un commentaire concernant l'événement : "%title%" + + + events.comment.message_from + Message de %firstName% + + + events.comment.answer + Vous pouvez lui répondre directement à l'adresse e-mail suivante : %email% + + + + diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index f3ed4a140..351bae727 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -1646,15 +1646,23 @@ forgot_password.title - Mot de passe oublié + Mot de passe oublié ? + + + forgot_password.submit + Envoyer un lien de réinitialisation + + + forgot_password.description + Entrez votre mail ci-dessous. Nous vous enverrons un lien de réinitialisation de votre mot de passe. - - forgot_password.instructions - Pour toute demande de réinitialisation de mot de passe, veuillez contacter notre équipe à l'adresse dialog@beta.gouv.fr en précisant l'adresse e-mail du compte que vous souhaitez réinitialiser. + + forgot_password.succeeded + Si cette adresse e-mail est enregistrée chez DiaLog, vous recevrez un lien pour réinitialiser votre mot de passe. - - forgot_password.go_back - Revenir au formulaire de connexion + + forgot_password.back_to_login + Retour à la connexion home.breadcrumb @@ -2667,6 +2675,34 @@ organization.form.image_description Vue d’ensemble schématique de l’arrêté montrant l’emplacement du logo. + + reset_password.succeeded + Votre mot de passe a bien été changé. Vous pouvez dès à présent vous connecter en utilisant votre nouveau mot de passe. + + + reset_password.password.old + Mot de passe actuel + + + reset_password.password + Nouveau mot de passe + + + reset_password.repeated_password + Retaper votre nouveau mot de passe + + + reset_password.submit + Changer mon mot de passe + + + reset_password.title + Changer mon mot de passe + + + reset_password.token.error + Le changement de mot de passe a échoué, veuillez faire une nouvelle demande. +