diff --git a/assets/controllers.json b/assets/controllers.json new file mode 100644 index 0000000..bde96a5 --- /dev/null +++ b/assets/controllers.json @@ -0,0 +1,4 @@ +{ + "controllers": {}, + "entrypoints": [] +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js new file mode 100644 index 0000000..2811f21 --- /dev/null +++ b/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,79 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_\/+a-zA-Z0-9]{24,}$/; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const h = generateCsrfHeaders(event.detail.formSubmission.formElement); + Object.keys(h).map(function (k) { + event.detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + removeCsrfToken(event.detail.formSubmission.formElement); +}); + +export function generateCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); + csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +export function generateCsrfHeaders (formElement) { + const headers = {}; + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + headers[csrfCookie] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js new file mode 100644 index 0000000..e847027 --- /dev/null +++ b/assets/controllers/hello_controller.js @@ -0,0 +1,16 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * This is an example Stimulus controller! + * + * Any element with a data-controller="hello" attribute will cause + * this controller to be executed. The name "hello" comes from the filename: + * hello_controller.js -> "hello" + * + * Delete this file or adapt it for your use! + */ +export default class extends Controller { + connect() { + this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; + } +} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 05cf4c0..2c559aa 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -27,7 +27,7 @@ security: lifetime: 604800 # 1 week, in seconds secure: true samesite: strict - signature_properties: [ 'password', 'validationAdministrator', 'validationDate' ] + signature_properties: [ 'password' ] logout: path: user_signOut # where to redirect after logout diff --git a/migrations/Version20250525104942.php b/migrations/Version20250525104942.php new file mode 100644 index 0000000..aff2393 --- /dev/null +++ b/migrations/Version20250525104942.php @@ -0,0 +1,35 @@ +addSql(<<<'SQL' + CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(100) NOT NULL, password VARCHAR(255) NOT NULL, name VARCHAR(100) DEFAULT NULL, roles JSON NOT NULL COMMENT '(DC2Type:json)', UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + DROP TABLE user + SQL); + } +} diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..baa328c --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,130 @@ +translator = $translator; + $this->connectedUserService = $connectedUserService; + } + + /** + * Register a new user + * + * @param Request $request The request + * @param UserPasswordHasherInterface $userPasswordHasher The password hashing service + * @param EntityManagerInterface $entityManager The entity manager + * + * @return Response The response + * + * @throws TransportExceptionInterface + */ + #[Route('/signUp', name: 'user_signUp')] + public function signUp (Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response { + if (($response = $this->connectedUserService->checkNotConnected())) { + return $response; + } + + $user = new User(); + + $form = $this->createForm(SignUpFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + //region Encode the plain password + $user->setPassword( + $userPasswordHasher->hashPassword( + $user, + $form->get('newPassword')->getData() + ) + ); + //endregion + + $entityManager->persist($user); + $entityManager->flush(); + + $this->addFlash(FlashType::SUCCESS, 'Votre compte a bien été créé.');; + return $this->redirectToRoute('user_signIn'); + } + + return $this->render('User/SignUp.html.twig', [ + 'signUpForm' => $form->createView(), + ]); + } + + /** + * Sign in a user + * + * @param AuthenticationUtils $authenticationUtils Security errors from query + * + * @return Response The response + */ + #[Route(path: '/signIn', name: 'user_signIn')] + public function login (AuthenticationUtils $authenticationUtils): Response { + if (($response = $this->connectedUserService->checkNotConnected())) { + return $response; + } + + if (($error = $authenticationUtils->getLastAuthenticationError()) !== null) { + $this->addFlash(FlashType::ERROR, $this->translator->trans($error->getMessageKey(), $error->getMessageData(), 'security')); + } + return $this->render( + 'User/SignIn.html.twig', + [ + 'last_username' => $authenticationUtils->getLastUsername(), + ] + ); + } + + /** + * Sign out + * + * NOTE : dummy controller, intercepted by firewall + * + * @return void + */ + #[Route(path: '/signOut', name: 'user_signOut')] + public function logout (): void { + throw new LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Form/User/SignUpFormType.php b/src/Form/User/SignUpFormType.php new file mode 100644 index 0000000..0887727 --- /dev/null +++ b/src/Form/User/SignUpFormType.php @@ -0,0 +1,84 @@ +setDefaults( + [ + 'data_class' => User::class, + ] + ); + } + + /** + * @inheritDoc + */ + public function buildForm (FormBuilderInterface $builder, array $options): void { + $builder + ->add('email', null, [ + 'label' => 'Email', + ]) + ->add('name', null, [ + 'label' => 'Nom', + ]) + ->add('newPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'attr' => ['autocomplete' => 'new-password'], + 'invalid_message' => 'Les mots de passe doivent correspondre.', + 'constraints' => [ + new NotBlank( + [ + 'message' => 'Le mot de passe est requis.', + ] + ), + new Length( + [ + 'min' => 6, + 'minMessage' => 'Le mot de passe doit faire au moins {{ limit }} caractères.', + 'max' => 4096, // max length allowed by Symfony for security reasons + ] + ), + ], + 'first_options' => [ + 'label' => 'Mot de passe', + ], + 'second_options' => [ + 'label' => 'Confirmation du mot de passe', + ], + ]) + ->add('agreeTerms', CheckboxType::class, [ + 'mapped' => false, + 'label' => 'J\'accepte les CGU', + 'constraints' => [ + new IsTrue( + [ + 'message' => 'Vous devez accepter les CGU.', + ] + ), + ], + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'S\'enregistrer', + ]); + } +} diff --git a/src/Service/LoggerService.php b/src/Service/LoggerService.php new file mode 100644 index 0000000..3406372 --- /dev/null +++ b/src/Service/LoggerService.php @@ -0,0 +1,55 @@ +loggerBase = $loggerBase; + } + + /** + * @inheritDoc + */ + public function log ($level, string|Stringable $message, array $context = []): void { + $this->loggerBase->log($level, $message, $context); + } + + /** + * Log an exception + * + * @param Throwable $exception The exception + * @param array $context The context (automatically add the exception in "throwable") + * @param string $level The log level + * + * @return void + */ + public function exception (Throwable $exception, array $context = [], string $level = LogLevel::ERROR): void { + $context['throwable'] = $exception; + $this->log( + $level, + 'Exception [' . get_class($exception) . ']: ' . $exception->getMessage(), + $context + ); + } +} \ No newline at end of file diff --git a/templates/Core/Main.html.twig b/templates/Core/Main.html.twig index 9df3a11..0a7fa4e 100644 --- a/templates/Core/Main.html.twig +++ b/templates/Core/Main.html.twig @@ -8,6 +8,6 @@ Bienvenu {{ app.user }} {% else %}

Bienvenu sur Recipe Manager, le gestionnaire de recette de jeux vidéos.

-{#

Merci de vous connecter ou créer un compte pour commencer.

#} +

Merci de vous connecter ou créer un compte pour commencer.

{% endif %} {% endblock %} diff --git a/templates/User/SignIn.html.twig b/templates/User/SignIn.html.twig new file mode 100644 index 0000000..ffad665 --- /dev/null +++ b/templates/User/SignIn.html.twig @@ -0,0 +1,33 @@ +{% extends 'base.html.twig' %} + +{% block title %}Connexion - {{ parent() }}{% endblock %} + +{% block mainContent %} +

Connexion

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+ + +
+
+
+ + + +
+{% endblock %} diff --git a/templates/User/SignUp.html.twig b/templates/User/SignUp.html.twig new file mode 100644 index 0000000..eef4d2b --- /dev/null +++ b/templates/User/SignUp.html.twig @@ -0,0 +1,8 @@ +{% extends 'base.html.twig' %} + +{% block title %}Création compte - {{ parent() }}{% endblock %} + +{% block mainContent %} +

Création compte

+ {{ form(signUpForm) }} +{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 3d6c311..70db251 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -19,14 +19,14 @@ -