diff --git a/webapp/src/Controller/User/SignupController.php b/webapp/src/Controller/User/SignupController.php new file mode 100644 index 0000000000000000000000000000000000000000..3e876496ec3c1a56f2b5dc2463b5ce053322ace7 --- /dev/null +++ b/webapp/src/Controller/User/SignupController.php @@ -0,0 +1,153 @@ +<?php + +/* + * This file is part of the Comptoir-du-Libre software. + * <https://gitlab.adullact.net/Comptoir/comptoir-du-libre> + * + * Copyright (c) ADULLACT <https://adullact.org> + * Association des Développeurs et Utilisateurs de Logiciels Libres + * pour les Administrations et les Collectivités Territoriales + * + * Comptoir-du-Libre is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU Affero General Public License + * along with this software. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>. + */ + +declare(strict_types=1); + +namespace App\Controller\User; + +use App\Entity\User; +use App\Form\User\SignupFormType; +use App\Repository\UserRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +#[\Symfony\Component\Routing\Attribute\Route( + requirements: [ + '_locale' => 'en|fr', + ], +)] +class SignupController extends AbstractController +{ + #[Route( + path: '/{_locale}/account/signup', + name: 'app_account_signup_init', + methods: ['GET'] + )] + public function signupInit( + Request $request, + TokenGeneratorInterface $tokenGenerator, + AuthorizationCheckerInterface $authChecker, + ): RedirectResponse { + if (true === $authChecker->isGranted('ROLE_USER')) { + return $this->redirectToRoute(route: 'app_user_account', status: Response::HTTP_SEE_OTHER); + } + + $session = $request->getSession(); + if ($session->has('signup_url_token') === true) { + $signupAntispamToken = $session->get('signup_url_token'); + } else { + $signupAntispamToken = $tokenGenerator->generateToken(); + $session->set('signup_url_token', $signupAntispamToken); + $session->set('signup_form_token', $tokenGenerator->generateToken()); + } + + return $this->redirectToRoute( + route: 'app_account_signup', + parameters: ['signupAntispamToken' => $signupAntispamToken], + status: Response::HTTP_SEE_OTHER + ); + } + + #[Route( + path: '/{_locale}/account/signup/t/{signupAntispamToken}', + name: 'app_account_signup', + methods: ['GET', 'POST'] + )] + public function signup( + string $signupAntispamToken, + Request $request, + FormFactoryInterface $formFactory, + UserRepository $userRepository, + UserPasswordHasherInterface $userPasswordHasher, + TranslatorInterface $translator, + AuthorizationCheckerInterface $authChecker, + ): Response|RedirectResponse { + if (true === $authChecker->isGranted('ROLE_USER')) { + return $this->redirectToRoute(route: 'app_user_account', status: Response::HTTP_SEE_OTHER); + } + + // Check session signup_*_token + $session = $request->getSession(); + $validTokenInbUrl = true; + if ($session->has('signup_url_token') === false) { + $validTokenInbUrl = false; + } elseif ($session->get('signup_url_token') !== $signupAntispamToken) { + $validTokenInbUrl = false; + $session->remove('signup_url_token'); + $session->remove('signup_form_token'); + } + + // If signup_url_token is not valid + if ($validTokenInbUrl === false) { + return $this->redirectToRoute(route: 'app_account_signup_init', status: Response::HTTP_SEE_OTHER); + } + + // Generate form + $signupFormToken = $session->get('signup_form_token'); + $minPasswordLength = $this->getParameter('app.user_config.min_password_length'); + $form = $formFactory->createNamed( + name: "signup_$signupFormToken", + type: SignupFormType::class, + options: ['min_password_length' => $minPasswordLength] + ); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $userEmail = $form->get('login')->getData(); + + // Check if the user already exists in the database + $user = $userRepository->findOneByEmail($form->get('login')->getData()); + if ($user instanceof User) { // TODO improve this case + $session->remove('signup_url_token'); // Cleanup session data + $session->remove('signup_form_token'); // Cleanup session data + $errorMsgId = 'signup.error.account_already_exists'; + $this->addFlash('danger', $translator->trans("$errorMsgId")); + return $this->redirectToRoute(route: 'app_account_login', status: Response::HTTP_SEE_OTHER); + } + + // Add new user + $now = new \DateTimeImmutable(); + $user = new User(); + $user->setUpdatedAt($now); + $user->setCreatedAt($now); + $user->setLastPassowrdUpdate($now); + $user->setEmail($userEmail); + $user->setPassword($userPasswordHasher->hashPassword($user, $form->get('plainPassword')->getData())); + $userRepository->save($user, true); // TODO add new User property [ enabled account ] + + // Cleanup session data + $session->remove('signup_url_token'); + $session->remove('signup_form_token'); + + // Redirect user and display success message + $this->addFlash('success', $translator->trans('signup.success')); + return $this->redirectToRoute('app_account_login', [], Response::HTTP_SEE_OTHER); + } + + // Display registration form (first display or user errors in form) + return $this->render('user/signup.html.twig', ['signupForm' => $form->createView(),]); + } +} diff --git a/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php b/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0487f13f8a78290dc14c3f2c108c620b0e92b407 --- /dev/null +++ b/webapp/tests/Functional/User/FunctionalTestCreateAccountTest.php @@ -0,0 +1,354 @@ +<?php + +/* + * This file is part of the Comptoir-du-Libre software. + * <https://gitlab.adullact.net/Comptoir/comptoir-du-libre> + * + * Copyright (c) ADULLACT <https://adullact.org> + * Association des Développeurs et Utilisateurs de Logiciels Libres + * pour les Administrations et les Collectivités Territoriales + * + * Comptoir-du-Libre is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU Affero General Public License + * along with this software. If not, see <https://www.gnu.org/licenses/agpl-3.0.en.html>. + */ + +declare(strict_types=1); + +namespace App\Tests\Functional\User; + +use App\Repository\UserRepository; +use App\Tests\Functional\TestHelperBreadcrumbTrait; +use App\Tests\Functional\TestHelperFormTrait; +use App\Tests\Functional\TestHelperTrait; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\HttpFoundation\Response; + +/** + * @group gogogo + */ +class FunctionalTestCreateAccountTest extends WebTestCase +{ + use TestHelperTrait; + use TestHelperFormTrait; + use TestHelperBreadcrumbTrait; + + private int $minPasswordLength; + private string $userEmailWhoNeverAddedToDatabase; + + protected function setUp(): void + { + $this->userEmailWhoNeverAddedToDatabase = 'please-do-no-add-to-database@example.org'; + $this->minPasswordLength = (int) $_ENV['WEBAPP_USER_CONFIG_MIN_PASSWORD_LENGTH']; + } + + + /** + * Check user account haas not been created + */ + private function checkUserAccountHasNotBeenCreated( + string $userEmail, + ): void { + $userRepository = static::getContainer()->get(UserRepository::class); + $this->assertNull($userRepository->findOneByEmail($userEmail)); + $this->assertNull($this->getMailerEvent(0)); // check no email was send to end user + } + + /** + * Get valid data for SignUp form + * + * @return string[] + */ + private function getValidDataForSignupForm( + string $htmlFormName, + string $userEmail, + ): array { + $email = $userEmail; + $password = '0123456789_ABCD'; + return [ + $htmlFormName . '[email]' => "$email", + $htmlFormName . '[plainPassword][first]' => "$password", + $htmlFormName . '[plainPassword][second]' => "$password", + ]; + } + + /** + * Send SignUp form request, with wrong data + */ + private function sendRequestSignupFormWithWrongData( + array $formDataWithWrongData, + KernelBrowser $kernelBrowser + ): Crawler { + $crawler = $kernelBrowser->submitForm( + button: 'public_signupForm_submit', + fieldValues: $formDataWithWrongData + ); + $this->assertRouteSame("app_account_signup"); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); // HTTP status code = 200 + $this->assertSelectorTextSame('h1', 'Create your account'); +// $this->assertSelectorTextSame('div.alert-danger', 'The form contains some errors, as described below.'); + return $crawler; + } + + /** + * Load an empty SignUp form + * and if necessary test all form fields + */ + private function loadEmptySignUpForm( + bool $enableAssertions = false, + ): KernelBrowser { + $locale = 'en'; + $kernelBrowser = static::createClient(); + $crawler = $kernelBrowser->request('GET', "/$locale/account/signup"); + $session = $kernelBrowser->getRequest()->getSession(); + $sessionSignupUrlToken = $session->get('signup_url_token'); + $sessionSignupFormToken = $session->get('signup_form_token'); + $realFormUrl = "/$locale/account/signup/t/$sessionSignupUrlToken"; + $htmlFormName = "signup_$sessionSignupFormToken"; + if ($enableAssertions === true) { + $this->assertRouteSame("app_account_signup_init"); + $this->assertNotNull($sessionSignupUrlToken); + $this->assertNotNull($sessionSignupFormToken); + $this->assertResponseStatusCodeSame( + expectedCode: Response::HTTP_SEE_OTHER, + message: 'Bad HTTP code response' + ); + $this->assertResponseHeaderSame( + headerName: "Location", + expectedValue: "$realFormUrl", + message: "Bad redirect location. Tested URL: /$locale/account/signup" + ); + } + + $crawler = $kernelBrowser->request('GET', "$realFormUrl"); + if ($enableAssertions === true) { + $this->assertRouteSame("app_account_signup"); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); // HTTP status code = 200 + $this->assertSelectorTextSame('h1', 'Create your account'); + + // Test all form fields + $this->checkAttribute(crawler: $crawler, cssFilter: "form[name=$htmlFormName]", attributesExpected: []); + $minPasswordLength = $this->minPasswordLength; + $this->checkFormField( + crawler: $crawler, + htmlFormName: $htmlFormName, + fieldIdsuffix: 'email', // see: HONEYPOT_FIELD_NAME + fieldAttributes: [ + 'type' => 'email', + 'tabindex' => '-1', + 'aria-hidden' => 'true', + 'class' => 'visually-hidden form-control', + ], + notAllowedFieldAttributes: ['required',], + labelText: 'Email address', + ); + $this->checkFormField( + crawler: $crawler, + htmlFormName: $htmlFormName, + fieldIdsuffix: 'login', // see: EMAIL_FIELD_NAME + fieldAttributes: [ + 'type' => 'email', + 'required' => 'required' + ], + labelText: 'Email address', + helpText: 'Enter your email address, and we will send you a link to activate your account.' + ); + $this->checkFormField( + crawler: $crawler, + htmlFormName: $htmlFormName, + fieldIdsuffix: 'plainPassword_first', + fieldAttributes: [ + 'type' => 'password', + 'required' => 'required', + 'autocomplete' => 'new-password', + 'minlength' => "$minPasswordLength", + 'maxlength' => "4096", + ], + labelText: 'Your password', + helpText: "Please enter your password. It should be at least $minPasswordLength characters." + ); + $this->checkFormField( + crawler: $crawler, + htmlFormName: $htmlFormName, + fieldIdsuffix: 'plainPassword_second', + fieldAttributes: [ + 'type' => 'password', + 'required' => 'required', + 'autocomplete' => 'new-password', + 'minlength' => "$minPasswordLength", + 'maxlength' => "4096", + ], + labelText: 'Repeat password', + helpText: 'Please repeat your password.' + ); + $this->checkAttribute( + crawler: $crawler, + cssFilter: "button#public_signupForm_submit", + attributesExpected: ['_text' => 'Create your account'], + ); + + // HTML content checks breadcrumb + $breadcrumb = [ '/en/account/signup' => 'Sign up',]; + $this->checkHasValidBreadcrumb($crawler, $breadcrumb, "en"); + } + return $kernelBrowser; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + */ + public function testAnonymousUserIsRedirectedToSignUpPageWithSessionTokenInUrl(): void + { + $locale = 'en'; + $kernelBrowser = static::createClient(); + $crawler = $kernelBrowser->request('GET', "/$locale/account/signup"); + $session = $kernelBrowser->getRequest()->getSession(); + $sessionSignupUrlToken = $session->get('signup_url_token'); + $sessionSignupFormToken = $session->get('signup_form_token'); + $this->assertNotNull($sessionSignupUrlToken); + $this->assertNotNull($sessionSignupFormToken); + $this->assertResponseStatusCodeSame(expectedCode: Response::HTTP_SEE_OTHER, message: 'Bad HTTP code response'); + $this->assertResponseHeaderSame( + headerName: "Location", + expectedValue: "/$locale/account/signup/t/$sessionSignupUrlToken", + message: "Bad redirect location. Tested URL: /$locale/account/signup" + ); + } + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + * @group NoConnectedUser + * @group NoConnectedUser_SignUp + */ + public function testAnonymousUserCanDisplaySignUpForm(): void + { + $this->loadEmptySignUpForm(enableAssertions: true); + } + + ///////////////////////////////////////////////////////////////////////// + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + * @group NoConnectedUser + * @group NoConnectedUser_SignUp + * @group NoConnectedUser_SignUp_With_Wrong_Data + */ + public function testAnonymousUserCanNotSendSignUpFormWithoutEmailField(): void + { + $userEmail = $this->userEmailWhoNeverAddedToDatabase; + $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false); + $session = $kernelBrowser->getRequest()->getSession(); + $htmlFormName = "signup_" . $session->get('signup_form_token'); + $this->commonCheckerWhenFormIsSentWithMissingOrEmptyField( + kernelBrowser: $kernelBrowser, + validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail), + formName: "$htmlFormName", + fieldName: 'login', // see: EMAIL_FIELD_NAME + invalidFeedback: 'This field is mandatory', + methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData' + ); + $this->checkUserAccountHasNotBeenCreated($userEmail); + } + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + * @group NoConnectedUser + * @group NoConnectedUser_SignUp + * @group NoConnectedUser_SignUp_With_Wrong_Data + */ + public function testAnonymousUserCanNotSendSignUpFormWithInvalidFormatEmail(): void + { + $userEmail = $this->userEmailWhoNeverAddedToDatabase; + $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false); + $session = $kernelBrowser->getRequest()->getSession(); + $htmlFormName = "signup_" . $session->get('signup_form_token'); + $validFormData = $this->getValidDataForSignupForm($htmlFormName, $userEmail); + $fieldName = 'login'; // see: EMAIL_FIELD_NAME + foreach ($this->getListOfEmailsInInvalidFormat() as $notValidEmail) { + $formDataWithWrongData = $validFormData; + $formDataWithWrongData["${htmlFormName}[${fieldName}]"] = $notValidEmail; + $crawler = $this->sendRequestSignupFormWithWrongData( + formDataWithWrongData: $formDataWithWrongData, + kernelBrowser: $kernelBrowser + ); + $this->commonCheckerIfFormFieldIsInvalid( + crawler: $crawler, + cssFilterOfInvadFormField: "#${htmlFormName}_${fieldName}", + cssFilterOfInvalidFeedback: '.invalid-feedback', + invalidFeedback: 'Please enter a valid email address.', + ); + $this->checkUserAccountHasNotBeenCreated($userEmail); + } + } + + ///////////////////////////////////////////////////////////////////////// + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + * @group NoConnectedUser + * @group NoConnectedUser_SignUp + * @group NoConnectedUser_SignUp_With_Wrong_Data + */ + public function testAnonymousUserCanNotSendSignUpFormWithoutSimilarPasswordFields(): void + { + $userEmail = $this->userEmailWhoNeverAddedToDatabase; + $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false); + $session = $kernelBrowser->getRequest()->getSession(); + $htmlFormName = "signup_" . $session->get('signup_form_token'); + $this->commonCheckWhenFormIsSentWithoutSimilarNewPasswordFields( + kernelBrowser: $kernelBrowser, + validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail), + formName: "$htmlFormName", + fieldName: 'plainPassword', + minPasswordLength: $this->minPasswordLength, + methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData', + invalidFeedback: 'The password fields must match.' + ); + $this->checkUserAccountHasNotBeenCreated($userEmail); + } + + /** + * @group Form + * @group CreateAccount + * @group CreateAccount_Form + * @group NoConnectedUser + * @group NoConnectedUser_SignUp + * @group NoConnectedUser_SignUp_With_Wrong_Data + */ + public function testAnonymousUserCanNotSendSignUpFormWithTooSmallPassword(): void + { + $userEmail = $this->userEmailWhoNeverAddedToDatabase; + $kernelBrowser = $this->loadEmptySignUpForm(enableAssertions: false); + $session = $kernelBrowser->getRequest()->getSession(); + $htmlFormName = "signup_" . $session->get('signup_form_token'); + $this->commonCheckWhenFormIsSentWithTooSmallRepeatPassword( + kernelBrowser: $kernelBrowser, + validFormData: $this->getValidDataForSignupForm($htmlFormName, $userEmail), + formName: "$htmlFormName", + fieldName: 'plainPassword', + minPasswordLength: $this->minPasswordLength, + methodNameToSendFormWithWrongData: 'sendRequestSignupFormWithWrongData', + ); + $this->checkUserAccountHasNotBeenCreated($userEmail); + } + //////////////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////////////// +}