Gestion des utilisateurs sans FosUserBundle 1ère partie

FosUserBundle, c’est super, rapide à mettre en place et… magique. A moins d’aller voir le code du bundle, la plupart du temps, on l’utilise uniquement pour sécuriser rapidement un backoffice. C’est bien, ça suit un protocole standard classique, et si l’on veut sortir un peu des clous, il faut surcharger les contrôleurs. Rien de très compliqué en soi, à part qu’il faut aller voir un peu du côté des events car il y en a pas mal.

Mais si FosUserBundle nous simplifie la vie, cela ne doit pas nous dispenser de comprendre ce qui se passe à l’arrière et d’implémenter par nous-même un système de sécurité afin de bien comprendre toutes les rouages du mécanisme. Et surtout, parfois, sous prétexte d’une simple sécurisation du site, on va sortir l’artillerie lourde FosUser alors qu’un simple formulaire d’authentification aurait suffi.

C’est parti!

Le firewall

#app/config/security.yml
security:
    encoders:
        # Our user class and the algorithm we'll use to encode passwords
        # http://symfony.com/doc/current/book/security.html#encoding-the-user-s-password
        AppBundle\Entity\User: sha512
        
    providers:
        # in this example, users are stored via Doctrine in the database
        # To see the users at src/AppBundle/DataFixtures/ORM/LoadFixtures.php
        # To load users from somewhere else: http://symfony.com/doc/current/cookbook/security/custom_provider.html
        database_users:
            entity: { class: AppBundle:User, property: username }

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

        main:
            # this firewall applies to all URLs
            pattern: ^/

            # but the firewall does not require login on every page
            # denying access is done in access_control or in your controllers
            anonymous: true
            
            # This allows the user to login by submitting a username and password
            # Reference: http://symfony.com/doc/current/cookbook/security/form_login_setup.html
            form_login:
                # The route name that the login form submits to
                check_path: security_login_check
                # The name of the route where the login form lives
                # When the user tries to access a protected page, they are redirected here
                login_path: security_login_form
                # Secure the login form against CSRF
                # Reference: http://symfony.com/doc/current/cookbook/security/csrf_in_login_form.html
                csrf_provider: security.csrf.token_manager
                
                default_target_path: user_dashboard
                always_use_default_target_path: true
            logout:
                # The route name the user can go to in order to logout
                path: security_logout
                # The name of the route to redirect to after logging out
                target: homepage
    
    access_control:
        - { path: ^/account/signup, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/account, roles: ROLE_USER }

Uniquement du classique ici:

  • On définit sha512 comme encoder
  • On indique que notre base d’utilisateurs va être associée à notre entité User (que nous allons bientôt créer) et que le login accepté sera le username (libre à vous de choisir un autre champ de votre table)
  • un firewall main dans lequel on définit les chemins check_path et login_path
  • La route du logout et celle de redirection
  • les contrôles d’accès, c’est à dire les routes pour lesquelles on a défini des rôles obligatoires

L’entité User et son interface

Pourquoi une interface?

Pour respecter le principe O de SOLID. En effet, si nous voulons qu’une classe soit ouverte à l’extension mais fermée à la modification, il faut définir une interface qui sera l’argument de nos fonctions. Ainsi, si un jour nous voulons utiliser une autre entité que User, il n’y a pas de problème, il suffira qu’elle implémente notre UserInterface et il n’y aura pas besoin de revenir sur le service dans lequel nous aurions passé directement User (donc un code trop strict et sur lequel nous devrions repasser en cas de changement).

Ici, une seule méthode importante: encodePassword.

<?php

namespace AppBundle\Entity;

use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
use Symfony\Component\Security\Core\User\AdvancedUserInterface as SecurityUserInterface;

interface UserInterface extends SecurityUserInterface
{
    public function encodePassword(PasswordEncoderInterface $encoder);
} 

 

Et voici notre entité User:

<?php

namespace AppBundle\Entity;

use AppBundle\Entity\UserInterface;
use Doctrine\ORM\Mapping as ORM;
use Serializable;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * User
 *
 * @ORM\Table(name="user")
 * @ORM\Entity()
 * @UniqueEntity(fields="username", message="That username is taken!")
 * @UniqueEntity(fields="email", message="That email is taken!")
 */
class User implements UserInterface, Serializable
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="username", type="string", length=255)
     * @Assert\NotBlank(message="Give us at least 3 characters")
     * @Assert\Length(min=3, minMessage="Give us at least 3 characters!")
     */
    private $username;

    /**
     * @var string
     *
     * @ORM\Column(name="password", type="string", length=255)
     */
    private $password;

    /**
     * @ORM\Column(type="string", length=255)
     * @Assert\NotBlank
     * @Assert\Email
     */
    private $email;

    /**
     * @ORM\Column(type="json_array")
     */
    private $roles = array();

    /**
     * @var bool
     *
     * @ORM\Column(type="boolean")
     */
    private $isActive = true;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $salt;

    /**
     * @Assert\NotBlank
     * @Assert\Regex(
     *      pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$/",
     *      message="Use 1 upper case letter, 1 lower case letter, and 1 number"
     * )
     */
    private $plainPassword;

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set username
     *
     * @param string $username
     * @return User
     */
    public function setUsername($username)
    {
        $this->username = $username;

        return $this;
    }

    /**
     * Get username
     *
     * @return string
     */
    public function getUsername()
    {
        return $this->username;
    }

    public function getPlainPassword()
    {
        return $this->plainPassword;
    }

    public function setPlainPassword($plainPassword)
    {
        $this->plainPassword = $plainPassword;

        return $this;
    }

    /**
     * Set password
     *
     * @param string $password
     * @return User
     */
    public function setPassword($password)
    {
        $this->password = $password;

        return $this;
    }

    /**
     * Get password
     *
     * @return string
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * @param PasswordEncoderInterface $encoder
     */
    public function encodePassword(PasswordEncoderInterface $encoder)
    {
        if ($this->plainPassword) {
            $this->salt = sha1(uniqid(mt_rand()));
            $this->password = $encoder->encodePassword(
                $this->plainPassword,
                $this->salt
            );

            $this->eraseCredentials();
        }
    }

    /**
     * Returns the roles granted to the user.
     *
     * <code>
     * public function getRoles()
     * {
     *     return array('ROLE_USER');
     * }
     * </code>
     *
     * Alternatively, the roles might be stored on a ``roles`` property,
     * and populated in any number of different ways when the user object
     * is created.
     *
     * @return Role[] The user roles
     */
    public function getRoles()
    {
        $roles = $this->roles;
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

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

        // allows for chaining
        return $this;
    }

    /**
     * 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()
    {
        $this->setPlainPassword(null);
    }

    /**
     * Returns the salt that was originally used to encode the password.
     *
     * This can return null if the password was not encoded using a salt.
     *
     * @return string|null The salt
     */
    public function getSalt()
    {
        return $this->salt;
    }

    /**
     * @return boolean
     */
    public function getIsActive()
    {
        return $this->isActive;
    }

    /**
     * @param boolean $isActive
     */
    public function setIsActive($isActive)
    {
        $this->isActive = $isActive;
    }

    public function isAccountNonExpired()
    {
        return true;
    }

    public function isAccountNonLocked()
    {
        return true;
    }

    public function isCredentialsNonExpired()
    {
        return true;
    }

    public function isEnabled()
    {
        return $this->isActive;
    }

    public function serialize()
    {
        return serialize(array(
            $this->id,
            $this->username,
            $this->password,
        ));
    }

    public function unserialize($serialized)
    {
        list (
            $this->id,
            $this->username,
            $this->password,
        ) = unserialize($serialized);
    }

    /**
     * Set email
     *
     * @param string $email
     * @return User
     */
    public function setEmail($email)
    {
        $this->email = $email;

        return $this;
    }

    /**
     * Get email
     *
     * @return string
     */
    public function getEmail()
    {
        return $this->email;
    }

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

Ici, je n’implémente pas la classique UserInterface. J’ai envie d’aller plus loin et d’utiliser les fonctions de l’AdvancedUserInterface, plus intéressantes, pour interdire les utilisateurs non activés.

L’interface AdvancedUserInterface ajoute quatre méthodes supplémentaires pour valider le statut du compte :

  • isAccountNonExpired() vérifie si le compte de l’utilisateur a expiré,
  • isAccountNonLocked() vérifie si l’utilisateur est verrouillé,
  • isCredentialsNonExpired() vérifie si les informations de connexion de l’utilisateur (mot de passe) ont expiré,
  • isEnabled() vérifie si l’utilisateur est activé.

L’interface Serializable ainsi que ses méthodes serialize et unserialize ont été ajoutées pour permettre à la classe User d’être sérialisable dans la session. Cela peut ou non être nécessaire en fonction de votre configuration.

Le plainPassword n’est pas toujours présent dans les tutos, et il ne l’est pas dans celui de Sensio Labs. Ce champ vous sera utile lors de l’enregistrement (nous y reviendrons).

La fonction encodePassword nous servira plus tard mais sachez que nous préparons le terrain pour correctement découpler toutes les fonctions et créer une application maintenable, testable et réutilisable.

Le contrôleur de sécurité

Ce contrôleur est généralement appelé SecurityController mais ce n’est pas une obligation. Vous pouvez très bien l’appeler AuthenticationController si vous préférez, l’important reste le routing qui est renseigné dans security.yml pour check_path, login_path et path (pour logout).

#src/AppBundle/Controller
<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

/**
 * Controller used to manage the application security.
 * See http://symfony.com/doc/current/cookbook/security/form_login_setup.html.
 *
 * @author Ryan Weaver <weaverryan@gmail.com>
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class SecurityController extends Controller
{
    /**
     * @Route("/login", name="security_login_form")
     */
    public function loginAction()
    {
        $helper = $this->get('security.authentication_utils');

        return $this->render('security/login.html.twig', array(
            // last username entered by the user (if any)
            'last_username' => $helper->getLastUsername(),
            // last authentication error (if any)
            'error' => $helper->getLastAuthenticationError(),
        ));
    }

    /**
     * This is the route the login form submits to.
     *
     * But, this will never be executed. Symfony will intercept this first
     * and handle the login automatically. See form_login in app/config/security.yml
     *
     * @Route("/login_check", name="security_login_check")
     */
    public function loginCheckAction()
    {
        throw new \Exception('This should never be reached!');
    }

    /**
     * This is the route the user can use to logout.
     *
     * But, this will never be executed. Symfony will intercept this first
     * and handle the logout automatically. See logout in app/config/security.yml
     *
     * @Route("/logout", name="security_logout")
     */
    public function logoutAction()
    {
        throw new \Exception('This should never be reached!');
    }
}

Chargement en base d’un utilisateur

Il ne reste plus qu’à enregistrer un utilisateur en créant le répertoire DataFixtures/ORM et en y plaçant le fichier loadFixtures.php:

<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace AppBundle\DataFixtures\ORM;

use AppBundle\Entity\User;
use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Defines the sample data to load in the database when running the unit and
 * functional tests. Execute this command to load the data:
 *
 *   $ php app/console doctrine:fixtures:load
 *
 * See http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html
 *
 * @author Ryan Weaver <weaverryan@gmail.com>
 * @author Javier Eguiluz <javier.eguiluz@gmail.com>
 */
class LoadFixtures implements FixtureInterface, ContainerAwareInterface
{
    /** @var ContainerInterface */
    private $container;

    public function load(ObjectManager $manager)
    {
        $this->loadUsers($manager);
    }

