Repository: kbond/starter-kit
Pull request #11: User Registration
Author: login: kbond
Created: 2025-04-15T15:44:22Z | Merged: 2025-05-09T13:51:48Z

Built on #9
Closes #4

- [ ] Rate limit registration?

Linked issue #4: User registration

Diff:
diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml
index cc86410..ffa0893 100644
--- a/config/packages/rate_limiter.yaml
+++ b/config/packages/rate_limiter.yaml
@@ -1,5 +1,11 @@
 framework:
     rate_limiter:
+        user_registration:
+            policy: sliding_window
+            limit: 1
+            interval: 30 minutes
+            lock_factory: null # todo remove in Symfony 7.3
+
         forgot_password_email:
             policy: sliding_window
             limit: 1
diff --git a/src/Controller/User/RegistrationController.php b/src/Controller/User/RegistrationController.php
new file mode 100644
index 0000000..5dd1016
--- /dev/null
+++ b/src/Controller/User/RegistrationController.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+use Symfony\Component\Routing\Attribute\Route;
+
+final class RegistrationController extends AbstractController
+{
+    #[Route('/register', name: 'register')]
+    public function register(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+
+        #[Target('userRegistrationLimiter')]
+        RateLimiterFactory $rateLimiter, // todo switch to RateLimiterInterface in 7.3
+    ) {
+        $user = new User();
+        $form = $this->createForm(RegistrationForm::class, $user);
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            $ip = $request->getClientIp();
+
+            if ($ip && !$rateLimiter->create($ip)->consume()->isAccepted()) {
+                $this->addFlash('error', 'You recently registered. Please try again later.');
+
+                return $this->redirectToRoute('homepage');
+            }
+
+            /** @var string $plainPassword */
+            $plainPassword = $form->get('password')->getData();
+
+            // encode the plain password
+            $user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
+            $em->persist($user);
+            $em->flush();
+            $this->addFlash('success', 'Registration successful! You are now logged in.');
+
+            return $security->login($user, 'form_login');
+        }
+
+        return $this->render('user/register.html.twig', [
+            'form' => $form,
+        ]);
+    }
+}
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 7196b4d..097fdf9 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -4,11 +4,14 @@
 
 use App\Repository\UserRepository;
 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;
 
 #[ORM\Entity(repositoryClass: UserRepository::class)]
 #[ORM\Table(name: 'users')]
+#[UniqueEntity(fields: 'email', message: 'There is already an account with this email')]
 class User implements UserInterface, PasswordAuthenticatedUserInterface
 {
     #[ORM\Id]
@@ -17,9 +20,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
     private ?int $id = null;
 
     #[ORM\Column]
+    #[Assert\NotBlank(message: 'Please enter your name.')]
     private ?string $name = null;
 
     #[ORM\Column(length: 180, unique: true)]
+    #[Assert\NotBlank(message: 'Please enter your email.')]
+    #[Assert\Email(message: 'Please enter a valid email address.')]
     private ?string $email = null;
 
     /**
diff --git a/src/Form/ChangePasswordForm.php b/src/Form/ChangePasswordForm.php
index 845ea71..8470459 100644
--- a/src/Form/ChangePasswordForm.php
+++ b/src/Form/ChangePasswordForm.php
@@ -2,15 +2,12 @@
 
 namespace App\Form;
 
+use App\Validator\Constraints\Password;
 use Symfony\Component\Form\AbstractType;
 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\Validator\Constraints\Length;
-use Symfony\Component\Validator\Constraints\NotBlank;
-use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
-use Symfony\Component\Validator\Constraints\PasswordStrength;
 
 final class ChangePasswordForm extends AbstractType
 {
@@ -25,19 +22,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
                     ],
                 ],
                 'first_options' => [
-                    'constraints' => [
-                        new NotBlank([
-                            'message' => 'Please enter a password',
-                        ]),
-                        new Length([
-                            'min' => 12,
-                            'minMessage' => 'Your password should be at least {{ limit }} characters',
-                            // max length allowed by Symfony for security reasons
-                            'max' => 4096,
-                        ]),
-                        new PasswordStrength(),
-                        new NotCompromisedPassword(),
-                    ],
+                    'constraints' => new Password(),
                     'label' => 'New Password',
                 ],
                 'second_options' => [
diff --git a/src/Form/RegistrationForm.php b/src/Form/RegistrationForm.php
new file mode 100644
index 0000000..736d840
--- /dev/null
+++ b/src/Form/RegistrationForm.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Form;
+
+use App\Entity\User;
+use App\Validator\Constraints\Password;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\PasswordType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+final class RegistrationForm extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options): void
+    {
+        $builder
+            ->add('name')
+            ->add('email')
+            ->add('password', 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 Password(),
+            ])
+            ->add('submit', SubmitType::class)
+        ;
+    }
+
+    public function configureOptions(OptionsResolver $resolver): void
+    {
+        $resolver->setDefaults([
+            'data_class' => User::class,
+        ]);
+    }
+}
diff --git a/src/Validator/Constraints/Password.php b/src/Validator/Constraints/Password.php
new file mode 100644
index 0000000..4108c2b
--- /dev/null
+++ b/src/Validator/Constraints/Password.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\PasswordHasher\PasswordHasherInterface;
+use Symfony\Component\Validator\Constraints\Compound;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
+use Symfony\Component\Validator\Constraints\PasswordStrength;
+
+final class Password extends Compound
+{
+    protected function getConstraints(array $options): array
+    {
+        return [
+            new NotBlank([
+                'message' => 'Please enter a password',
+            ]),
+            new Length([
+                'min' => 12,
+                'minMessage' => 'Your password should be at least {{ limit }} characters',
+                // max length allowed by Symfony for security reasons
+                'max' => PasswordHasherInterface::MAX_PASSWORD_LENGTH,
+            ]),
+            new PasswordStrength(),
+            new NotCompromisedPassword(),
+        ];
+    }
+}
diff --git a/templates/user/register.html.twig b/templates/user/register.html.twig
new file mode 100644
index 0000000..bbf1c55
--- /dev/null
+++ b/templates/user/register.html.twig
@@ -0,0 +1,7 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Register{% endblock %}
+
+{% block body %}
+    {{ form(form) }}
+{% endblock %}
diff --git a/tests/Functional/User/RegistrationTest.php b/tests/Functional/User/RegistrationTest.php
new file mode 100644
index 0000000..fcd37ee
--- /dev/null
+++ b/tests/Functional/User/RegistrationTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Tests\Functional\User;
+
+use App\Factory\UserFactory;
+use App\Tests\FunctionalTestCase;
+
+class RegistrationTest extends FunctionalTestCase
+{
+    public function testRegistration(): void
+    {
+        $this->browser()
+            ->visit('/register')
+            ->fillField('Name', 'Karen Smith')
+            ->fillField('Email', 'ksmith@example.com')
+            ->fillField('Password', 'super s3cure password')
+            ->click('Submit')
+            ->assertOn('/')
+            ->assertSee('Registration successful! You are now logged in.')
+            ->assertAuthenticated(as: 'ksmith@example.com')
+            ->visit('/logout')
+            ->assertNotAuthenticated()
+            ->visit('/login')
+            ->fillField('Email', 'ksmith@example.com')
+            ->fillField('Password', 'super s3cure password')
+            ->click('Sign in')
+            ->assertOn('/')
+            ->assertAuthenticated(as: 'ksmith@example.com')
+        ;
+
+        UserFactory::assert()->exists([
+            'name' => 'Karen Smith',
+            'email' => 'ksmith@example.com',
+        ]);
+    }
+
+    /**
+     * @dataProvider registrationFormValidationProvider
+     */
+    public function testRegistrationFormValidation(string $name, string $email, string $password, string $message): void
+    {
+        $this->browser()
+            ->visit('/register')
+            ->fillField('Name', $name)
+            ->fillField('Email', $email)
+            ->fillField('Password', $password)
+            ->click('Submit')
+            ->assertOn('/register')
+            ->assertSee($message)
+            ->assertNotAuthenticated()
+        ;
+
+        UserFactory::assert()->empty();
+    }
+
+    public static function registrationFormValidationProvider(): iterable
+    {
+        yield 'blank name' => [
+            'name' => '',
+            'email' => 'kevin@example.com',
+            'password' => 'super s3cure password',
+            'message' => 'Please enter your name.',
+        ];
+        yield 'blank email' => [
+            'name' => 'Kevin',
+            'email' => '',
+            'password' => 'super s3cure password',
+            'message' => 'Please enter your email',
+        ];
+        yield 'invalid email' => [
+            'name' => 'Kevin',
+            'email' => 'invalid',
+            'password' => 'super s3cure password',
+            'message' => 'Please enter a valid email address',
+        ];
+        yield 'blank password' => [
+            'name' => 'Kevin',
+            'email' => 'kevin@example.com',
+            'password' => '',
+            'message' => 'Please enter a password',
+        ];
+        yield 'weak password' => [
+            'name' => 'Kevin',
+            'email' => 'kevin@example.com',
+            'password' => '1234',
+            'message' => 'The password strength is too low. Please use a stronger password',
+        ];
+    }
+}


Reviews:
user: login: weaverryan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:16:42Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T18:30:13Z
user: login: weaverryan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:56:08Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T20:15:07Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T20:18:37Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T20:19:50Z
user: login: kbond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T20:28:37Z

Review comments:
diff_hunk: @@ -0,0 +1,44 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/register', name: 'register')]
+final class RegistrationController extends AbstractController
+{
+    public function __invoke(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+    ) {
path: src/Controller/User/RegistrationController.php
user: login: weaverryan
body: `__invoke()` works well here as there is only 1 action. But if we have multiple actions, I'd prefer 1 class with multiple methods. This creates inconsistency. I'm not convinced of the benefit to the user. Message handlers, sure, commands, yes. Controllers? It feels like we're using `__invoke()` to show off technically. It also won't match most docs
created_at: 2025-05-08T18:04:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/register', name: 'register')]
+final class RegistrationController extends AbstractController
+{
+    public function __invoke(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+    ) {
+        $user = new User();
+        $form = $this->createForm(RegistrationForm::class, $user);
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            /** @var string $plainPassword */
+            $plainPassword = $form->get('password')->getData();
+
+            // encode the plain password
+            $user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
path: src/Controller/User/RegistrationController.php
user: login: weaverryan
body: In our UserBundle, I'd really like to hash automatically. These one-off hashes introduce complexity & feel sloppy 
created_at: 2025-05-08T18:06:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+<?php
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\PasswordHasher\PasswordHasherInterface;
+use Symfony\Component\Validator\Constraints\Compound;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
+use Symfony\Component\Validator\Constraints\PasswordStrength;
+
+final class Password extends Compound
path: src/Validator/Constraints/Password.php
user: login: weaverryan
body: smells like a class for core? Or our UserBundle?

Clever idea...
created_at: 2025-05-08T18:08:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Register{% endblock %}
+
+{% block body %}
+    {{ form(form) }}
path: templates/user/register.html.twig
user: login: weaverryan
body: boo ;)
created_at: 2025-05-08T18:09:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Register{% endblock %}
+
+{% block body %}
+    {{ form(form) }}
path: templates/user/register.html.twig
user: login: weaverryan
body: add an `h1`?

We should probably have one on each page
created_at: 2025-05-08T18:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+<?php
+
+namespace App\Tests\Functional\User;
+
+use App\Factory\UserFactory;
+use App\Tests\FunctionalTestCase;
+
+class RegistrationTest extends FunctionalTestCase
+{
+    public function testRegistration(): void
+    {
+        $this->browser()
+            ->visit('/register')
+            ->fillField('Name', 'Karen Smith')
path: tests/Functional/User/RegistrationTest.php
user: login: weaverryan
body: ```suggestion
            ->fillField('Name', 'Sarah Connor')
```
created_at: 2025-05-08T18:14:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+<?php
+
+namespace App\Tests\Functional\User;
+
+use App\Factory\UserFactory;
+use App\Tests\FunctionalTestCase;
+
+class RegistrationTest extends FunctionalTestCase
+{
+    public function testRegistration(): void
+    {
+        $this->browser()
+            ->visit('/register')
+            ->fillField('Name', 'Karen Smith')
+            ->fillField('Email', 'ksmith@example.com')
+            ->fillField('Password', 'super s3cure password')
path: tests/Functional/User/RegistrationTest.php
user: login: weaverryan
body: WDYT about light silliness?

```suggestion
            ->fillField('Password', 'skynet84')
```
created_at: 2025-05-08T18:15:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/register', name: 'register')]
+final class RegistrationController extends AbstractController
+{
+    public function __invoke(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+    ) {
path: src/Controller/User/RegistrationController.php
user: login: kbond
body: My primary purpose for using them was to make potential future composition of starter-kit "scaffolds" easier.

To clarify, you'd prefer `RegistrationController::register()` here?
created_at: 2025-05-08T18:30:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,44 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/register', name: 'register')]
+final class RegistrationController extends AbstractController
+{
+    public function __invoke(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+    ) {
path: src/Controller/User/RegistrationController.php
user: login: weaverryan
body: yup!
created_at: 2025-05-08T18:56:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Register{% endblock %}
+
+{% block body %}
+    {{ form(form) }}
path: templates/user/register.html.twig
user: login: kbond
body: Yep, probably add in the layout via the title block?
created_at: 2025-05-08T20:15:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+<?php
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\PasswordHasher\PasswordHasherInterface;
+use Symfony\Component\Validator\Constraints\Compound;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
+use Symfony\Component\Validator\Constraints\PasswordStrength;
+
+final class Password extends Compound
path: src/Validator/Constraints/Password.php
user: login: kbond
body: I thought that but decided it might be too opinionated? User bundle would be fine I think though.
created_at: 2025-05-08T20:18:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,30 @@
+<?php
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\PasswordHasher\PasswordHasherInterface;
+use Symfony\Component\Validator\Constraints\Compound;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
+use Symfony\Component\Validator\Constraints\PasswordStrength;
+
+final class Password extends Compound
path: src/Validator/Constraints/Password.php
user: login: kbond
body: It has a lot of potential customization that I wouldn't want to expose as bundle config for such a simple class
created_at: 2025-05-08T20:19:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,44 @@
+<?php
+
+namespace App\Controller\User;
+
+use App\Entity\User;
+use App\Form\RegistrationForm;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[Route('/register', name: 'register')]
+final class RegistrationController extends AbstractController
+{
+    public function __invoke(
+        Request $request,
+        UserPasswordHasherInterface $passwordHasher,
+        EntityManagerInterface $em,
+        Security $security,
+    ) {
+        $user = new User();
+        $form = $this->createForm(RegistrationForm::class, $user);
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            /** @var string $plainPassword */
+            $plainPassword = $form->get('password')->getData();
+
+            // encode the plain password
+            $user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
path: src/Controller/User/RegistrationController.php
user: login: kbond
body: You mean like how fosuser bundle did? Doctrine event listener and eraseCredentials iirc? Fyi eraseCredentials is deprecated as of 7.3
created_at: 2025-05-08T20:28:37Z
author_association: COLLABORATOR

PR comments:
user: login: weaverryan
created_at: 2025-05-08T18:58:10Z
body: > Rate limit registration?

Actually a good idea. It's probably out of scope (?) but captcha?
author_association: CONTRIBUTOR
user: login: weaverryan
created_at: 2025-05-08T19:08:25Z
body: ALTCHA for captcha? looks like you can self-host for free. We might also mention Cloudlare Turnstile
author_association: CONTRIBUTOR
user: login: kbond
created_at: 2025-05-08T20:15:53Z
body: Re captcha: created #24 to discuss further
author_association: COLLABORATOR