Decouple UserInterface in Symfony Security

Use Symfony Security without implementing UserInterface in your domain User object.

UserInterface is a contract between your application and Symfony Security. It binds them together so that you can use Symfony Security in your application. The domain layer is where our business entities live and it is not a great idea when your application has to fulfill a contract right within the Domain layer.

We usually have a User entity in our application but we do not want to fulfill a third party contract in the domain layer. We still have to provide an implementation of UserInterface but not in the domain layer. You will have to write a few classes to achieve this loose-coupling. I have broken it down into 5-steps. Let us get started.

  1. As we are using Symfony Security. There will definitely be a User entity. But we do not need to implement UserInterface. This entity can have whatever your business logic needs. Find below a User entity.
namespace App\Domain\Entity;

use DateTime;

class User
{
	private $id;
    private string $uuid;
    private string $email;
    private string $password;
    private bool $activated = false;
    private bool $deleted = false;
    private DateTime $memberSince;
    private array $roles;

    public function __construct(string $uuid, string $email, string $password, array $roles)
    {
        $this->uuid = $uuid;
        $this->email = $email;
        $this->password = $password;
        $this->roles = $roles;

        $this->activated = false;
        $this->deleted = false;
        $this->memberSince = new DateTime('now');
    }

	public function getId() : ?int
    {
        return $this->id;
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function getUuid(): string
    {
        return $this->uuid;
    }

    public function setUuid(string $uuid): void
    {
        $this->uuid = $uuid;
    }
	
    public function getEmail() : string
    {
        return $this->email;
    }

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

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

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

    public function getActivated() : bool
    {
        return $this->activated;
    }

    public function setActivated(bool $active): void
    {
        $this->activated = $active;
    }

    public function getDeleted() : bool
    {
        return $this->deleted;
    }

    public function setDeleted(bool $deleted): void
    {
        $this->deleted = $deleted;
    }

    public function getMemberSince() : DateTime
    {
        return $this->memberSince;
    }

    public function setMemberSince(DateTime $memberSince): void
    {
        $this->memberSince = $memberSince;
    }

    public function getRoles() : array
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }

    public function setRoles($roles): void
    {
        $this->roles = $roles;
    }
}

2. Secondly, you need an implementation of UserInterface. I call it UserAdapter. Its purpose is to make the Symfony Security happy and give us back our normal User entity when we want to use the authenticated User in our business logic. You do not need to have the methods of your User entity. Its purpose is to implement UserInterface. And the methods of UserInterface are of importance here.

namespace App\Infrastructure\Security\Symfony;

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

class UserAdapter implements UserInterface
{
    private User $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

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

    public function setPassword(string $password): void
    {
        $this->user->setPassword($password);
    }

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

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

    public function getSalt(): string
    {
        //Since php 7, random salt is auto-generated by hash_password and is stored with password
        return '';
    }

    public function eraseCredentials()
    {
        $this->setPassword('');
    }

    public function getEntity(): User
    {
        return $this->user;
    }
}

3. In the third step, we need to load a normal User entity from the database (or any other source) when a user is authenticated, but provide a UserInterface for Symfony Security. For this purpose, we write a custom UserLoader.

use App\Domain\Repository\User\UserFinder;
use Symfony\Bridge\Doctrine\Security\User\EntityUserProvider;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;

class UserLoader extends EntityUserProvider implements UserLoaderInterface
{
    private $userFinder;

    public function __construct(UserFinder $userFinder)
    {
        $this->userFinder = $userFinder;
    }

    public function loadUserByUsername($username)
    {
        if (null === ($user = $this->userFinder->findOneBy('email', $username))) {
            throw new BadCredentialsException(sprintf('No user found for "%s"', $username));
        }

        return new UserAdapter($user);
    }
}

4. When you use the Symfony Security class in a controller to get the currently authenticated user, you will get an object of UserAdapter, the UserInterface implementation. It is not a User entity object. One shortcut way is to access the user via $security->getUser()->getEntity() but it is a train wreck. Let’s write better code and provide an Adapter for the Symfony Security class.

namespace App\Infrastructure\Security\Symfony;

use App\Domain\Entity\User;
use Symfony\Component\Security\Core\Security as SymfonySecurity;

class SecurityAdapter
{
    private SymfonySecurity $security;

    public function __construct(SymfonySecurity $security)
    {
        $this->security = $security;
    }

    public function getUser(): User
    {
        return $this->security->getUser()->getEntity();
    }
}

When you need the current user in your controller or any other class, do not use Symfony\Component\Security\Core\Security instead use your own App\Infrastructure\Security\Symfony\SecurityAdapter that you just wrote above.

5. Finally, the security configurations that you need to take care of. You need to provide the encoder for UserAdapter. Secondly, you also need to specify your custom UserLoader. The rest of the configs are normally expected ones. This is a YAML sample.

security:
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    encoders:
        App\Infrastructure\Security\Symfony\UserAdapter:
            algorithm: auto

    providers:
        custom_provider:
            id: App\Infrastructure\Security\Symfony\UserLoader

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        login:
            pattern:  ^/login
            stateless: true
            anonymous: true
            provider: custom_provider
            form_login:
                check_path:  /login_check
                username_parameter: email
                password_parameter: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern:   ^/(job|arbeit)
            stateless: true
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        - { path: ^/login, roles: !php/const App\Application\Service\Security\Role::ANONYMOUS }
        - { path: ^/(job|arbeit), roles: !php/const App\Application\Service\Security\Role::USER }

Discuss this on twitter