Element Zero
Element Zero

Reputation: 1751

Decouple authentication from your user entity in Symfony 4?

After having some problems with using the same entity for both maintaining employees and for tracking logins to my website, I found a post here that talks about decoupling the security user. Unfortunately I feel the post leaves quite a bit missing in terms of how to fully implement it (and it seems I'm not the only one). First off why do I need to do this and how would I implement it in Symfony 4?

Upvotes: 1

Views: 733

Answers (1)

Element Zero
Element Zero

Reputation: 1751

Why do I need to do this?

This is explained more in detail here

...this also comes with side effects: You will end up with this Entity in your session Developers tend to also use this entity in forms Session Entities If you end up with Entities in your session, you will get synchronization issues. If you update your entity, that means your session entity won't be updated as it's not from the database. In order to solve this issue, you can merge the entity back into the entity manager each request.

While this solves one of the problems, another common issue is the (un)serialization. Eventually your User Entity will get relations to other objects and this comes with several side-effects:

Relations will be serialized as well If a relation is lazy loaded (standard setting), it will try to serialize the Proxy which contains a connection. This will spew some errors on your screen as the connection cannot be serialized. Oh and don't even think about changing your Entity such as adding fields, this will cause unserialization issues with incomplete objects because of missing properties. This case is triggered for every authenticated user.

Basically the problem is that if you use the same entity for authentication AND for dealing with users/employees/clients/etc. you will come into the problem of when you change a property of the entity it will cause the authenticated user to not be in sync with what's in the database - leading to issues with roles not being correct, the user being suddenly forced to logout (thanks to the logout_on_user_change setting), or other problems depending on how the user class is used in your system.

How do I fix this?

Assumptions: I'm going to assume you have a 'User' Entity that has at least username, password and roles

In order to fix this we need to make a couple separate services that will work as a bridge between the User entity and the User for authentication.

This first is to create a security user which utilizes fields from the user class

SecurityUser /app/Security/SecurityUser.php

<?php

namespace App\Security;

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

class SecurityUser implements UserInterface, \Serializable
{
    private $username;
    private $password;
    private $roles;

    public function __construct(User $user)
    {
        $this->username = $user->getUsername();
        $this->password = $user->getPassword();
        $this->roles = $user->getRoles();
    }

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

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

    public function getSalt()
    {
        // you *may* need a real salt depending on your encoder
        // see section on salt below
        return null;
    }

    /** @see \Serializable::serialize() */
    public function serialize()
    {
        return serialize(array(
            $this->username,
            $this->password,
            // Should only be set if your encoder uses a salt i.e. PBKDF2
            // This example uses Argon2i
            // $this->salt,
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized)
    {
        list (
            $this->username,
            $this->password,
            // Should only be set if your encoder uses a salt i.e. PBKDF2
            // This example uses Argon2i
            // $this->salt
            ) = unserialize($serialized, array('allowed_classes' => false));
    }

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

    public function eraseCredentials()
    {
    }
}

With this, we pull the records from our User entity - which means we don't need to have a separate table for storing user information, and yet we have decoupled the authentication user from our Entity - meaning that changes to the Entity will now not directly impact the SecurityUser.

In order for Symfony to authenticate with this SecurityUser class, we will need to create a Provider:

SecurityUserProvider /app/Security/SecurityUserProvider

<?php

namespace App\Security;

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

class SecurityUserProvider implements UserProviderInterface
{
    private $userRepository;

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

    public function loadUserByUsername($username)
    {
        return $this->fetchUser($username);
    }

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof SecurityUser) {
            throw new UnsupportedUserException(
                sprintf('Instances of "%s" are not supported.', get_class($user))
            );
        }

        $username = $user->getUsername();

        $this->logger->info('Username (Refresh): '.$username);

        return $this->fetchUser($username);
    }

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

    private function fetchUser($username)
    {
        if (null === ($user = $this->userRepository->findOneBy(['username' => $username]))) {
            throw new UsernameNotFoundException(
                sprintf('Username "%s" does not exist.', $username)
            );
        }

        return new SecurityUser($user);
    }
}

This service will basically ask query the database for the username and then the roles for the associated username. If the username is not found then it will create an error. It then return a SecurityUser object back to Symfony for authentication.

Now we need to tell Symfony to use this object

Securty.yaml /app/config/packages/security.yaml

security:
    ...
    providers:
        db_provider:
            id: App\Security\SecurityUserProvider

the name "db_provider" doesn't matter - you can use anything you wish. This name is only used to map the provider to the firewall. How to configure the firewall is a little beyond the scope of this document, see here for the pretty good documentation on it. Regardless, if for some reason you are curious what mine looks like (though I won't go into explaining it):

security:
    ...
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern:    ^/
            anonymous: ~
            provider: db_provider
            form_login:
                login_path: login
                check_path: login
            logout:
                path:   /logout
                target: /
                invalidate_session: true

    access_control:
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: ROLE_USER }

Lastly, we need to configure an encoder so that we can encrypt the passwords.

security:
    ...
    encoders:
        App\Security\SecurityUser:
            algorithm: argon2i
            memory_cost: 102400
            time_cost: 3
            threads: 4

Side note (off-topic): Note that I'm using Argon2i. The values for the memory_cost, time_cost and threads are quite subjective depending on your system. You can see my post here which can help you get the correct values for your system

At this point your Security should be working and you have completely decoupled from your User Entity - congrats!

Other related areas of interest

Now that you have this, perhaps you should add some code so that your users sessions will be destroyed after they are Idle for so long. To do that please look at my answer here.

Upvotes: 2

Related Questions