Mini série: création d'un espace membre sur Symfony 4 (2/5)

publié le 06/02/2018

symfony 4 entité User User Provider

A la fin de la partie 1, nous pouvions nous connecter et nous déconnecter de notre espace membre. Nous chargions nos membres en utilisant le memory Provider de Symfony. La plupart du temps cependant, vous enregistrerez vos membres en base de données. C'est donc le moment d'utiliser la puissance de Doctrine, de créer une entité User et de l'utiliser comme nouveau provider.


Partie 2: Chargement des membres depuis une base de données

Installation et configuration de Doctrine

Si vous ne l'avez pas fait avant, c'est le moment d'installer et de configurer Doctrine:

composer require doctrine
# .env
# personnaliser cette ligne en saisissant vos informations de connexion à la base!
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name"

Créons notre base de données. En ligne de commande:

bin/console doctrine:database:create

Création de l'entité User

<?php
// src/Entity/User.php
namespace App\Entity;
 
use Doctrine\ORM\Mapping as ORM ;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;
 
/**
 * @ORM\Table(name="app_users")
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 * @UniqueEntity(fields="email", message="Cet email est déjà enregistré en base.")
 * @UniqueEntity(fields="username", message="Cet identifiant est déjà enregistré en base")
 */
class User implements UserInterface, \Serializable
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
 
    /**
     * @ORM\Column(type="string", length=25, unique=true)
     * @Assert\NotBlank()
     * @Assert\Length(max=25)
     */
    private $username;
 
    /**
     * @ORM\Column(type="string", length=64)
     */
    private $password;
 
    /**
     * @ORM\Column(type="string", length=60, unique=true)
     * @Assert\NotBlank()
     * @Assert\Length(max=60)
     * @Assert\Email()
     */
    private $email;
 
    /**
     * @ORM\Column(name="is_active", type="boolean")
     */
    private $isActive;
      
    public function __construct()
    {
        $this->isActive = true;
    }
     
    /*
     * Get id
     */
    public function getId()
    {
        return $this->id;
    }
 
    public function getUsername()
    {
        return $this->username;
    }
 
    public function setUsername($username)
    {
        $this->username = $username;
        return $this;
    }
 
 
    public function getPassword()
    {
        return $this->password;
    }
 
    public function setPassword($password)
    {
        $this->password = $password;
        return $this;
    }
 
    /*
     * Get email
     */
    public function getEmail()
    {
        return $this->email;
    }
 
    /*
     * Set email
     */
    public function setEmail($email)
    {
        $this->email = $email;
        return $this;
    }
 
    /*
     * Get isActive
     */
    public function getIsActive()
    {
        return $this->isActive;
    }
 
    /*
     * Set isActive
     */
    public function setIsActive($isActive)
    {
        $this->isActive = $isActive;
        return $this;
    }
 
    public function getSalt()
    {
        // pas besoin de salt puisque nous allons utiliser bcrypt
        // attention si vous utilisez une méthode d'encodage différente !
        // il faudra décommenter les lignes concernant le salt, créer la propriété correspondante, et renvoyer sa valeur dans cette méthode
        return null;
    }
 
    public function eraseCredentials()
    {
    }
 
    /** @see \Serializable::serialize() */
    public function serialize()
    {
        return serialize(array(
            $this->id,
            $this->username,
            $this->password,
            $this->isActive,
            // voir remarques sur salt plus haut
            // $this->salt,
        ));
    }
 
    /** @see \Serializable::unserialize() */
    public function unserialize($serialized)
    {
        list (
            $this->id,
            $this->username,
            $this->password,
            $this->isActive,
            // voir remarques sur salt plus haut
            // $this->salt
        ) = unserialize($serialized);
    }
 
}

L'installation de Doctrine inclut désormais DoctrineMigrations dans son package. Nous avons créé une entité. Il faut désormais que migre la base.

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

La structure de notre entité est désormais enregistrée en base de donnée.


Création de la classe UserChecker