    private function loadUsers(ObjectManager $manager)
    {
        $user = new User();
        $user->setUsername('user1');
        $user->setEmail('user1@symfony.com');
        $user->setPlainPassword('pass');
        $user->setIsActive(true);

        $factory = $this->container->get('security.encoder_factory');
        $encoder = $factory->getEncoder($user);
        $user->encodePassword($encoder);
        
        $manager->persist($user);
        $manager->flush();
    } 

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }
}

Le template de connexion

Il ne nous reste plus qu’à mettre en place le template de connexion:

{# app/Ressources/view/user/login.html.twig #}
{% extends 'base.html.twig' %}

{% block body_id 'login' %}

{% block main %}
    {% if error %}
        <div class="alert alert-danger">
            {{ error.messageKey|trans(error.messageData) }}
        </div>
    {% endif %}

    <div class="row">
        <div class="col-sm-5">
            <div class="well">
                <form action="{{ path('security_login_check') }}" method="post">
                    <fieldset>
                        <legend><i class="fa fa-lock"></i> Secure Sign in</legend>
                        <div class="form-group">
                            <label for="username">Username</label>
                            <input type="text" id="username" name="_username" value="{{ last_username }}" class="form-control"/>
                        </div>
                        <div class="form-group">
                            <label for="password">Password:</label>
                            <input type="password" id="password" name="_password" class="form-control" />
                        </div>
                        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
                        <button type="submit" class="btn btn-primary">
                            <i class="fa fa-sign-in"></i> Sign in
                        </button>
                    </fieldset>
                </form>
            </div>
        </div>
    </div>
{% endblock %}

 

puis le template du dashboard de l’utilisateur:

{# app/Ressources/view/user/dashboard.html.twig #}
{% extends 'base.html.twig' %}

{% block title 'Member Area' %}

{% block main %}
    <h1>Dashboard</h1>
    <p>
        Welcome {{ app.user.username }}
    </p>
{% endblock %}

Le contrôleur lié aux actions du user connecté

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\ChangePassword;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

/**
 * @Route("/account")
 */
class UserController extends Controller
{
    /**
     * @Route("/dashboard", name="user_dashboard")
     * @Method("GET|POST")
     */
    public function dashboardAction()
    {
        return $this->render('user/dashboard.html.twig');
    }
}

Et la connexion est possible.

Dans le prochain article, nous verrons comment mettre en place l’enregistrement et comment découpler correctement son code en mettant en place plusieurs services et events afin de minimiser le code dans les contrôleurs et découpler au maximum les fonctions.

Le code github se trouve ici: https://github.com/jpsymfony/authentication-demo
Sa version mise à jour (il faudra chercher un peu pour retrouver les classes car l’arborescence a changé) en Symfony 3.2 est ici: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture

Rédigé par

21 comments

  1. Bjr je tiens à te remercier pour ces excellents tuto sur la gestion des utilisateur en symfony. je suis entrain de mis former tout en faisant un site en symfony et un peu j’avais du mal avec FosUserBundle donc.
    GRAND MERCI

  2. Merci pour ce genre de tutoriel de grande qualité ou tout est bien expliqué de A à Z. Dans l’attente d’autres tutoriels du meme genre 🙂

    1. Merci! Je viens de commencer une série de tutos sur les design patterns, avec leur implémentation en Symfony2. J’espère que ça te plaira.

  3. Bonjour et bonne année à tous,

    Avant tout, merci pour ce tuto.
    Je suis confronté à un problème.

    Lors de la connexion j’ai le message d’erreur « throw new \Exception(‘This should never be reached!’) » de la méthode « loginCheckAction ».

    J’avoue sécher, pourriez-vous m’aider ?

    Merci d’avance

    1. Bonjour Matthieu,

      Est-ce que tes routes sont bien configurées dans le firewall de security.yml?

      En effet, le nom de la route loginCheckAction doit être renseignée avec le champ check_path et le nom indiqué doit être exactement celui de ta route.

      form_login:
      provider: database_users
      # The route name that the login form submits to
      check_path: security_login_check

      /**
      * This is the route the login form submits to.
      *
      * But, this will never be executed. Symfony will intercept this first
      * and handle the login automatically. See form_login in app/config/security.yml
      *
      * @Route(« /login_check », name= »security_login_check »)
      */
      public function loginCheckAction()
      {
      throw new \Exception(‘This should never be reached!’);
      }

      J’ai été un jour confronté à ce problème en mettant en place une api. Je tombais systématiquement sur ce message, mais c’était parce que ma route était mal configurée et Symfony ne l’interceptait pas.

  4. Bonjour,
    merci pour ton tuto.
    j’ai l’erreur : Unrecognized option « csrf_provider » under « security.firewalls.main.form_login »

  5. Bonjour,
    Je tiens à te remercier pour cet tuto très instructif pour moi qui apprend Symfony.
    Je débute un projet avec Symfony 3, serait t’il possible d’avoir une version actualisée de cet Excellent tuto?
    Cordialement.

    1. Bonjour Ousmane,

      Je manque cruellement de temps en ce moment pour les tutos (j’ai six articles dans les cartons mais manque de temps pour les finir) mais j’ai fait une version Symfony3 de ce tuto: https://github.com/jpsymfony/symfony3-generic-project

      La seule différence, c’est que le CoreBundle est allégé et générique, et que les entités concernant la partie User sont dans le UserBundle.

      Sinon, tu retrouveras exactement le même code, mais en Symfony3. Enjoy!

  6. bonjour
    j’ai ce message a chaque fois que j’essai de me loguer
    Authentication request could not be processed due to a system problem.

    auriez vous une idée pourquoi ?je cherche mais je ne trouve pas

    1. Bonjour Martin,

      J’ai déjà eu ce problème. Il était lié au fichier security.yml (de souvenir, problème de déclaration d’un provider ou d’un encoder). Regarde de ce côté là.

  7. Très bon tutorial, Grand merci , Très Grand merci !
    Au delà du formulaire d’authentification, cela m’a permis d’aborder la définition des Services, les Handlers, des Events avec aise.
    Sincèrement, il le fallait ce tutorial, très bonne continuation Jean Pierre.

  8. Bonjour,
    Tout d’abord un grand merci pour les tutos.

    je suis passé sous Symfony 4 et maintenant j’ai un message d’erreur lors du chargement de l’utilisateur :

    The « security.encoder_factory » service or alias has been removed or inlined when the container was compiled. You should either make it public, or stop using the container directly and use dependency injection instead.

    Auriez vous une idée ?

    1. Bonjour,

      Malheureusement, Symfony4 étant sorti il y a très peu de temps, je n’ai pas encore eu le temps de me pencher sur cette version (qui change vraiment beaucoup de choses au niveau de la configuration, le fichier AppKernel.php qui devient Bundles.php et est réduit au strict minimum, le fichier de configuration qui est explosé en pleins de petits fichiers de configuration, etc.)

      Je vois que pleins de gens ont le même souci sur le net. Ce service est utilisé dans les fixtures, mais tous les services sont privés par défaut dans Symfony4.

      Il faut donc l’injecter directement dans le constructeur plutôt que de faire un $this->containe->get(‘security.encoder_factory’) comme on le faisait en Symfony3. Tout est indiqué ici: https://symfony.com/doc/master/bundles/DoctrineFixturesBundle/index.html#accessing-services-from-the-fixtures

  9. bonjour,

    J’essaye de reproduire votre tuto sur symfony3 avec le lien github que vous avez laissé en commentaire mais je m’y perds un peu avec les nombreux changements effectué et le nombre de classe ajouté, concrètement pour adapter ce code a SF3 qu’elles sont vraiment les modifications a faire?

    Cordialement,

    1. J’essaierai de lister les modifications mais tout dépend si ton passe à symfony 3.4 avec l’autowiring ou pas (car ma migration en sf3 s’est arrêtée à sf3.2 sans autowiring)

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.