DEV Community

Matsounga Jules
Matsounga Jules

Posted on

Symfony on a lambda: authentication

Symfony on a lambda: connections

If you are lost, you can find the project on Github: link
Each branch will match a chapter.

Authentications

Now that we know how to handle our sessions, we can work with the authentication.

Creation of the user table

Like for the session, we need a table to store our users. Let's create one !
In our serverless.yml will add the following resource:

//serverless.yml
service: [name of your project]

provider:
    // ...
custom:
    // ...
plugins:
    // ...

functions:
    // ...

resources:
    Resources:
        // ...
    UsersTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:custom.prefix}-usersTable
            AttributeDefinitions:
            - AttributeName: email
                AttributeType: S
            KeySchema:
            - AttributeName: email
                KeyType: HASH
        ProvisionedThroughput:
            ReadCapacityUnits: 1
            WriteCapacityUnits: 1

We add a table to store our users, and define the field email as the index. We can now just run serverless deploy to create the table.

Once the table is created, we can now code the authentication system !

Forms

Symfony has a simple tool to produce what we need to login, lets use it. But first we need to create a class for our user.
In src/Entity, add the following class:

<?php

namespace App\Entity;

use Symfony\Component\Security\Core\User\UserInterface;

class User implements UserInterface
{
    /** @var string */
    protected $email;

    /** @var string */
    protected $password;

    /** @var array */
    protected $roles;

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): string
    {
        $this->email = $email;

        return $this;
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    public function getSalt()
    {
        return null;
    }

    public function getUsername(): string
    {
        return $this->email;
    }

    /**
     * Removes sensitive data from the user.
     *
     * This is important if, at any given point, sensitive information like
     * the plain-text password is stored on this object.
     */
    public function eraseCredentials()
    {
        // TODO: Implement eraseCredentials() method.
    }
}

Now, run the command php bin/console make:auth, then type 1, LoginFormAuthenticator and press enter for the other questions.

One small problem is, the template used for login has no style. Let's add some (so we can at least see the fields). Because I'm lazy (and that I have no idea for the design of this exercise), I will borrow what has been done on the landing page.

Here is my template:

{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}
    <section id="landing">
        <div id="landing-background">
        </div>
        <section class="w-1/2 mx-auto -mt-10 bg-white shadow-md py-2 px-4 rounded">
            <form method="post">
                {% if error %}
                    <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
                {% endif %}

                {% if app.user %}
                    <div class="mb-3">
                        You are logged in as {{ app.user.username }}, <a href="{{ path('app_logout') }}">Logout</a>
                    </div>
                {% endif %}

                <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
                <label for="inputEmail">Email</label>
                <input
                    type="email"
                    value="{{ last_username }}"
                    name="email" id="inputEmail"
                    required
                    autofocus
                >

                <label for="inputPassword">Password</label>
                <input
                    type="password"
                    name="password"
                    id="inputPassword"
                    required
                >

                <input type="hidden" name="_csrf_token"
                       value="{{ csrf_token('authenticate') }}"
                >

                {#
                    Uncomment this section and add a remember_me option below your firewall to activate remember me functionality.
                    See https://symfony.com/doc/current/security/remember_me.html

                    <div class="checkbox mb-3">
                        <label>
                            <input type="checkbox" name="_remember_me"> Remember me
                        </label>
                    </div>
                #}

                <button class="bg-blue-400 text-white p-2 rounded-sm mt-2" type="submit">
                    Sign in
                </button>
            </form>
        </section>
    </section>
{% endblock %}

To style the input, create assets/scss/components/form.scss with the following style:

form {
  input[type="text"], input[type="email"], input[type="password"] {
    @apply block border border-gray-400 p-2 w-full;
  }
}

And dont forget to import it in assets/scss/app.scss

We will now do the same for the registration.
Run php bin/console make:registration-form, just answer no to Do you want to add a @UniqueEntity validation annotation on your User class to make sure duplicate accounts aren't created? (yes/no) [yes], we will handle that ourself. For this one we will also change the design, here is my template

{% extends 'base.html.twig' %}

{% block title %}Register{% endblock %}

{% block body %}
    <section id="landing">
        <div id="landing-background">
        </div>
        <section class="w-1/2 mx-auto -mt-10 bg-white shadow-md py-2 px-4 rounded">
            Please register
            {{ form_start(registrationForm) }}
                {{ form_row(registrationForm.email) }}
                {{ form_row(registrationForm.plainPassword) }}
                {{ form_row(registrationForm.agreeTerms) }}

                <button class="bg-blue-400 text-white p-2 rounded-sm mt2">Register</button>
            {{ form_end(registrationForm) }}
        </section>
    </section>
{% endblock %}

Great, now that we have some visual, let's work connect everything !

Registration

First thing we want to work with is the registration.
We need an encoder to encode our password. Let's add it, open config/packages/security.yaml and add the following lines:

security:
    //....
    encoders:
        Symfony\Component\Security\Core\User\UserInterface: auto

Now, because we don't use an SQL database we can't use doctrine to persist our user. Let's create our own repository to work with.
In src/Repository/UserRepository.php add the following lines

<?php

namespace App\Repository;

use App\Entity\User;
use App\Services\AwsClient;
use Aws\DynamoDb\Marshaler;

class UserRepository
{
    protected $dynamoDbClient;

    protected $tableName;

    public function __construct(AwsClient $awsClient, string $tableName)
    {
        $this->dynamoDbClient = $awsClient->dynamoDbClient();
        $this->tableName = $tableName;
    }

    public function insert(User $user): User
    {
        $item = (new Marshaler())
            ->marshalJson((string) json_encode([
                'email' => $user->getEmail(),
                'password' => $user->getPassword(),
                'roles' => $user->getRoles()
            ]));

        $this->dynamoDbClient->putItem([
            'TableName' => $this->tableName,
            'Item' => $item,
        ]);

        return $user;
    }
}

We just create a service with the dynamoDbClient we used for our session handling. We also pass the table name to the constructor as it change depending of the stage and project name. Because of that we need to update our config/services.yaml

//...

App\Repository\UserRepository:
    arguments:
    $tableName: '%env(APP_AWS_RESOURCE_PREFIX)%-usersTable'

To come back to our repository, we just create a simple insert function. DynamoDb has its own format so we have to transform the data with the help of Marshaler .

We should now update the RegistrationController.php to use this repository. The function register should now look like this:

    public function register(
        Request $request,
        UserPasswordEncoderInterface $passwordEncoder,
        GuardAuthenticatorHandler $guardHandler,
        LoginFormAuthenticator $authenticator,
        UserRepository $userRepository
    ): Response {
        $user = new User();
        $form = $this->createForm(RegistrationFormType::class, $user);
        $form->handleRequest($request);

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

            $user->setRoles(['ROLE_USER']);
            $userRepository->insert($user);

//            return $guardHandler->authenticateUserAndHandleSuccess(
//                $user,
//                $request,
//                $authenticator,
//                'main' // firewall name in security.yaml
//            );
        }

We pass our repository and use it. We also give a role to our user so once login it can do something. As we don't have connect the authentication, we comment the rule that auto-connect once logging. We will uncomment it later.

Login

We now have a user in the database, so we can connect. Still, because we don't use a SQL database, we will need to have or own user provider that will fetch our user from our dynamoDb table.

Create a new class src/Security/UserProvider.php:

<?php

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class UserProvider implements UserProviderInterface
{
    /** @var UserRepository */
    protected $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function loadUserByUsername($username): UserInterface
    {
        $user = $this->userRepository->findOneByEmail($username);

        if ($user === null) {
            throw new UsernameNotFoundException();
        }

        return $user;
    }

    public function refreshUser(UserInterface $user)
    {
        return $user;
    }

    public function supportsClass($class)
    {
        return User::class === $class;
    }
}

The class does not do a lot of thing:

  • loadUserByUsername will get our user using the repository (don't worry, we will implement the method just after).
  • refreshUser is used once the page change and that we are logged, we can refresh some data if we want.
  • supportsClass is only use to determine if this provider can be use with the entity we retrieved.

Let's create our method find in the repository.

// src/Repository/UserRepository
public function findOneByEmail(string $email): ?User
{
    $key = (new Marshaler())
        ->marshalJson((string) json_encode(['email' => $email]));

    $userDocument = $this->dynamoDbClient
        ->getItem([
          'TableName' => $this->tableName,
          'Key' => $key,
        ])
        ->get('Item')
    ;

    if ($userDocument === null) {
        return null;
    }

    $userArray = (new Marshaler())->unmarshalItem($userDocument);

    return (new User())
        ->setEmail($userArray['email'])
        ->setPassword($userArray['password'])
        ->setRoles($userArray['roles'])
    ;
}

Here, we search the user using its email. Because we set email as the index of the table, we can query on it easily.
If we don't have any user we just return null, otherwise we hydrate a new User object.

We can now tell Symfony to use our custom provider. To do that, we have to edit src/config/packages/security.yaml:

security:
        providers:
        user_provider:
            id: App\Security\UserProvider
    //...

Now it's time to update src/Security/LoginFormAuthenticator.php. Once create using the make command, some methods are let empty, so we have to complete them.

First, we have to update the constructor:

protected $userPasswordEncoder;

public function __construct(
    UrlGeneratorInterface $urlGenerator,
    CsrfTokenManagerInterface $csrfTokenManager,
    UserPasswordEncoderInterface $userPasswordEncoder
) {
    $this->urlGenerator = $urlGenerator;
    $this->csrfTokenManager = $csrfTokenManager;
    $this->userPasswordEncoder = $userPasswordEncoder;
}

We will use the UserPasswordEncoderInterface to check that the password entered in the login form match the user that we found in the table.

Next, let's edit the checkCredentials method:

public function checkCredentials($credentials, UserInterface $user)
{
    return $this->userPasswordEncoder->isPasswordValid($user, $credentials['password']);
}

The method talk for itself.

Then, we have to edit onAuthenticationSuccess to have a correct redirection once the user is authenticated:

public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
    if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
        return new RedirectResponse($targetPath);
    }

    return new RedirectResponse($this->urlGenerator->generate('app_user_index'));
}

app_user_index is a new route, we will have to create it. To do so, create a new class src/Controller/UserController :

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class UserController extends AbstractController
{
    /**
     * @Route("/user", name="app_user_index")
     * @IsGranted("ROLE_USER")
     */
    public function index(): Response
    {
        return $this->render('user/index.html.twig', ['user' => $this->getUser()]);
    }
}

And then create the template templates/user/index.html.twig:

{% extends 'base.html.twig' %}

{% block body %}
    <main id="landing" class="w-screen h-screen bg-blue-100">
        <div id="landing-background">
        </div>
        <section class="w-1/2 mx-auto -mt-10 bg-white shadow-md py-2 text-center rounded">
            <h1 class="text-4xl">Welcome {{ user.email }}</h1>
        </section>
    </main>
{% endblock %}

You can now log in, you will be redirect to the template above.

It's time to uncomment the lines that we commented in src/Controller/RegistrationController.php, now, once we register we will be automatically log in.

You can logout with the /logout route, it has been created automatically earlier.


Thanks to the session, we can now navigate without losing the connection. See you in the last chapters for some tips and information that can be helpful.

Top comments (0)