Gestion utilisateur : création et (dé)connexion
parent
795c89b9e6
commit
851bb45f70
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"controllers": {},
|
||||||
|
"entrypoints": []
|
||||||
|
}
|
@ -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';
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250525104942 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Form\User\SignUpFormType;
|
||||||
|
use App\Misc\FlashType;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Service\ConnectedUserService;
|
||||||
|
use App\Service\LoggerService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use LogicException;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controllers for user pages
|
||||||
|
*/
|
||||||
|
#[Route('/user')]
|
||||||
|
class UserController extends AbstractController {
|
||||||
|
/**
|
||||||
|
* @var TranslatorInterface The translator service
|
||||||
|
*/
|
||||||
|
private readonly TranslatorInterface $translator;
|
||||||
|
/**
|
||||||
|
* @var ConnectedUserService The connected user service
|
||||||
|
*/
|
||||||
|
private readonly ConnectedUserService $connectedUserService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisation
|
||||||
|
*
|
||||||
|
* @param TranslatorInterface $translator The translator service
|
||||||
|
* @param ConnectedUserService $connectedUserService The connected user service
|
||||||
|
*/
|
||||||
|
public function __construct (TranslatorInterface $translator, ConnectedUserService $connectedUserService) {
|
||||||
|
$this->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
|
||||||
|
*
|
||||||
|
* <b>NOTE :</b> 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.');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\User;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form for user registration (sign up)
|
||||||
|
*/
|
||||||
|
class SignUpFormType extends AbstractType {
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function configureOptions (OptionsResolver $resolver): void {
|
||||||
|
$resolver->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\LoggerTrait;
|
||||||
|
use Psr\Log\LogLevel;
|
||||||
|
use Stringable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for logging
|
||||||
|
*/
|
||||||
|
class LoggerService implements LoggerInterface {
|
||||||
|
use LoggerTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var LoggerInterface The logger service base
|
||||||
|
*/
|
||||||
|
public readonly LoggerInterface $loggerBase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialization
|
||||||
|
*
|
||||||
|
* @param LoggerInterface $loggerBase The logger service base
|
||||||
|
*/
|
||||||
|
public function __construct (LoggerInterface $loggerBase) {
|
||||||
|
$this->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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Connexion - {{ parent() }}{% endblock %}
|
||||||
|
|
||||||
|
{% block mainContent %}
|
||||||
|
<h1>Connexion</h1>
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="email" class="col-form-label col-sm-2">Email</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="email" value="{{ last_username }}" name="_username" id="email" class="form-control" autocomplete="email" required autofocus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="password" class="col-form-label col-sm-2">Mot de passe</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="password" name="_password" id="password" class="form-control" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<div class="col-sm-2"></div>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="_remember_me" id="remember_me" class="form-check-input">
|
||||||
|
<label for="remember_me" class="form-check-label">Se souvenir de moi</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||||
|
<button class="btn btn-lg btn-primary" type="submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Création compte - {{ parent() }}{% endblock %}
|
||||||
|
|
||||||
|
{% block mainContent %}
|
||||||
|
<h1>Création compte</h1>
|
||||||
|
{{ form(signUpForm) }}
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue