Step 4: Configuring the connect (register) layer

April 28, 2024 ยท View on GitHub

A) Create the Connector class

We need to create new class that will implement HWI\Bundle\OAuthBundle\Connect\AccountConnectorInterface interface. That class will be responsible for persisting User entities with given resource owner identifiers. For simple implementation we can define the class as:

services:
    App\Security\OAuthConnector:
        arguments:
            $properties:
                'facebook': 'facebook'
                'google': 'google'

And implement it:

namespace App\Security;

use HWI\Bundle\OAuthBundle\Connect\AccountConnectorInterface;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;

final class OAuthConnector implements AccountConnectorInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly array $properties
    ) {
    }

    public function connect(UserInterface $user, UserResponseInterface $response)
    {
        if (!isset($this->properties[$response->getResourceOwner()->getName()])) {
            return;
        }

        $property = new PropertyAccessor();
        $property->setValue($user, $this->properties[$response->getResourceOwner()->getName()], $response->getUserIdentifier());

        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }
}

B) Create form & form handler

In order to properly display & handle form of user registration, we need to create two classes: form & form handler. The first one will be Symfony Form, let's call it RegistrationFormType:

namespace App\Form;

final class RegistrationFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('firstName')
            ->add('lastName')
            ->add('email')
            ->add('username', TextType::class, ['required' => false])
            ->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 handler
                '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,
                    ]),
                ],
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => User::class,
        ]);
    }
}

Now we need to handle the form data, to do that we need new class that implements HWI\Bundle\OAuthBundle\Form\RegistrationFormHandlerInterface interface:

namespace App\Security;

final readonly class FormHandler implements RegistrationFormHandlerInterface
{
    public function __construct(
        private UserPasswordHasherInterface $userPasswordHasher
    ) {
    }

    public function process(Request $request, FormInterface $form, UserResponseInterface $userInformation): bool
    {
        $user = new User();
        $user->setEmail($userInformation->getEmail());
        $user->setUsername($userInformation->getNickname());
        $user->setFirstName($userInformation->getFirstName());
        $user->setLastName($userInformation->getLastName());

        $form->setData($user);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // encode the plain password
            $user->setPassword(
                $this->userPasswordHasher->hashPassword(
                    $user,
                    $form->get('plainPassword')->getData()
                )
            );

            return true;
        }

        return false;
    }
}

C) Configure connect functionality

As final step, we need to enable connect functionality both in the bundle & on the security firewall:

# config/packages/hwi_oauth.yaml
hwi_oauth:
    connect:
        account_connector: App\Security\OAuthConnector
        registration_form: App\Form\RegistrationFormType
        registration_form_handler: App\Security\FormHandler

In firewall configuration we need to change failure_path to the bundle route named hwi_oauth_connect_registration:

# config/packages/security.yaml
security:
    enable_authenticator_manager: true

    firewalls:
        main:
            pattern: ^/
            oauth:
                resource_owners:
                    facebook: "/login/check-facebook"
                    google:   "/login/check-google"
                login_path:   /login
                failure_path: hwi_oauth_connect_registration

                oauth_user_provider:
                    service: my.oauth_aware.user_provider.service

That was it!

Now when user tries to use login functionality without having account in your application, bundle will redirect on new page where user can finish creating account.

Given above examples are not production ready, and you need to adjust them to your needs.

Remember that you can (and you should) also overwrite templates provided by this bundle.

Bonus: connect existing accounts

Additional functionality is allowing users to connect their existing accounts with resource owners.

{% for owner in hwi_oauth_resource_owners() %}
    {% if attribute(app.user, owner) is empty %}
        <a href="{{ path('hwi_oauth_connect_service', {'service': owner}) }}">{{ owner | trans({}, 'HWIOAuthBundle') }}</a> <br />
    {% else %}
        <span>{{ owner | trans({}, 'HWIOAuthBundle') }} connected</span> <br />
    {% endif %}
{% endfor %}

Return to the index.