Add sign up form

master
Julien Rosset 1 year ago
parent 73ccec5e08
commit 544624172d

@ -38,4 +38,6 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
# MAILER_DSN=null://null
MAILER_EMAIL=jul.rosset@gmail.com
MAILER_NAME=WebEDM Mail Bot
###< symfony/mailer ###

@ -4,47 +4,48 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
"doctrine/doctrine-bundle": "^2.9",
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
"doctrine/doctrine-bundle": "^2.9",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.15",
"doctrine/orm": "^2.15",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.20",
"stof/doctrine-extensions-bundle": "^1.7",
"symfony/asset": "6.4.*",
"symfony/asset-mapper": "6.4.*",
"symfony/console": "6.4.*",
"symfony/doctrine-messenger": "6.4.*",
"symfony/dotenv": "6.4.*",
"phpstan/phpdoc-parser": "^1.20",
"stof/doctrine-extensions-bundle": "^1.7",
"symfony/asset": "6.4.*",
"symfony/asset-mapper": "6.4.*",
"symfony/console": "6.4.*",
"symfony/doctrine-messenger": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*",
"symfony/flex": "^2",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.4.*",
"symfony/process": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/yaml": "6.4.*",
"symfonycasts/sass-bundle": "^0.6.0",
"twbs/bootstrap": "^5.3",
"twig/extra-bundle": "^3.0",
"twig/twig": "^3.0"
"symfony/flex": "^2",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.4.*",
"symfony/process": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/yaml": "6.4.*",
"symfonycasts/sass-bundle": "^0.6.0",
"symfonycasts/verify-email-bundle": "^1.17",
"twbs/bootstrap": "^5.3",
"twig/extra-bundle": "^3.0",
"twig/twig": "^3.0"
},
"config": {
"allow-plugins": {

@ -1,16 +1,17 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
];

@ -8,7 +8,7 @@ security:
app_user_provider:
entity:
class: App\Entity\User
property: username
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@ -16,6 +16,7 @@ security:
main:
lazy: true
provider: app_user_provider
user_checker: App\Security\UserChecker
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall

@ -4,6 +4,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
mailer.email: '%env(MAILER_EMAIL)%)'
mailer.name: '%env(MAILER_NAME)%)'
services:
# default configuration for services in *this* file

@ -10,6 +10,11 @@ use Symfony\Component\Routing\Attribute\Route;
* Controller for core page : home, etc.
*/
class CoreController extends AbstractController {
/**
* Home page
*
* @return Response The response
*/
#[Route('/', name: 'core_main')]
public function main (): Response {
return $this->render('core/main.html.twig');

@ -0,0 +1,123 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\SignUpFormType;
use App\Repository\UserRepository;
use App\Security\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
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\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
class UserController extends AbstractController {
/**
* @var EmailVerifier The email verifier service
*/
private readonly EmailVerifier $emailVerifier;
/**
* Initialisation
*
* @param EmailVerifier $emailVerifier The email verifier service
*/
public function __construct (EmailVerifier $emailVerifier) {
$this->emailVerifier = $emailVerifier;
}
/**
* Register a new user
*
* @param Request $request The query
* @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 {
$user = new User();
$form = $this->createForm(SignUpFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
$entityManager->persist($user);
$entityManager->flush();
// Generate the mail with the link to verify the account
$this->emailVerifier->sendEmailConfirmation(
'user_mailVerify',
$user,
(new TemplatedEmail())
->from(new Address(
$this->getParameter('mailer.email'),
$this->getParameter('mailer.name')
)
)
->to($user->getEmail())
->subject('Please Confirm your Email')
->htmlTemplate('user/confirmation_email.html.twig')
);
$this->addFlash('info', 'Please validate your account through the confirmation mail');
return $this->redirectToRoute('core_main');
}
return $this->render('user/signUp.html.twig', [
'registrationForm' => $form,
]);
}
/**
* User email verification
*
* @param Request $request The request
* @param TranslatorInterface $translator The translation service
* @param UserRepository $userRepository The user repository
*
* @return Response
*/
#[Route('/emailVerify', name: 'user_mailVerify')]
public function verifyUserEmail (Request $request, TranslatorInterface $translator, UserRepository $userRepository): Response {
$id = $request->query->get('id');
if ($id === null) {
return $this->redirectToRoute('user_signUp');
}
$user = $userRepository->find($id);
if ($user === null) {
return $this->redirectToRoute('user_signUp');
}
try {
$this->emailVerifier->handleEmailConfirmation($request, $user);
}
catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('verify_email_error', $translator->trans($exception->getReason(), [], 'VerifyEmailBundle'));
return $this->redirectToRoute('user_signUp');
}
$this->addFlash('success', 'Your email address has been verified, now please wait for an administrator confirmation');
return $this->redirectToRoute('core_main');
}
}

@ -6,7 +6,9 @@ use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
@ -15,6 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* A registered user
*/
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface {
use TEntityBase;
@ -63,6 +66,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface {
#[ORM\Column(nullable: true)]
private ?DateTimeImmutable $validationDate = null;
/**
* @var bool Is the email verified ?
*/
#[ORM\Column(type: Types::BOOLEAN)]
private bool $isVerified = false;
/**
* Initialization
*/
@ -257,4 +266,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface {
$this->validationDate = new DateTimeImmutable();
return $this;
}
/**
* Is the email verified ?
*
* @return bool Is the email verified ?
*/
public function isVerified (): bool {
return $this->isVerified;
}
/**
* Set if the email is verified
*
* @param bool $isVerified Is the email verified ?
*
* @return $this
*/
public function setVerified (bool $isVerified): static {
$this->isVerified = $isVerified;
return $this;
}
}

@ -0,0 +1,68 @@
<?php
namespace App\Form;
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\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')
->add('agreeTerms', CheckboxType::class, [
'mapped' => false,
'constraints' => [
new IsTrue(
[
'message' => 'You should agree to our terms.',
]
),
],
])
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank(
[
'message' => 'Please enter a password',
]
),
new Length(
[
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]
),
],
]);
}
}

@ -0,0 +1,101 @@
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
/**
* The email verifier service
*/
class EmailVerifier {
/**
* @var VerifyEmailHelperInterface The email verifier service (from "VerifyEmail" bundle)
*/
private readonly VerifyEmailHelperInterface $verifyEmailHelper;
/**
* @var MailerInterface The mailing service
*/
private readonly MailerInterface $mailer;
/**
* @var EntityManagerInterface The entity manager
*/
private readonly EntityManagerInterface $entityManager;
/**
* Initialisation
*
* @param VerifyEmailHelperInterface $verifyEmailHelper The email verifier service (from "VerifyEmail" bundle)
* @param MailerInterface $mailer The mailing service
* @param EntityManagerInterface $entityManager The entity manager
*/
public function __construct (
VerifyEmailHelperInterface $verifyEmailHelper,
MailerInterface $mailer,
EntityManagerInterface $entityManager
) {
$this->verifyEmailHelper = $verifyEmailHelper;
$this->mailer = $mailer;
$this->entityManager = $entityManager;
}
/**
* Send the confirmation email
*
* @param string $verifyEmailRouteName The route managing the email verification
* @param User $user The user to confirm email
* @param TemplatedEmail $email The email itself
*
* @return void
*
* @throws TransportExceptionInterface
*/
public function sendEmailConfirmation (string $verifyEmailRouteName, User $user, TemplatedEmail $email): void {
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
$user->getId(),
$user->getEmail(),
[
'id' => $user->getId(),
]
);
$context = $email->getContext();
$context['signedUrl'] = $signatureComponents->getSignedUrl();
$context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
$context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
$email->context($context);
$this->mailer->send($email);
}
/**
* Handle the email confirmation
*
* @param Request $request The request
* @param User $user The user
*
* @return void
*
* @throws VerifyEmailExceptionInterface If the confirmation link is invalid
*
* @noinspection PhpDocRedundantThrowsInspection
*/
public function handleEmailConfirmation (Request $request, User $user): void {
$this->verifyEmailHelper->validateEmailConfirmationFromRequest(
$request,
$user->getId(),
$user->getEmail()
);
$user->setVerified(true);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

@ -0,0 +1,31 @@
<?php
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Check if a user is valid for authentification
*/
class UserChecker implements UserCheckerInterface {
/**
* @inheritDoc
*/
public function checkPreAuth (UserInterface $user): void {
if (!$user instanceof User) {
return;
}
if ($user->getValidationAdministrator() === null || $user->getValidationDate() === null) {
throw new CustomUserMessageAccountStatusException('Your account has not been validated by an administrator yet.');
}
}
/**
* @inheritDoc
*/
public function checkPostAuth (UserInterface $user): void {
}
}

@ -294,6 +294,9 @@
"symfonycasts/sass-bundle": {
"version": "v0.6.0"
},
"symfonycasts/verify-email-bundle": {
"version": "v1.17.0"
},
"twig/extra-bundle": {
"version": "v3.6.0"
}

@ -0,0 +1,11 @@
<h1>Hi! Please confirm your email!</h1>
<p>
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl|raw }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
</p>
<p>
Cheers!
</p>

@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block title %}Sign Up{% endblock %}
{% block mainContent %}
<h1>Sign up</h1>
<p>
NOTE : after confirming your account email, it must be also validated by an administrator
<br>You'll receive an email when your account would be accepted
</p>
{{ form_errors(registrationForm) }}
{{ form_start(registrationForm) }}
{{ form_row(registrationForm.email) }}
{{ form_row(registrationForm.plainPassword, {
label: 'Password'
}) }}
{{ form_row(registrationForm.agreeTerms) }}
<button type="submit" class="btn">Request account</button>
{{ form_end(registrationForm) }}
{% endblock %}
Loading…
Cancel
Save