Archives mensuelles : avril 2018

Symfony 4 : Les base d’une gestion des utilisateurs (inscription, connexion, droits d’accès)

Quand on démarre le développement d’une site sous Symfony, l’une des première chose à faire concerne la gestion des utilisateurs. En effet dans la plupart des cas notre application devra permettre à des utilisateurs de s’inscrire puis de se connecter au site afin d’avoir accès à des sections particulières (espace membre, espace administrateur). Je reprends dans cet article les différents éléments à mettre en place pour avoir une base de travail fonctionnelle.

On va rendre possible une inscription (email / mot de passe), puis une connexion (idem). Une fois connecté on aura accès a un espace membre.

Nous partirons d’une installation « website » de symfony 4 lancé avec la commande

composer create-project symfony/website-skeleton my-project

La première chose à faire sera de créer la base de données, et de la configurer dans le fichier « /.env », par exemple :

DATABASE_URL=mysql://root:@localhost/ma_base

Entrons dans le vif du sujet, avec la création de l’entité « User », dans « /src/Entity/User.php ». Elle contiendra les champs email, password, isActive, roles et étendra la classe « UserInterface » de symfony. Ce qui nous donne avec les getters/setters et les méthodes obligatoires à implémenter, le code suivant :

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Table(name="user")
 * @UniqueEntity(fields="email")
 * @ORM\Entity()
 */

class User implements UserInterface, \Serializable {

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */

    private $id;

    /**
     * @ORM\Column(type="string", length=255, unique=true)
     * @Assert\NotBlank()
     * @Assert\Email()
     */

    private $email;

    /**
     * @Assert\NotBlank()
     * @Assert\Length(max=250)
     */

    private $plainPassword;

    /**
     * The below length depends on the "algorithm" you use for encoding
     * the password, but this works well with bcrypt.
     *
     * @ORM\Column(type="string", length=64)
     */

    private $password;

    /**
     * @ORM\Column(name="is_active", type="boolean")
     */

    private $isActive;

    /**
     * @ORM\Column(name="roles", type="array")
     */

    private $roles = array();

    public function __construct() {
        $this->isActive = true;
        // may not be needed, see section on salt below
        // $this->salt = md5(uniqid('', true));
    }

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

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

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

    function setPassword($password) {
        $this->password = $password;
    }

    public function getRoles() {
        if (empty($this->roles)) {
            return ['ROLE_USER'];
        }
        return $this->roles;
    }

    function addRole($role) {
        $this->roles[] = $role;
    }

    public function eraseCredentials() {
       
    }

    /** @see \Serializable::serialize() */
    public function serialize() {
        return serialize(array(
            $this->id,
            $this->email,
            $this->password,
            $this->isActive,
                // see section on salt below
                // $this->salt,
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized) {
        list (
                $this->id,
                $this->email,
                $this->password,
                $this->isActive,
                // see section on salt below
                // $this->salt
                ) = unserialize($serialized);
    }

    function getId() {
        return $this->id;
    }

    function getEmail() {
        return $this->email;
    }

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

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

    function setId($id) {
        $this->id = $id;
    }

    function setEmail($email) {
        $this->email = $email;
    }

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

    function setIsActive($isActive) {
        $this->isActive = $isActive;
    }

}

Une fois ce fichier enregistré, on peux mettre à jour notre base de données afin d’y créer la table « User ». Pour cela on lance les 2 commandes suivantes :

php bin/console doctrine:migration:diff
php bin/console doctrine:migration:migrate

Il est temps de passer à la configuration. Rendons nous dans le fichier « /config/packages/security.yaml » et remplissons le de la façon suivante :

security:
   # encoder
    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
                property
: email
       
    firewalls
:
        main
:
            pattern
:   ^/
            http_basic
: ~
            provider
: our_db_provider
            anonymous
: ~
            form_login
:
                login_path
: login
                check_path
: login
            logout
:
                path
:  /logout
                target
: /
           
    role_hierarchy
:
        ROLE_ADMIN
:      ROLE_USER
        #ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    # 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
: ^/admin, roles: ROLE_ADMIN }
        - { path
: ^/member, roles: ROLE_USER }

Comme on peux le comprendre on a mis en place la configuration pour l’encodage du mot de passe, on indique que nos utilisateurs passe par l’entité « App\Entity\User » et que le login est le champ « email ». On configure aussi les liens /login et /logout à utiliser, les différents roles possible (ROLE_USER, ROLE_ADMIN) et bien sur les droits d’accès en fonctions des rôles. Ici toutes les routes commençant par « /member » ne seront accessible que pour les membres connectés, et celle commençant par « /admin » pour les membres connecté ayant le rôle « ROLE_ADMIN ».

Pour faire fonctionner la déconnexion « logout », il faut simplement créer la route. On se rend donc dans le fichier « /config/routes.yaml » pour y mettre ce code :

logout:
    path
: /logout

Il nous reste à créer la page contenant le formulaire d’inscription, celle contenant le formulaire de connexion et tout sera fonctionnel.

Pour l’inscription, on va d’abord créer le formulaire dans un fichier à part qui sera « /src/Form/UserType.php ». J’ai ajoutés des classes css de bootstrap pour le bouton submit, car on verra plus bas que je propose d’utiliser bootstrap 4 pour l’affichage, mais il s’agit bien sur uniquement d’un exemple.

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

class UserType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) {
        $builder
                ->add('email', EmailType::class)
                ->add('plainPassword', RepeatedType::class, array(
                    'type' => PasswordType::class,
                    'first_options' => array('label' => 'Mot de passe'),
                    'second_options' => array('label' => 'Confirmation du mot de passe'),
                ))
                ->add('submit', SubmitType::class, ['label'=>'Envoyer', 'attr'=>['class'=>'btn-primary btn-block']])
        ;
    }
}

Nous allons utiliser ce formulaire dans le controller « /src/Controller/RegistrationController.php ». La page permettant l’inscription sera accessible sur l’url « /register », ici on enregistre et on valide directement le compte, on peut aussi attribuer le rôle utilisateur dans ce controlleur. C’est une base qui pourras être amélioré (envoie d’un email de confirmation…etc)

namespace App\Controller;

use App\Form\UserType;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class RegistrationController extends Controller {

    /**
     * @Route("/register")
     */

    public function registerAction(Request $request, UserPasswordEncoderInterface $passwordEncoder) {
        // 1) build the form
        $user = new User();
        $form = $this->createForm(UserType::class, $user);
        // 2) handle the submit (will only happen on POST)
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // 3) Encode the password (you could also do this via Doctrine listener)
            $password = $passwordEncoder->encodePassword($user, $user->getPlainPassword());
            $user->setPassword($password);
            //on active par défaut
            $user->setIsActive(true);
            //$user->addRole("ROLE_ADMIN");
            // 4) save the User!
            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($user);
            $entityManager->flush();
            // ... do any other work - like sending them an email, etc
            // maybe set a "flash" success message for the user
            $this->addFlash('success', 'Votre compte à bien été enregistré.');
            //return $this->redirectToRoute('login');
        }
        return $this->render('registration/register.html.twig', ['form' => $form->createView(), 'mainNavRegistration' => true, 'title' => 'Inscription']);
    }

}

Dans la foulée, on enchaine sur le controller « /src/Controller/SecurityController.php » avec la page « /login » qui permettra la connexion, cette fois on créer directement le formulaire dans le controller, cela permet de voir une autre manière de faire que pour l’enregistrement.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends Controller {

    /**
     * @Route("/login", name="login")
     */

    public function login(Request $request, AuthenticationUtils $authenticationUtils) {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();
        //
        $form = $this->get('form.factory')
                ->createNamedBuilder(null)
                ->add('_username', null, ['label' => 'Email'])
                ->add('_password', \Symfony\Component\Form\Extension\Core\Type\PasswordType::class, ['label' => 'Mot de passe'])
                ->add('ok', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, ['label' => 'Ok', 'attr' => ['class' => 'btn-primary btn-block']])
                ->getForm();
        return $this->render('security/login.html.twig', [
                    'mainNavLogin' => true, 'title' => 'Connexion',
                    //
                    'form' => $form->createView(),
                    'last_username' => $lastUsername,
                    'error' => $error,
        ]);
    }

}

Tout ce qui concerne la partie « technique » et maintenant en place et fonctionnel. Pour aller jusqu’au bout on va aussi voir la mise en place des templates pour pouvoir tester le tout (avec un joli affichage à l’aide de bootstrap 4). Pour avoir accès à nos route « /register » et « /login » de nos controllers il nous faut créer les 2 templates appelés par ceux-ci. Les 2 sont très simples et assez proches.

« /templates/registration/register.html.twig »

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

{% block body %}
    {{form(form)}}
{% endblock %}

« /templates/security/login.html.twig »

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

{% block body %}

    {% if error %}
        <div class="alert alert-danger">
            <ul class="list-unstyled mb-0">
                <li><span class="initialism form-error-icon badge badge-danger">Error</span>
                    <span class="form-error-message">{{ error.messageKey|trans(error.messageData, 'security') }}</span>
                </li>
            </ul>
        </div>
    {% endif %}
    {{form(form)}}
{% endblock %}

Ces 2 templates dépendent de « base.html.twig », c’est à dire le fichier « /templates/base.html.twig », ci dessous je vous en met un exemple. comme vu précédemment il utilise bootstrap 4. Pour aller jusqu’au bout j’ai inclus ici un menu vers différente page de l’application : les page d’inscription/connnexion ou de déconnexion si on est connecté. Ainsi qu’une page d’accueil, et un espace membre et/ou espace admin en fonction du rôle de l’utilisateur connecté. Pour que tout fonctionne il faudra donc créer les controllers pour ces 3 pages ainsi que leur vues, on voit ça juste après, à la fin de l’article.

<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <title>
            {% block title %}
                {% if title is defined %}{{title}}{% else %}Title{% endif %} | Nom du site
            {% endblock %}
        </title>
        {% block stylesheets %}
            {# bootstrap #}
            <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
            {# custom #}
            <link rel="stylesheet" href="{{ asset('assets/css/styles.css') }}">
        {% endblock %}
        <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" />
    </head>
    <body>
    <body id="{% block body_id %}{% endblock %}">

        {% block header %}
            <header id="header" class="bck1 border-bottom">
                <div class="container">
                    <h1>{% if title is defined %}{{title}}{% else %}Header{% endif %}</h1>
                </div>
            </header>
        {% endblock %}

        {% block nav %}
            <nav id="main-nav" class="container navbar navbar-expand-lg navbar-light bg-light border rounded mb-3">
                <ul class="navbar-nav">
                    <li class="nav-item {% if mainNavHome is defined %}active{% endif %}">
                        <a class="nav-link" href="{{path('app_homepage_index')}}">Accueil</a>
                    </li>
                    {% if is_granted('ROLE_USER') %}
                        <li class="nav-item {% if mainNavMember is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_member_index')}}">Espace membre</a>
                        </li>
                    {% endif %}
                    {% if is_granted('ROLE_ADMIN') %}
                        <li class="nav-item {% if mainNavAdmin is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_admin_homepage_index')}}">Espace admin</a>
                        </li>
                    {% endif %}
                </ul>
                <ul class="navbar-nav ml-auto">
                    {% if is_granted('ROLE_USER') %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{path('logout')}}">Déconnexion</a>
                        </li>
                    {% else %}
                        <li class="nav-item {% if mainNavLogin is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('login')}}">Connexion</a>
                        </li>
                        <li class="nav-item {% if mainNavRegistration is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_registration_register')}}">Inscription</a>
                        </li>
                    {% endif %}
                </ul>
            </nav>
        {% endblock %}

        {% block flash %}
            {% for flash_message in app.session.flashbag.get('success') %}
                <div class="container alert alert-success" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('info') %}
                <div class="container alert alert-info" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('warning') %}
                <div class="container alert alert-warning" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('danger') %}
                <div class="container alert alert-danger" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
        {% endblock %}

        <div class="container bck1 padded">
            {% block body %}
            {% endblock %}
        </div>

        {% block footer %}
            <footer id="footer" class="bck1 border-top">
                <div class="container">
                    <h4>Footer</h4>
                </div>
            </footer>
        {% endblock %}

        {% block javascripts %}{% endblock %}
    </body>
</html>

Le layout présenté ci-dessus inclus en plus de bootstrap un fichier css custom permettant d’ajouter nos propres classe de présentation. Voici les quelques lignes ajouté pour ma part dans « /public/assets/css/styles.css »

/*base*/
body{background: #ebeff2;}
.bck1{background: #fff;}

/*generique*/
.padded{padding: 15px;}

/*header*/
header#header {margin-bottom: 20px;padding: 10px;}
header#header h1 {text-align: center;color: #5cb85c;}

/*footer*/
footer#footer{margin-top: 20px;padding: 10px;}

Et pour que nos formulaires s’affichent en suivant les conventions de bootstrap 4 on remercie symfony et sa communauté, une seule ligne de configuration suffit ! Dans « /config/packages/twig.yaml » :

twig:
    form_themes
: ['bootstrap_4_layout.html.twig']

Pour la page d’accueil le simple controller « /src/Controller/HomepageController.php » suffira :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

class HomepageController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('homepage/index.html.twig', ['mainNavHome'=>true, 'title'=>'Accueil']);
    }

}

Rien de spécial pour la vue il suffit de créer le fichier correspandant « /templates/homepage/index.html.twig » de la même façon qu’on l’a fait pour « inscription » et « login », avec le contenu que l’on souhaite.

Le dernier points à voir, c’est la création des « espace membre » et « espace administrateur » limité selon les rôles de l’utilisateur. Il suffit pour cela, en accord avec la configuration faite dans « /config/packages/security.yaml » de faire commencer les routes par « /member » ou « /admin » selon ce que l’on souhaite. Voici pour l’espace utilisateur (fichier « /src/Controller/MemberController.php ») :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

/** @Route("/member") */
class MemberController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('member/index.html.twig', ['mainNavMember'=>true, 'title'=>'Espace Membre']);
    }

}

Exactement pareil pour l’admin, sauf que dans un soucis d’organisation je place le fichier dans un dossier admin, ce qui donne « src/Controller/Admin/HomepageController.php : « 

namespace App\Controller\Admin;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

/** @Route("/admin") */
class HomepageController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('admin/homepage/index.html.twig', ['mainNavAdmin' => true, 'title' => 'Espace Admin']);
    }

}

Je ne rajoute pas le code des « views » car rien de particulier et donc même processus que pour la page d’accueil.

C’est terminé pour ce long article à réutiliser à souhait lors du démarrage d’un projet symfony 4