Gestion utilisateur : création et (dé)connexion

master
Julien Rosset 2 months ago
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';
}
}

@ -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

@ -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
);
}
}

@ -8,6 +8,6 @@
Bienvenu {{ app.user }}
{% else %}
<p>Bienvenu sur Recipe Manager, le gestionnaire de recette de jeux vidéos.</p>
{# <p>Merci de vous <a href="{{ path('user_signIn') }}">connecter</a> ou <a href="{{ path('user_signOut') }}">créer un compte</a> pour commencer.</p>#}
<p>Merci de vous <a href="{{ path('user_signIn') }}">connecter</a> ou <a href="{{ path('user_signOut') }}">créer un compte</a> pour commencer.</p>
{% endif %}
{% endblock %}

@ -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 %}

@ -19,14 +19,14 @@
<a class="nav-link dropdown-toggle py-0" id="dropdown-locale" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ app.user }}
</a>
<ul class="dropdown-menu" aria-labelledby="dropdown-locale">
<li><a href="{{ path('user_signOut') }}" class="dropdown-item">{{ 'user.signOut'|trans }}</a></li>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdown-locale">
<li><a href="{{ path('user_signOut') }}" class="dropdown-item">Se déconnecter</a></li>
</ul>
</li>
<!--endregion-->
{% else %}
{# <li class="nav-item"><a href="{{ path('user_signIn') }}" class="nav-link py-0">{{ 'user.signIn.title'|trans }}</a></li>#}
{# <li class="nav-item"><a href="{{ path('user_signUp') }}" class="nav-link py-0">{{ 'user.signUp.title'|trans }}</a></li>#}
<li class="nav-item"><a href="{{ path('user_signIn') }}" class="nav-link py-0">Se connecter</a></li>
<li class="nav-item"><a href="{{ path('user_signUp') }}" class="nav-link py-0">S'enregistrer</a></li>
{% endif %}
</ul>
</nav>

Loading…
Cancel
Save