Cette classe sera utilisée par Symfony comme un "callback", afin de vérifier lors de la connexion d'un membre, que le compte est bel et bien activé. Si nous n'implémentons pas cette classe, la prorpiété isActive de notre entité User n'aura aucun effet en terme de restriction d'authentification.

<?php
namespace App\Security;
 
use App\Entity\User as AppUser;
use Symfony\Component\Security\Core\Exception\AccountExpiredException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
 
class UserChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user)
    {
        if (!$user instanceof AppUser) {
            return;
        }
    }
 
    public function checkPostAuth(UserInterface $user)
    {
        if (!$user instanceof AppUser) {
            return;
        }
 
        // user account is expired, the user may be notified
        if (!$user->getIsActive()) {
            throw new \Exception("ce membre n'est pas actif");
        }
    }
}

Modification du security.yaml

# config/packages/security.yaml
security:
    encoders:
        App\Entity\User:
            algorithm: bcrypt
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        our_db_provider:
            entity:
                class: App\Entity\User
    firewalls:
        # ...
        main:
            pattern:    ^/
            user_checker: App\Security\UserChecker # activation du UserChecker créé précédemment
            http_basic: ~
            provider: our_db_provider
        # ...

Nouvelle méthode dans le UserRepository

L'entité User contient 2 champs uniques: le username et l'email. La configuration actuelle permet de s'authentifier uniquement grâce à l'identifiant. Nous allons modifier le UserRepository pour que les utilisateurs puissent se connecter en reseignant l'email également.

<?php
// src/Repository/UserRepository.php
namespace App\Repository;
 
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Doctrine\ORM\EntityRepository;
 
class UserRepository extends EntityRepository implements UserLoaderInterface
{
    public function loadUserByUsername($username)
    {
        return $this->createQueryBuilder('u')
            ->where('u.username = :username OR u.email = :email')
            ->setParameter('username', $username)
            ->setParameter('email', $username)
            ->getQuery()
            ->getOneOrNullResult();
    }
}

La gestion des rôles

Actuellement, le rôle dans notre entité User est hardcodé car la méthode getRoles retourne automatiquement ['ROLE_USER']. Modifions notre entité pour qu'on puisse assigner des rôles différents à nos membres.

<?php
// src/Entity/User.php
namespace App\Entity;
 
// ...
class User implements UserInterface, \Serializable
{
    // ...
    /**
     * @var array
     * @ORM\Column(type="array")
     */
    private $roles;
 
    public function __construct()
    {
        $this->isActive = true;
        $this->roles = ['ROLE_USER'];
    }
 
    // modifier la méthode getRoles
    public function getRoles()
    {
        return $this->roles; 
    }
 
    public function setRoles(array $roles)
    {
        if (!in_array('ROLE_USER', $roles))
        {
            $roles[] = 'ROLE_USER';
        }
        foreach ($roles as $role)
        {
            if(substr($role, 0, 5) !== 'ROLE_') {
                throw new InvalidArgumentException("Chaque rôle doit commencer par 'ROLE_'");
            }
        }
        $this->roles = $roles;
        return $this;
    }
}

L'entité comporte une nouvelle propriété. Il faut donc migrer le schéma de la base.

bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate

Dans le cas d'un espace admin dont on voudrait limiter l'accès, il faudrait modifier le security.yaml de cette manière:

# config/packages/security.yaml
security:
    role_hierarchy:
        # un membre avec le ROLE_ADMIN aura également le ROLE_USER
        ROLE_ADMIN: ROLE_USER
        # Le ROLE_SUPER_ADMIN avec le ROLE_ALLOWED_TO_SWITCH qui permet de s'authentifier en se faisant passer pour n'importe quel membre. Particulièrement utile pour débugger son code.
        ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
 
    # ...
    firewalls:
        # ...
        main:
            # ...
            switch_user: ~
            # permet de profiter du ROLE_ALLOWED_TO_SWITCH du super admin
            # https://symfony.com/doc/current/security/impersonating_user.html
 
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }