Gestion des utilisateurs, groupes et roles sous Symfony 2

L’objectif de ce document est de synthétiser les informations pour la mise en place d’une gestion des utilisateurs sous Symfony 2, avec l’attribution de rôles à des groupes d’utilisateurs. Nous verrons comment gérer les permissions au niveau des utilisateurs, et -cerise sur le gateau- nous configurerons une connexion à un annuaire LDAP de test pour évaluer la possibilité d’importer des utilisateurs depuis l’annuaire après une connexion réussie.

Le résultat que l’on souhaite obtenir est un cadre général pour gérer les utilisateurs sur une application typique en backoffice, en particulier on souhaite l’interface d’administration qui permette les tâches de création, modification, désactivation d’utilisateurs et de groupes, ainsi que l’attribution de rôles aux groupes d’utilisateurs.

Groupes ou pas groupes ? Les recommandations sous Symfony2 sont d’utiliser l’héritage de rôle au niveau utilisateur lorsque la situation le permet car elle couvre la plupart des cas et a l’avantage d’être plus performante. Je préfère gérer les rôles au niveau des groupes par habitude de travailler avec des organismes très découpés administrativement, ces découpages en services sont plus facilement gérables en termes de groupes. De plus je préfère masquer le plus possible aux utilisateurs de l’administration la possibilité d’avoir des privilèges individuels, cela évite de gérer les cas d’exception d’utilisateurs privilégiés et permet de clarifier les règles fonctionnelles.

En images, le résultat d’une interface d’administration des utilisateurs et groupes une fois appliquée une mise en page minimaliste.

symfony2-admin-user
symfony2-user-group

Sous Symfony, l’authentification d’un utilisateur par la base de données passe par une entité utilisateur qui implémente une interface définie dans le composant de sécurité.

Pour les besoins de l’application nous pourrions créer notre propre entité utilisateur qui implémente les interfaces utilisateur (AdvancedUserInterface et UserInterface) de Symfony 2, c’est un choix efficace, cependant nous utiliserons une extension populaire qui gère le support des utilisateurs et s’intègre à plusieurs autres extensions très populaires, nous verrons comment personnaliser les classes utilisateur et groupe pour nos propres besoins, comment ajouter des champs et manipuler les entités pour migrer une structure existante par exemple.

L’extension FOSUserBundle ajoute le support pour un système de base de données utilisateur soutenu dans Symfony2. Il fournit les éléments nécessaires à la gestion complète de l’utilisateur au travers des tâches courantes telles que l’enregistrement d’un nouvel utilisateur ou encore la récupération de mot de passe oublié. Voyons comment installer, utiliser et étendre cette extension.

L’extension FOSUserBundle

Le composant de sécurité Symfony fournit un cadre de sécurité flexible qui permet entre autres de charger les utilisateurs d’une base de données, l’utilisateur récupéré est fourni au composant de sécurité pour l’authentification. C’est le scénario que nous allons détailler : les utilisateurs seront stockés à l’aide de l’extension FOSUserBundle au travers de l’ORM Doctrine, ils seront récupérés puis exploités lors du processus d’authentification puis pour la gestion des permissions.

Installation de l’extension

L’installation FOSUserBundle est bien documentée, je vais reprendre rapidement les différentes étapes et fournir quelques explications annexes pour regrouper les sources d’informations nécessaires au démarrage d’un projet.

Télécharger les fichiers source

Démarrez un nouveau projet, soit comme par le passé par l’outil de gestion des dépendances d’applications PHP composer, soit par l’installeur Symfony, comme l’indiquent les dernières recommandations pour la création d’un projet Symfony.

Commencez par exécuter l’utilitaire composer, la commande suivante ajoute une ligne au fichier composer.json où sont référencées les extensions du projet.

composer require friendsofsymfony/user-bundle "~2.0@dev"

Lancez la commande composer de mise à jour pour installer le composant…

composer update

Pensez à enregistrer l’extension dans le noyau de l’application…

<?php
// app/AppKernel.php

public function registerBundles()
{
    $bundles = array(
        // ...
        new FOS\UserBundle\FOSUserBundle(),
        // ...
    );
}

A ce point vous devez disposer des codes sources, vous pouvez vérifier la présence de classes User et Group sous le répertoire /vendor/friendsofsymfony/user-bundle/Model
Ces classes sont les fondements de notre gestion utilisateur, elles possèdent de nombreuses propriétés et méthodes. Il nous reste à définir nos propres classes d’utilisateur et de groupes qui implémentent ces classes : elles comporteront toute la logique métier spécifique à notre application aussi il est recommandé d’isoler ces fonctionnalités au niveau de notre propre logique applicative.

Si vous suivez les recommandations de bonnes pratiques vous devriez normalement disposer d’un seul bundle pour la logique de l’application, ce code n’étant pas destiné à être un distribuable nous allons suivre les bonnes pratiques et créer nos classes sous ce bundle applicatif.

Nous nous bornons à utiliser l’ORM Doctrine pour la persistence en base de données, configurons notre base de données si ça n’est pas déjà fait.

Configurer une base de données

Editez le fichiers config/parameters.yml et modifiez les paramètres pour s’adapter à votre système de gestion de base de données, à titre d’exemple j’utiliserai une base PostgreSQL.

Je fais pointer la configuration sur l’adresse de ma machine qui est valable dans tous les cas d’utilisation, que ce soit une commande de console Doctrine depuis mon IDE ou bien un accès depuis le serveur web.

parameters:
    database_driver: pdo_pgsql
#    database_host: 127.0.0.1
    database_host: 192.168.30.30
    database_port: 5432
    database_name: sandbox
    database_user: sandbox
    database_password: sandbox
    mailer_transport: smtp
    mailer_host: null
    mailer_user: null
    mailer_password: null
    secret: 10d423abcab44c15b3b76f4fb9a3c4117

Attention, le fichier config/parameters.yml est auto-généré par l’utilitaire composer et les paramètres sont écrasés par défaut en cas de mise à jour.

Pour éviter ce comportement éditez le fichier composer.json et mettez en commentaire la ligne de la section post-update : « Incenteev\\ParameterHandler\\ScriptHandler::buildParameters »

Remarquez l’entrée database_driver, vous devrez modifier le fichier config.yml où le pilote renseigné par défaut correspond est pdo_mysql

# Doctrine Configuration
doctrine:
    dbal:
        driver:   "%database_driver%"

Sous PostgreSQL vous pouvez créer un utilisateur sanbox et lui attribuer les permissions sur la base à créer

psql >
CREATE USER sandbox WITH PASSWORD 'sandbox';
CREATE DATABASE sandbox
  WITH OWNER = sandbox
       ENCODING = 'UTF8'
       TABLESPACE = pg_default;
GRANT ALL ON DATABASE sandbox TO sandbox;

Vous devrez éditer le fichier de configuration pg_hba.conf de votre serveur et ajouter les règles d’accès nécessaires.

vi /etc/postgresql/9.4/main/pg_hba.conf
host    all             all             192.168.30.30/32        md5
host    all             all             192.168.0.14/32        md5

L’information host permet d’indiquer que la tentative de connexion doit être de type TCP/IP, la première information all spécifie que toutes les bases de données sont acceptées, la seconde information all permet d’accepter tous les noms d’utilisateur de la base de données, enfin la plage d’adresses permet de spécifier les machines client acceptées, saisissez votre adresse IP (l’hôte), suivie du masque CIDR /32 (255.255.255.255) afin d’accepter une seule machine hôte, le dernier paramètre md5 indique la méthode d’authentification, vous devrez vous connecter par mot de passe crypté md5

Attention à configurer PostgreSQL pour écouter les connexions entrantes sur les adresses nécessaires, en développement on peut écouter toutes les adresses.

vi /etc/postgresql/9.4/main/postgresql.conf

listen_addresses = '*'

Créer nos propres classes utilisateur et groupe

Créons l’entité utilisateur, il suffit de créer une classe src/AppBundle/Entity/User.php avec le contenu par défaut indiqué dans la documentation dans le cas Doctrine ORM. Dans notre cas nous utilisons des groupes d’utilisateur, cette relation est crée dans l’entité utilisateur.

<?php
// src/AppBundle/Entity/User.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Group")
     * @ORM\JoinTable(name="fos_user_user_group",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
     * )
     */
    protected $groups;

    public function __construct()
    {
        parent::__construct();
        // your own logic
    }
}

Le bundle permet d’associer des groupes aux utilisateurs, ce qui permet simplement de regrouper des collections de rôles. Les rôles associés à un groupe seront attribués aux utilisateurs qui appartiennent au groupe.

Créons l’entité groupe

// src/AppBundle/Entity/Group.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\Group as BaseGroup;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_group")
 */
class Group extends BaseGroup
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
     protected $id;
}

Configurer le bundle

L’étape suivante consiste à configurer la sécurité. Dans l’immédiat recopiez l’exemple fourni dans la documentation pour le fichier security.yml en prenant soin de conserver l’entrée de pare-feu nommée dev qui permet de désactiver la sécurité pour l’outil de profilage, poursuivez avec les étapes 5, 6, et 7 de la documentation, nous reviendrons plus loin sur la sécurité.

Ces étapes ne posent pas de difficulté et ne nécessitent pas d’adaptation par défaut pour Doctrine ORM :

Editez le fichier de configuration pour ajouter la configuration spécifique à FOSUserBundle sous app/config/config.yml

# app/config/config.yml
fos_user:
    db_driver: orm # other valid values are 'mongodb', 'couchdb' and 'propel'
    firewall_name: main
    user_class: AppBundle\Entity\User
    group:
        group_class: AppBundle\Entity\Group

Editez le fichier de routage pour importer les routes spécifiques à FOSUserBundle sous app/config/routing.yml (ici la configuration avancée permettra de désactiver les routes dont on a pas besoin, par défaut le documentation indique le fichier de routes global)

# app/config/routing.yml
fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"

fos_user_profile:
    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
    prefix: /profile

fos_user_register:
    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
    prefix: /register

fos_user_resetting:
    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
    prefix: /resetting

fos_user_change_password:
    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
    prefix: /profile

fos_user_group:
    resource: "@FOSUserBundle/Resources/config/routing/group.xml"
    prefix: /group

Tout cela est bien beau, mais à vrai dire c’est assez confus ! Quelles sont donc ces routes, que nous réservent-elles ?

Symfony 2 permet de visualiser et débuguer les routes, à cette fin il propose une commande qui va permettre de voir chaque route de l’application, idéal dans notre cas pour découvrir les possibilités du bundle.

console debug:router
[router] Current routes
 Name                              Method   Scheme Host Path                              
 ...     
 homepage                          ANY      ANY    ANY  /                                 
 fos_user_security_login           GET|POST ANY    ANY  /login                            
 fos_user_security_check           POST     ANY    ANY  /login_check                      
 fos_user_security_logout          GET      ANY    ANY  /logout                           
 fos_user_profile_show             GET      ANY    ANY  /profile/                         
 fos_user_profile_edit             GET|POST ANY    ANY  /profile/edit                     
 fos_user_registration_register    GET|POST ANY    ANY  /register/                        
 fos_user_registration_check_email GET      ANY    ANY  /register/check-email             
 fos_user_registration_confirm     GET      ANY    ANY  /register/confirm/{token}         
 fos_user_registration_confirmed   GET      ANY    ANY  /register/confirmed               
 fos_user_resetting_request        GET      ANY    ANY  /resetting/request                
 fos_user_resetting_send_email     POST     ANY    ANY  /resetting/send-email             
 fos_user_resetting_check_email    GET      ANY    ANY  /resetting/check-email            
 fos_user_resetting_reset          GET|POST ANY    ANY  /resetting/reset/{token}          
 fos_user_change_password          GET|POST ANY    ANY  /profile/change-password
...         
Done.

Le résultat est assez parlant, les routes sont définies pour le formulaire de connexion, le post du formulaire, la déconnection, la vue du profil utilisateur, l’édition du profil utilisateur, l’enregistrement d’un utilisateur, le changement de mot de passe, et ce qui semble être des méthodes de gestion interne.

Nous pouvons même investiguer d’avantage à l’aide d’une autre commande Symfony, demandons plus d’information sur le formulaire de connexion.

console router:match /login
Route "fos_user_security_login" matches

[router] Route "fos_user_security_login"
Name         fos_user_security_login
Path         /login
Path Regex   #^/login$#s
Host         ANY
Host Regex   
Scheme       ANY
Method       GET|POST
Class        Symfony\Component\Routing\Route
Defaults     _controller: FOSUserBundle:Security:login
Requirements NO CUSTOM
Options      compiler_class: Symfony\Component\Routing\RouteCompiler
Done.

Retenons pour le moment vers quel contrôleur pointe la route.

Il reste à mettre à jour le schéma de base de données pour prendre en compte notre classe utilisateur, cette opération va créer la table ainsi qu’une séquence associée

console doctrine:schema:update --force

Vous y êtes ? Vous pouvez désormais vérifier que vous êtes en mesure d’accéder au formulaire de login !

http://192.168.30.30/login

Bien, comment se connecter !? En toute logique il nous faut créer un premier utilisateur…

A ce moment là, la documentation officielle vous invite à consulter une liste de liens pour aller plus loin, et à vrai dire si vous êtes pressé, ce qui est probablement le cas si vous débutez un projet réel, c’est à ce moment là où vous serez tenté d’abandonner le bundle face à la montagne de travail qui se profile à l’horizon ! N’en faites rien, et voyons comment aller plus loin sans trop charger la mule. Mais d’abord il est temps d’expliquer le pourquoi de la configuration de sécurité qui nous est demandée lors de l’installation.

Mise en place du composant sécurité et formulaire d’authentification

La sécurité sous Symfony est un système qui permet de déterminer l’identité d’un utilisateur et de contrôler ses autorisations d’accès.

En ce qui concerne notre besoin le processus sécuritaire consiste à créer des règles de pare-feu pour le processus d’authentification par formulaire web et des règles de contrôles d’accès pour déterminer si l’utilisateur peut accéder aux ressources. Il faut donc notamment autoriser tout le monde à accéder au formulaire d’authentification.

Configurer le pare feu

Lorsqu’un utilisateur va chercher à accéder à l’application, le processus de sécurité se met en route : il fait correspondre l’url que l’utilisateur cherche à accéder avec les règles configurées dans le pare-feu pour déterminer si l’utilisateur doit être authentifié.

Une règle est composée d’un masque d’expression régulière et d’instructions qui permettent de déterminer si l’utilisateur doit ou ne doit pas être authentifié. Il y a donc mise en correspondance de l’url avec l’expression régulière pour savoir si la règle doit être vérifiée. Chaque règle est testée jusqu’à ce que le système trouve une correspondance ou qu’il ait parcouru l’ensemble des règles : la première correspondance établie est donc celle qui permet de déterminer si l’utilisateur doit être authentifié.

Sous Symfony2 la sécurité se configure dans le fichier app/config/security.yml, c’est l’un des fichiers que l’on vous a demandé de paramétrer pour FOSUserBundle.

Revoyons les règles de pare-feu :

# app/config/security.yml
security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: ROLE_ADMIN

    providers:
        fos_userbundle:
            id: fos_user.user_provider.username

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/
            form_login:
                provider: fos_userbundle
                csrf_provider: security.csrf.token_manager # Use form.csrf_provider instead for Symfony <2.4

            logout:       true
            anonymous:    true

    access_control:
        - { path: ^/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin/, role: ROLE_ADMIN }

Dans l’exemple précédent, nous définissons deux règles. Les règles sont évaluées par ordre de priorité.

  • La règle nommée dev permet de retirer le profileur web Symfony et les ressources du pare-feu lors de la phase de développement. Elle fait correspondre les url qui contiennent les mots _profiler ou _wdt et désactive la sécurité pour ces url
  • La règle main permet de faire correspondre toutes les urls, c’est le même pare-feu qui va sécuriser l’application et les routes définies dans le bundle. L’entrée form_login indique à Symfony de sécuriser l’accès par une authentification par formulaire, tout utilisateur qui n’est pas authentifié sera redirigé vers la page de connexion. Les chemins vers le formulaire de connexion sont en temps normaux indiqués dans le firewall par deux routes : la route /login qui affiche le formulaire, la route /login_check qui permet de valider le formulaire lorsqu’il est soumis par l’utilisateur, cependant avec l’utilisation du bundle il suffit de déclarer que le bundle est fournisseur de ce service, ce qui est réalisé par l’instruction de l’entrée provider.

Le système vérifie les droits d’accès aux ressources, ce qui peut se traduire par : le système vérifie par rapport au rôle utilisateur s’il est autorisé à accéder à l’url sur laquelle il tente de se rendre.

Le processus d’autorisation est paramétrable sur les urls, sous la section nommée access_control. Cette section access_control est l’endroit où vous spécifiez les informations d’identification nécessaires pour que les utilisateurs tentent d’accéder à des zones spécifiques de votre application.
Le pare-feu est configuré pour accepter les accès anonymes, et seul le role anonyme est requis pour les routes qui permettent à l’utilisateur d’accéder au formulaire de connexion et autres routes d’inscription, les requêtes qui débutent par /admin nécessiteront par contre de posséder le rôle ROLE_ADMIN

Lorsque l’utilisateur tente d’accéder à une page, le système de sécurité fait correspondre comme l’url que l’utilisateur cherche à accéder avec les règles configurées dans les contrôles d’accès.

Une règle est composée d’un masque d’expression régulière et d’instructions qui permettent de déterminer le rôle minimal requis pour accéder à la ressource.

Le rôle est récupéré depuis l’objet utilisateur de l’utilisateur authentifié, ou bien est renseigné à un rôle utilisateur anonyme sinon.

Pour finir, notez l’héritage de rôle permet à un rôle administrateur d’accéder aux ressources d’un rôle utilisateur, et au rôle super administrateur d’accéder par effet de cascade au ressources des rôles administrateur et utilisateur.

Le système de sécurité a connaissance des chemins vers le formulaire de connexion, vers la vérification effectuée après soumission du formulaire et vers la déconnexion au travers du bundle déclaré comme fournisseur. En fait le formulaire de connexion est la responsabilité de l’application (que l’on délègue à FOSUserBundle), sa soumission par contre est traitée par le système de sécurité. Cela implique de respecter une norme simple (par défaut) pour le nommage des éléments de formulaire, le composant de sécurité ne requiert que deux champs nommés par défaut _username et _password. Sachant cela vous pouvez ouvrir et étudier le formulaire de connexion, nous connaissons les routes, et nous avons vu précédemment quel contrôleur correspondait à la connexion.

Vérifiez par vous même, ouvrez le contrôleur puis le fichier de template correspondant au rendu : \vendor\friendsofsymfony\user-bundle\Resources\views\Security\login.html.twig

Vous pouvez également jeter un œil au contrôleur qui s’occupe de la vérification utilisateur, il ne fait que jeter une exception car cette action est normalement interceptée par le système de sécurité.

console router:match /login_check --method POST

Le système de sécurité a également besoin de savoir comment sont encodés les mots de passe lorsqu’il fait la comparaison du mot de passe saisi dans le formulaire de connexion avec le mot de passe encodé récupéré dans l’objet utilisateur. C’est pourquoi il faut paramétrer l’encodeur par défaut qui sera utilisé avec l’entité utilisateur, ce qui est réalisé dans le fichier security.yml où bcrypt est déclaré conformément aux recommandations Symfony.

Lorsqu’au travers du formulaire de connexion l’utilisateur fournit un identifiant et un mot de passe, le système de sécurité utilise les fournisseurs d’utilisateurs configurés pour retourner des objets utilisateur pour un nom d’utilisateur donné.

Nous avons vu comment sont renseignée les règles de pare feu pour indiquer si une url donnée est soumise à authentification. Nous avons également vu la mise en place d’un système d’authentification par formulaire de connexion et nous savons que le système de sécurité authentifie les utilisateurs, dans notre cas au travers de la table d’utilisateurs. Ok ? peut-être maintenant allons nous enfin nous connecter non ? et ce formulaire de connexion il ne va pas rester comme ça, si ? Patience…

Paramétrer le pare feu pour se souvenir de l’utilisateur

Symfony2 possède une fonctionnalité qui permet à un utilisateur de faire persister ses crédits alloués à la connexion au delà d’une simple session. Lors de la connexion l’utilisateur peut choisir de se reconnecter automatiquement au travers d’une option remember_me sous forme de case à cocher dans le formulaire de connexion. Si l’utilisateur coche la case se souvenir de moi, et qu’il ferme sa session, la prochaîne fois qu’il se rend sur l’application il sera automatiquement connecté grace à un cookie de rappel.

Pour utiliser cette fonctionnalité il faut indiquer dans le pare feu de l’activer au niveau du formulaire et configurer l’option.

...
            form_login:
                provider: fos_userbundle
                csrf_provider: security.csrf.token_manager # Use form.csrf_provider instead for Symfony <2.4
                remember_me: true
                default_target_path: /
            logout:       true
            anonymous:    true
            remember_me:
                key:      %secret%
                lifetime: 604800 # 1 week in seconds
                path:     /
                domain:   ~ # Defaults to the current domain from $_SERVER
#                secure:   true
                httponly: true
...

Consultez la documentation Symfony2 pour paramétrer l’option.

Dans cet exemple on se souviendra de l’utilisateur pour une durée d’une semaine, le cookie sera associé à tout le site, le domaine est récupéré depuis les variables d’environnement de PHP, et on demande de n’accéder au cookie que par protocole HTTP.

Post Installation

Oui, c’est fait j’ai installé la solution, partons à sa découverte.

Les indicateurs d’état dans l’entité utilisateur

Si vous ouvrez la classe utilisateur \vendor\friendsofsymfony\user-bundle\Model\User.php, vous risquez d’être surpris par le nombre d’indicateurs booléens qui reflètent l’état d’un utilisateur (en standard dans Symfony), c’est un vrai casse tête, et la documentation est introuvable.

Faisons le point : vous ne pouvez pas supprimer un utilisateur qui appartient à une relation, à cause des contraintes d’intégrité référentielles, parfait.
Mais si vous souhaitez désactiver l’utilisateur pour faire en sorte qu’il ne puisse plus se connecter, faut il couper le fil vert ou le fil noir ? Couper les 2 fils en appuyant sur le bouton vert ? Il vous reste 19 secondes…

L’entité utilisateur dispose des indicateurs enabled, locked, expired, credentialsExpired.

Ils sont vérifiés dans cet ordre par le processus d’authentification du composant de sécurité Symfony.

  • Enabled : L’indicateur prend la valeur vrai lorsque l’utilisateur a été vérifié, c’est à dire quand il est propriétaire de son e-mail car il a reçu l’email de confirmation de création de profil et suivi la procédure de vérification.
  • Locked : Cet indicateur permet de vérouiller le compte, l’utilisateur ne peut plus se connecter ou réinitialiser son mot de passe, il ne peut plus manipuler son compte
  • Expired : Un utilisateur est une denrée perrissable, enfin son compte. Un compte expiré permet d’inactiver l’utilisateur : il ne peut plus se connecter. Cela permet de conserver le compte dormant pour archive, et si besoin d’ouvrir à nouveau le compte et forcer l’utilisateur à se valider à nouveau
  • CredentialsExpired : Cet indicateur permet de vérifier après connexion si le certificat est expiré, si c’est le cas l’application devrait forcer l’utilisateur à modifier son mot de passe

Muni de ces informations, vous pouvez désactiver l’utilisateur comme il vous plaira en jouant sur un ou plusieurs indicateurs. Ma préférence va au blocage du compte par verrou qui permet si besoin de réactiver un compte archivé pour consultation tout en évitant de manipuler une date d’expiration

Créer un premier utilisateur

Pour tester le formulaire de connexion, créons un utilisateur. Heureusement FOSUserBundle possède un jeu de commandes interactives.

Créé un utilisateur avec le rôle de super administrateur

console fos:user:create admin --super-admin

Créé un utilisateur avec le seul rôle d’utilisateur en précisant son e-mail et son mot de passe en ligne de commande

console fos:user:create someone someone@somewhere.com somepass

Si tout va bien vous êtes redirigé vers la page d’accueil et un coup d’œil au profileur Symfony vous informe enfin sous quel utilisateur vous êtes connecté !

Si vous retournez sur la page de connexion vous pouvez tenter une déconnexion et une nouvelle connexion avec l’option se souvenir de moi.

Personnaliser la page de connexion

FosUserBundle fournit par défaut des gabarits de mise en pages pour un certain nombre de situations, il est nécessaire de personnaliser ces gabarits pour chaque situation que vous allez mettre en place : voyons comment surcharger les deux gabarits d’agencement et de connexion propres à la page de connexion.

Comme (trop) souvent il existe plusieurs façons de faire, nous nous contenterons de la méthode qui consiste à définir un gabarit du même nom dans le répertoire de ressources de notre bundle applicatif.

Si vous suivez les bonnes pratiques Symfony2, les répertoire app/Resources/ est l’endroit où vous stockez les gabarits et fichiers de traduction pour l’application. Vous pouvez créer un sous répertoire et son arborescence à cet endroit pour surcharger les ressources par défaut de FOSUserBundle.

Créez un répertoire app/Resources/FOSUserBundle/views/ dans lequel vous copierez le fichier vendor\friendsofsymfony\user-bundle\Resources\views\layout.html.twig

Vous pouvez alors personnaliser le fichier que vous venez de copier-coller, dans l’immédiat une balise de titre sera suffisante à la compréhension !

Créez un répertoire app/Resources/FOSUserBundle/views/Security dans lequel vous copierez le fichier vendor\friendsofsymfony\user-bundle\Resources\views\Security\login.html.twig

Vous pouvez alors personnaliser le bloc où s’affiche le formulaire de connexion…

Regardez ce dernier gabarit, FOSUserBundle prend soin d’avoir un rendu international, il utilise des clefs pour le textes à traduire selon la variable de lieu (locale) configurée sous Symfony2. Si vous n’utilisez pas la traduction vous pouvez changer les étiquettes des champs de formulaire directement dans le gabarit, pour ma part j’ai recopié les fichiers FOSUserBundle.fr.yml et validators.fr.yml sous le répertoire app/Resources/FOSUserBundle/translations.

(Note : vider le cache n’a pas suffit à prendre en compte les modifications des fichiers de traduction, cela a nécessité de basculer la variable locale dans me fichier config.yml, puis de la restaurer à sa valeur précédente)

Nous avons vu comment personnaliser le formulaire de connexion, à ce point FOSUserBundle répond sans autres modifications que cosmétiques au besoin de connexion, voyons maintenant comment créer une interface pour l’administration des profils.

Administrer les groupes et les utilisateurs

Je ne suis pas partisan des solutions plus ou moins toutes prêtes d’administration pour plusieurs raisons, allant de la maintenance dans le temps, à la mise en oeuvre en passant par les capacités de personnalisation. Ce n’est qu’un ressentit mais j’ai l’impression à la fois de perdre du temps -qui plus est, du temps de configuration et de résolution de problèmes en tous genres que je considère désagréable- et surtout le contrôle de mon application car les mécanismes de fonctionnement sont souvent opaques.

Ce que je veux dire simplement c’est que l’on se passera d’un SonataAdminBundle pour se contenter des capacités de génération en ligne de commande de Doctrine. C’est un choix, je tenterai prochainement de donner des crédits à l’approche SonataAdminBundle car d’autres l’on fait avec plus ou moins de succès et surtout devant la popularité de la solution, on peut retrouver cette approche dans le cadre d’une maintenance applicative.

Personnaliser l’entité utilisateur à notre besoin

En l’état, FOSUserBundle permet d’ajouter un groupe, de lister les groupes, il permet à un utilisateur de s’enregistrer, et d’autres choses qui sont malheureusement limitées par rapport à une interface d’administration réelle.

Justement, prenons l’exemple d’un besoin client qui implique de modifier la table d’utilisateurs, de ressortir les informations et d’intervenir après connexion : le client souhaite comptabiliser le nombre de connexions de l’utilisateur et enregistrer sa date de première et dernière connexion.

Mettre à jour la classe utilisateur

Ajoutons ces informations dans l’entité utilisateur, en fait il existe déjà une propriété nommée lastLogin dans la classe du bundle, ajoutons les autres propriétés à notre classe User.php :

    /**
     * @ORM\Column(type="integer", length=6, options={"default":0})
     */
    protected $loginCount = 0;
    
    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $firstLogin;   

Insérons les méthodes pour décompter les connexions et enregistrer les informations d’horodatage à la connexion.

console doctrine:generate:entities AppBundle:User

Le décompte de connexions est initialisé par défaut au niveau de la base de données comme configuré dans les options d’annotations Doctrine, par contre il faut ajouter cette initialisation également au niveau de la déclaration de la propriété pour pouvoir créer les utilisateurs depuis le code sans renseigner la propriété.

Mettons à jour la base de données

console doctrine:cache:clear-metadata 
console doctrine:schema:update --force
Alimenter la base avec des utilisateurs factices

Nous pouvons tester la manipulation de notre classe d’utilisateur au travers de l’alimentation de la base en utilisateurs factices.

FOSUserBundle utilise un gestionnaire d’utilisateurs auquel on accède au travers d’un service. Toutes les opérations sur les instances d’utilisateurs devraient être exécutées par ce gestionnaire afin d’assurer l’indépendance au stockage.

L’utilisation des capacités de Doctrine à générer des données pour alimenter une base de test requiert une extension.

composer require "doctrine/doctrine-fixtures-bundle"

Modifiez le fichier AppKernel.php pour inclure le bundle en environnement de développement.

$bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();

Créez un fichier /AppBundle/DataFixtures/ORM/UserFixtures.php

<?php namespace AppBundle\DataFixtures\ORM; use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\OrderedFixtureInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; //use AppBundle\Entity\User; //use AppBundle\Entity\Group; class UserFixtures extends AbstractFixture implements OrderedFixtureInterface, ContainerAwareInterface { /** * @var ContainerInterface */ private $container; /** * {@inheritDoc} */ public function setContainer(ContainerInterface $container = null) { $this->container = $container;
    }
    
    public function getOrder() {
        return 0;
    }

    public function load(ObjectManager $manager) {

        $userManager = $this->container->get('fos_user.user_manager');
        
        $user = $userManager->createUser();
        
        $user
            ->setUsername('someguy')
            ->setEmail('john.doe@example.com')
            ->setFirstLogin(\DateTime::createFromFormat('j-M-Y', '15-Feb-2009'))
            ->setEnabled(true);
        
        $user->setPlainPassword('somepass');

        // Equivalent à :
        
//        $encoder = $this->container
//                ->get('security.encoder_factory')
//                ->getEncoder($user)
//            ;
//        $user->setPassword($encoder->encodePassword('somepass', $user->getSalt()));

        
        $userManager->updateUser($user);
    }
    
}

Vous pouvez lancer la génération des données en mode ajout

console doctrine:fixtures:load --append

Plusieurs choses sont importantes ici :

  • On récupère le gestionnaire d’utilisateur depuis le conteneur qui est passé à la classe automatiquement grace à l’implémentation de l’interface ContainerAwareInterface
  • Nous pouvons encoder le mot de passe avec l’encodeur qui est définit dans le composant de sécurité, il est également récupéré depuis le conteneur, ou bien reposer sur les écouteurs d’événements qui mettent à jour le mot de passe après enregistrement
  • La mise à jour de l’utilisateur effectue les opération de flush Doctrine automatiquement, ce comportement est paramétrable par passage de booléen
  • Par défaut un écouteur d’événement met à jour les champs canoniques (email et username) après enregistrement, ainsi que le traitement de cryptage du mot de passe fourni en clair (plainPassword), ce comportement peut être désactivé
Evénements et écoutes de connexion

Les actions de comptabilisation et d’enregistrement des dates de connexion s’effectuent après une authentification réussie, or le système d’authentification redirige l’utilisateur automatiquement après connexion, sans nous laisser la main pour des traitements de mise à jour !

Sous Symfony ce traitement peut se faire en déclarant un écouteur sur un événement connexion.

Ouvrez le fichiers AppBundle\Resources\config\services.yml

Un écouteur d’événement est un service. Le premier fichier de configuration Symfony permet d’enregistrer notre service qui prendra en charge les traitements après connexion dans le conteneur de services.

services: 
    login_listener:
        class: 'AppBundle\Listener\LoginListener'
        arguments: ['@fos_user.user_manager']
        tags:
            - { name: 'kernel.event_listener', event: 'security.interactive_login' }
            - { name: 'kernel.listener', event: 'fos_user.security.implicit_login' }

Nous déclarons la classe LoginListener qui correspond à l’écouteur.

A l’aide de l’entrée arguments on indique à Symfony d’injecter le service de gestion des utilisateurs du bundle dont notre propre service dépend.

On peut obtenir des informations sur les services de l’application à l’aide d’une commande :

console container:debug

et le détail !

console debug:container fos_user.user_manager

La dernière entrée tags indique de quelle façon sera utilisé le service. Ici il prend la valeur kernel.event_listener qui indique d’écouter les événements propagés par le framework et déclenche la méthode correspondante de la classe de notre service lorsqu’un événement security.interactive_login se produit, c’est à dire lors d’une connexion réussie.

Ce n’est pas tout, FOSUserBundle ajoute une couche supplémentaire qui autorise les connexions implicites, il propage ses propres événements dont ce mode de connexion.

Pour ne pas vieillir prématurément à la recherche de solutions à des problèmes qui devraient être documentés et ne le sont pas on peut s’inspirer des propres écouteurs du bundle, ouvrez le fichier /EventListener/LastLoginListener.php, la propriété qui enregistre la date de dernière connexion est déjà traitée par un système d’écouteur !

Il reste à écrire les traitements dans notre propre écouteur. Créez le fichier AppBundle\Listener\LoginListener.php

ATTENTION, j’ai eu la mauvaise idée d’activer un cache REDIS au niveau des requêtes Doctrine, avec un cache le traitement POST connexion ne fonctionne pas !?
<?php namespace AppBundle\Listener; use FOS\UserBundle\FOSUserEvents; use FOS\UserBundle\Event\UserEvent; use FOS\UserBundle\Model\UserManagerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; use Symfony\Component\Security\Http\SecurityEvents; class LoginListener implements EventSubscriberInterface { protected $userManager; public function __construct(UserManagerInterface $userManager) { $this->userManager = $userManager;
    }
    
    public static function getSubscribedEvents()
    {
        return array(
            FOSUserEvents::SECURITY_IMPLICIT_LOGIN => 'onImplicitLogin',
            SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
        );
    }

    protected function updateUser($user) {
        
        if (!$user->getLoginCount())
            $user->setFirstLogin(new \DateTime());
        
        $user->setLoginCount((int) $user->getLoginCount() + 1);
        
        $this->userManager->updateUser($user);
    }
    
    public function onImplicitLogin(UserEvent $event)
    {
        $this->updateUser($event->getUser());
    }
    
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) {
        $user = $event->getAuthenticationToken()->getUser();
        //if ($user instanceof UserInterface)
            $this->updateUser($user);
    }
}

Je peux enfin fermer cette parenthèse et passer à l’interface d’administration.

Générer un CRUD à l’aide de la console

Ce que l’on souhaite c’est administrer les utilisateurs et les groupes au tra ers de plusieurs écrans : listes, formulaires d’ajout et modification.

La commande doctrine:generate:crud génère les opérations CRUD sur la base d’une entité Doctrine, elle repose sur l’extension SensioGeneratorBundle et les gabarits sont inclus par défaut (sous \vendor\sensio\generator-bundle\Sensio\Bundle\GeneratorBundle\Resources\skeleton\crud). L’aide en ligne de la commande nous informe que l’on peut étendre ces gabarits pour les personnaliser en les copiant sous un répertoire de notre bundle, ce qui est certainement ce qu’il faudrait faire si personne n’avait fait encore mieux avant !

Pour notre application nous souhaitons nous appuyer sur des valeurs sûres et utiliser Twitter Bootstrap 3 et Font Awesome pour nous aider dans la mise en page css, cela tombe bien car le générateur par défaut a été étendu par un bundle qui lui apporte quelques fonctionnalités et surtout intègre par défaut Twitter Bootstrap 3 et Font Awesome, alors utilisons le, il se nomme
PUGXGeneratorBundle. Nous installerons conjointement un bundle qui nous permettra d’ajouter les fonctionnalités de pagination au CRUD, il s’agit de KnpPaginatorBundle, un bundle qui permet d’ajouter les fonctionnalités de filtre nommé LexikFormFilterBundle, et également un bundle qui permet de générer des données factices aléatoires : BazingaFakerBundle.

Après génération du CRUD, nous pourrons appliquer un filtre sur la liste des utilisateurs qui, après modification du code, pourra inclure une sélection de groupes.

symfony2-filtre

Commençons par télécharger les sources…

composer require pugx/generator-bundle:2.4.* --dev
composer require knplabs/knp-paginator-bundle
composer require "lexik/form-filter-bundle"
composer require "willdurand/faker-bundle"

Editez le fichier AppKernel.php pour enregistrer les bundles au sein de l’application.

En environnement de test uniquement pour les générateurs de CRUD et de données factices.

$bundles[] = new PUGX\GeneratorBundle\PUGXGeneratorBundle();
$bundles[] = new Bazinga\Bundle\FakerBundle\BazingaFakerBundle();

Et dans tous les cas pour la pagination et le filtre.

new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(),
new Lexik\Bundle\FormFilterBundle\LexikFormFilterBundle(),

Les bundles installés, il faut désormais les configurer ! Et toujours aucune ligne de code tapée, patience…

Configurons en premier le générateur de données factices, il faut d’abord autoriser la configuration du bundle dans app/config/config_dev.yml

bazinga_faker: ~

Ensuite comme on utilisera ce bundle en dev avec l’orm Doctrine, avec des jeux de données en français on va paramétrer l’orm et la variable de localisation, puis l’entité utilisateur à hauteur de 20 utilisateurs, ce qui nous permettra de tester comment se comporte la pagination.

bazinga_faker:
    orm: doctrine
    locale: fr_FR
    entities:
        AppBundle\Entity\User:
            number: 20

Nous pouvons lancer la génération des entités paramétrées et vérifier le résultat en base de données.

console faker:populate

La librairie PHP Faker permet de personnaliser les formats de champs, pour aller au plus simple on s’est contenté ici du comportement par défaut sans paramétrer les champs, cependant pour éviter des déconvenues nous allons altérer la table une fois les données crées pour renseigner les données de type tableau.

console doctrine:query:sql "UPDATE fos_user SET roles = 'a:0:{}' where roles = 'N;'"

Nous disposons alors de données exploitables !

faker-users

Configurons maintenant le bundle de filtre. Il faut ajouter les blocs de gabarits utilisés dans les formulaires de filtre aux ressources de formulaire du moteur Twig. Editez le fichier app/config/config.yml et ajoutez ces lignes sous la configuration twig :

twig:
    form:
        resources:
            - LexikFormFilterBundle:Form:form_div_layout.html.twig

Nous utilisons PostgreSQL aussi nous pouvons préciser dans la configuration si les recherches sont sensibles à la casse de caractères, le dernier paramètre n’est pas clair pour moi à l’heure où j’écris ces ligne, je le met dans la configuration pour pouvoir tester le fonctionnement lorsque l’on change ce paramètre pour ses valeurs prédéfinies.

lexik_form_filter:
    force_case_insensitivity: false
    where_method: ~  # null | and | or

Le générateur de CRUD apporte une nouvelle commande similaire à la commande doctrine:generate:crud

console pugx:generate:crud --help

L’usage nous informe que la commande ne génère par défaut que les actions de liste et d’aperçu, les actions d’ajouts, édition et suppressions sont conditionnées par l’option –with-write

Nous utiliseront également la pagination et le tri par colonnes sur l’action de liste des utilisateurs (ce ne sera pas nécessaire sur les groupes, leur nombre étant très limité), ces fonctions sont conditionnées par les options –use-paginator et –with-sort.

Paramétrons la pagination ! Editez le fichier config.yml et ajoutez une section. Ici je n’ai modifié par rapport à la documentation que le gabarit de pagination pour utiliser le gabarit prêt à l’emploi du bundle pour Twitter Bootstrap 3.

knp_paginator:
    page_range: 5                      # default page range used in pagination control
    default_options:
        page_name: page                # page query parameter name
        sort_field_name: sort          # sort field query parameter name
        sort_direction_name: direction # sort direction query parameter name
        distinct: true                 # ensure distinct results, useful when ORM queries are using GROUP BY statements
    template:
        pagination: KnpPaginatorBundle:Pagination:twitter_bootstrap_v3_pagination.html.twig 
        sortable: KnpPaginatorBundle:Pagination:sortable_link.html.twig # sort link template

Le gabarit nécessite une traduction, pour cela éditez le fichier du domaine messages, par ex. /app/Resources/translations/messages.fr.yml

Next:       Suivant
Previous:   Précédent

Si ce n’est pas déjà fait, assurez vous d’incorporer les feuilles de styles et fichiers JavaScripts pour la mise en page. Ce fichier app\Resources\views\base.html.twig fait appel aux ressources depuis un CDN, cela sera suffisant pour commencer !

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>{% block title %}Welcome!{% endblock %}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        {% block stylesheets %}
            <link href="//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" rel="stylesheet">
            <link href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css" rel="stylesheet">
        {% endblock %}
        <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" />
    </head>
    <body>

<nav class="navbar navbar-fixed-top">
            <!-- put your nav bar here -->
        </nav>


<div class="container">
            {% block body '' %}
        </div>

        {% block javascripts %}
            <script src="//code.jquery.com/jquery-2.1.1.js"></script>
            <script src="//netdna.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
        {% endblock %}
    </body>
</html>

Les gabarits générés supportent l’internationalisation lorsqu’elle est activée, pourquoi pas, créons un fichier app/Resources/translations/admin.fr.yml

"%entity% creation":             "Création - %entity%"
"%entity% edit":                 "Modification - %entity%"
"%entity% list":                 "Liste - %entity%"
Actions:                         Actions
Back to the list:                Retour à la liste
Confirm delete:                  Confirmer la suppression
Create:                          Créer
Create a new entry:              Nouveau
Delete:                          Supprimer
"Do you want to proceed?":       "Voulez-vous poursuivre?"
Edit:                            Modifier
edit:                            modifier
Filter:                          Filtrer
No:                              Non
Reset filters:                   Annuler les filtres
show:                            voir
"Show/hide filters":             "Afficher/masquer les filtres"
this procedure is irreversible:  cette procédure est irréversible
Yes:                             Oui
You are about to delete an item: Vous êtes sur le point de supprimer un élément

Nous utilisons Bootstrap 3 et la distribution de Symfony 2 utilise fort heureusement des thèmes pour le rendu des formulaires sous ce framework css, ce qui nous facilite la tâche et nous économise beaucoup de travail.

Ouvrez le répertoire \vendor\symfony\symfony\src\Symfony\Bridge\Twig\Resources\views\Form, vous devriez trouver deux thèmes Twitter Bootstrap 3, l’un pour la mise en page horizontale, l’autre pour la mise en page par défaut. (respectivement bootstrap_3_horizontal_layout.html.twig et bootstrap_3_layout.html.twig)

(Documentation pour personnaliser les formulaires Symfony 2)

Je suis sûr que je personnaliserai rapidement quelque peu ces gabarits, je préfère d’emblée dupliquer ces fichiers sous le bundle applicatif. Copiez le répertoire \vendor\symfony\symfony\src\Symfony\Bridge\Twig\Resources\views\Form vers \src\AppBundle\Resources\views\Form

Tout est prêt, il reste à générer les actions pour nos entités !

En premier commençons par les groupes, un groupe n’a que très peu de propriétés : un identifiant, un nom et un tableau qui contient des rôles. De plus je n’imagine pas de cas d’utilisations avec des dizaines de groupes, ce qui nous permet d’ignorer les fonctionnalités de tri et de filtre et rend ce cas le plus simple pour appréhender le fonctionnement du générateur.

console pugx:generate:crud \
--entity=AppBundle:Group \
--layout=::base.html.twig \
--theme=AppBundle:Form:bootstrap_3_layout.html.twig \
--with-write \
--route-prefix="admin/groups"

La commande est assez parlante en soi, on souhaite générer les actions CRUD de l’entité qui décrit un groupe, pour la génération on précise le gabarit de mise en page et le thème à appliquer aux formulaire, on souhaite générer les actions d’édition et on déclare que nos routes seront préfixées par /admin/groups pour toutes les actions générées pour cette entité.

Le générateur a créé un contrôleur nommé GroupController, des templates (edit, index, new et show) sous /Resources/views/group, un type de formulaire sous Form/Type et des tests /Tests/GroupControllerTest.php

Remarquez que j’ai utilisé dans l’immédiat le gabarit principal de l’application pour la mise en page, on aurait bien sûr pu utiliser une mise en page spécifique pour l’administration ou le bundle (ex. –layout=AppBundle::layout.html.twig ou –layout=::admin.html.twig)

Vous pouvez vous rendre sur l’url par défaut de l’administration pour lister tous les groupes : http://192.168.30.30/admin/groups. La première impression, c’est que l’interface est très propre sans avoir rien fait à ce niveau ! Si vous avez créé un groupe (FOSUserBundle a défini une route pour cela, vous vous rappelez ?) vous pouvez voir votre groupe listé avec pour seul champ l’identifiant, où sont donc passés le libellé et les rôles ? Eh bien si l’on regarde de près la class Form/Type/GroupType, on constate que la méthode chargée de construire un formulaire est bien vide, l’héritage de la classe FOS\UserBundle\Model\Group n’a pas été pris en compte, c’est assez décevant mais pas insurmontable.

Bien, amis bricoleurs, sortez vos boîtes à outils.

Faites une sauvegarde de vos entités, il suffit de les déplacer temporairement quelques part.

C’est fait ? Bien ! Nous allons les régénérer depuis la base de données, ce qui aura pour effet de fusionner toutes les propriétés qui ont servi à construire les tables et relations.

Récupérez les informations de mise en correspondance des entités aux tables :

console doctrine:mapping:import  "AppBundle" xml

Ceci a pour effet de créer deux fichiers xml qui structurent l’information de nos entités, renommez ces fichiers.

AppBundle/Resources/config/doctrine/FosGroup.orm.xml devient Group.orm.xml
AppBundle/Resources/config/doctrine/FosUser.orm.xml devient User.orm.xml

Ouvrez ces fichiers et renommez les occurrences des entités FosGroup et FosUser pour faire disparaître le préfixe Fos. Vous pouvez alors ré-générer les entités :

console doctrine:generate:entities AppBundle

La suite vous la connaissez, il suffit de reprendre là où l’on a abandonné et générer le CRUD pour chaque classe, je vous donne la commande de génération de l’entité utilisateur, on rajoute le filtre et la pagination sur cette commande.

console pugx:generate:crud \
--entity=AppBundle:User \
--layout=::base.html.twig \
--theme=AppBundle:Form:bootstrap_3_layout.html.twig \
--with-write --with-filter --with-sort --use-paginator \
--route-prefix="admin/users"

Une fois que c’est fait, supprimez les fichiers de structure xml et remplacez les entités User et Group par les sauvegardes que vous aviez réalisé. Rendez vous sur l’url d’administration, ce n’est pas fini mais c’est déjà beaucoup plus complet.

En l’état nous avons encore un certain nombre de problèmes à régler, et beaucoup de personnalisations à effectuer.

Le temps passe et tout documenter est particulièrement long, aussi je ne m’attarderai pas sur chaque problème et sur chaque modification, en l’état l’application soulève des exceptions sur de nombreux cas d’utilisation, et j’ai fait des choix personnels qui ne vous conviendront sans doute pas dans le cadre de vos projets. Je vais quand même fournir l’essentiel, et expliquer quelques points pour vous faire gagner du temps !

Premièrement, les gabarits pour chaque écran. Ici il n’y a pas de difficulté, vous êtes en mesure de personnaliser les gabarits vous même en une poignée d’heures. Je n’ai fait que traduire les propriétés des champs en français, supprimer les éléments que je n’utilise pas, ajouter le nom des groupes dans la liste des utilisateurs et ajouter l’état de la connexion utilisateur dans le gabarit principal.

Pour ajouter les groupes à la liste des utilisateurs pensez à désactiver le tri sur la colonne au niveau de l’entête, c’est inutile et soulève une exception.

AppBundle\Resources\views\user\index.html.twig

...

<th scope="col">{{ thead('user', 'username', "Nom d'utilisateur") }}</th>


<th scope="col">{{ thead('user', 'email', 'E-mail') }}</th>


<th scope="col">{{ thead('user', 'locked', 'Verrouillé') }}</th>


<th scope="col">Groupes</th>


<th scope="col">{{ thead('user', 'id', 'Identifiant') }}</th>


<th scope="col">{{ 'Actions'|trans({}, 'admin') }}</th>

...

<td><a href="{{ path('admin_users_show', {id: user.id}) }}">{{ user.username }}</a></td>


<td>{{ user.email }}</td>


<td>{% if user.locked %}<i class="fa fa-check-square-o"></i>{% else %}<i class="fa fa-square-o"></i>{% endif %}</td>


<td>{{ user.groups|join(', ') }}</td>


<td>{{ user.id }}</td>

Remarquez que je n’affiche pas les rôles, c’est délibéré car je souhaite que les utilisateurs finaux lambda n’aient pas conscience des permissions au travers de rôles mais uniquement au travers de leur appartenance à un groupe. Le seul endroit où j’affiche les rôles c’est lors de l’édition d’un groupe.

Pour indiquer à l’utilisateur s’il est connecté et sous quel profil j’ai modifié le gabarit principal de l’application.

        {% if is_granted("IS_AUTHENTICATED_REMEMBERED") %}
            Connecté en tant que {{ app.user.username }}
            -
            <a href="{{ path('fos_user_security_logout') }}">Déconnexion</a>
        {% else %}
            <a href="{{ path('fos_user_security_login') }}">Connexion</a>
        {% endif %}

Pour finir avec les gabarits, sachez que j’ai laissé pour le moment la possibilité de supprimer les utilisateurs et les groupes. Je n’ai pas d’autres relations utilisateur mais il sera préférable de désactiver l’utilisateur plutôt que de le supprimer. Pour supprimer un groupe il faut qu’il n’y ai plus de relations utilisateur/ groupe définie, j’ai donc rajouté un traitement pour supprimer les relations au niveau de l’action de suppression avant de supprimer le groupe. (je dois améliorer ce point et déplacer le code dans un dépôt)

            // @TODO refactoriser dans le modèle
            $em = $this->getDoctrine()->getManager();
            $users = $group->getUsers();
            foreach ($users as $user)
                $user->getGroups()->removeElement($group);
            $em->flush();

            $this->get('fos_user.group_manager')->deleteGroup($group);

Pourquoi je rentre dans ces détails ? Pour parler de la méthode getUsers() car par défaut la relation qui permet de récupérer les utilisateurs depuis un groupe n’est pas dans la documentation du bundle FOSUserBundle. Voici l’entité groupe finale (remarquez l’initialisation du nom à vide dans le constructeur)

<?php // src/AppBundle/Entity/Group.php namespace AppBundle\Entity; use FOS\UserBundle\Model\Group as BaseGroup; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity * @ORM\Table(name="fos_group") */ class Group extends BaseGroup { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @ORM\ManyToMany(targetEntity="AppBundle\Entity\User", mappedBy="groups") * */ protected $users; public function __construct($name = '', $roles = array()) { $this->name = $name;
        $this->roles = $roles;
    }

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

    function getUsers() {
        return $this->users;
    }

}

Bien, et cette relation se déclare également dans l’entité utilisateur ! (inversedBy) Voici la classe utilisateur avec son lot de changements.

<?php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 * @UniqueEntity(fields="usernameCanonical", errorPath="username", message="fos_user.username.already_used", groups={"Default", "Registration", "Profile"})
 * @UniqueEntity(fields="emailCanonical", errorPath="email", message="fos_user.email.already_used", groups={"Default", "Registration", "Profile"})
 */
class User extends BaseUser {

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

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Group", inversedBy="users")
     * @ORM\JoinTable(name="fos_user_user_group",
     *      joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *      inverseJoinColumns={@ORM\JoinColumn(name="group_id", referencedColumnName="id")}
     * )
     */
    protected $groups;

    /**
     * @ORM\Column(type="integer", length=6, options={"default":0})
     */
    protected $loginCount = 0;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $firstLogin;

    public function __construct() {
        parent::__construct();
        $this->groups = new ArrayCollection();
    }

    /**
     * Set loginCount
     *
     * @param integer $loginCount
     *
     * @return User
     */
    public function setLoginCount($loginCount) {
        $this->loginCount = $loginCount;
        return $this;
    }

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

    /**
     * Set firstLogin
     *
     * @param \DateTime $firstLogin
     *
     * @return User
     */
    public function setFirstLogin($firstLogin) {
        $this->firstLogin = $firstLogin;
        return $this;
    }

    /**
     * Get firstLogin
     *
     * @return \DateTime
     */
    public function getFirstLogin() {
        return $this->firstLogin;
    }

    function getEnabled() {
        return $this->enabled;
    }

    function getLocked() {
        return $this->locked;
    }

    function getExpired() {
        return $this->expired;
    }

    function getExpiresAt() {
        return $this->expiresAt;
    }

    function getCredentialsExpired() {
        return $this->credentialsExpired;
    }

    function getCredentialsExpireAt() {
        return $this->credentialsExpireAt;
    }

    function setSalt($salt) {
        $this->salt = $salt;
    }

    public function setPassword($password) {
        if ($password !== null)
            $this->password = $password;
        return $this;
    }

    function setGroups(Collection $groups = null) {
        if ($groups !== null)
            $this->groups = $groups;
    }

    public function setRoles(array $roles = array()) {
        $this->roles = array();
        foreach ($roles as $role)
            $this->addRole($role);
        return $this;
    }

    public function hasGroup($name = '') {
        return in_array($name, $this->getGroupNames());
    }

}

Au nombre des changements, la création de getters, la modifications de la méthode setGroups() pour pouvoir recevoir des paramètres null, l’initialisation des groupes dans le constructeur avec un ArrayCollection.

Parmi les changements, il a fallut prendre en compte le caractère obligatoire de saisie du mot de passe en création, qui devient facultatif en modification (et du caractère facultatif du vérrouillage en création). Ce dernier point est possible grâce au passage d’une option lors de la construction du formulaire :

// AppBundle\Controller\UserController.php
    /**
     * Displays a form to edit an existing User entity.
     *
     * @Route("/{id}/edit", name="admin_users_edit", requirements={"id"="\d+"})
     * @Method("GET")
     * @Template()
     */
    public function editAction(User $user)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

Et dans le type :

<?php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolverInterface; class UserType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('username', null, array('label' => "Nom d'utilisateur"))
            ->add('email', null, array('required' => false, 'label' => 'E-mail'))
            ->add('plainPassword', 'repeated', array(
                'type' => 'password',
                'invalid_message' => 'Les mots de passe doivent être identiques.',
                'required' => $options['passwordRequired'],
                'first_options'  => array('label' => 'Mot de passe'),
                'second_options' => array('label' => 'Répétez le mot de passe'),
            ))
            ->add('groups', 'entity', array(
                'label' => 'Groupes',
                'multiple' => true,
                'expanded' => true,
                'required' => false,
                'class' => 'AppBundle\Entity\Group'))
        ;
        if ($options['lockedRequired']) {
            $builder->add('locked', null, array('required' => false, 
                'label' => 'Vérouiller le compte'));
        }
        
    }

    /**
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User',
            'passwordRequired' => true,
            'lockedRequired' => false,
        ));
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'user';
    }
}

Remarquez que l’on ne manipule que le mot de passe saisi plein texte, et non pas le mot de passe crypté.

The Symfony validator is enabled by default, but you must explicitly enable annotations if you’re using the annotation method to specify your constraints:

FosUserBundle utilise une configuration xml pour la validation des formulaires, l’unicité des noms d’utilisateur et d’email sont définis dans un groupe nommé Registration localisé dans le fichier vendor\friendsofsymfony\user-bundle\Resources\config\storage-validation\orm.xml

Par défaut le formulaire généré par le CRUD n’applique pas la validation sur ce groupe, il ne prend que le groupe par défaut, il faut donc soit rajouter manuellement le groupe soit gérer ces contraintes dans le groupe par défaut. Depuis la version 2.5 de Symfony la validation spécifique aux méthodes de stockage est désactivée par défaut mais pour cette dernière solution nous allons utiliser les annotations, en effet nous n’utilisons l’utilisateur que dans le cadre de l’ORM et le format xml manque d’aspect pratique. (Vous pouvez bien entendu surcharger les fichiers xml sous votre propre répertoire de ressources si vous préférez)

Sous Symfony 2.7 la validation par xml et yaml est activée par défaut mais on doit explicitement autoriser les annotations si l’on souhaite utiliser cette méthode pour indiquer nos contraintes. On doit l’activer dans la configuration (app/config/config.yml)

framework:
    validation: { enable_annotations: true }

On peut alors ajouter des annotations à la classe utilisateur pour gérer par défaut l’unicité des deux champs. (L’activation par défaut ne permettra pas que les règles fonctionnent aussi lorsque l’on cherchera à se connecter en LDAP et que l’utilisateur sera ajouté après connexion réussie à la base d’utilisateurs de l’application, ce cas doit être géré differemment)

...
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
...
/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 * @UniqueEntity(fields="usernameCanonical", errorPath="username", message="fos_user.username.already_used", groups={"Default", "Registration", "Profile"})
 * @UniqueEntity(fields="emailCanonical", errorPath="email", message="fos_user.email.already_used", groups={"Default", "Registration", "Profile"})
 */
class User extends BaseUser {
...

Notez également l’enregistrement de l’utilisateur dans le contrôleur… par défaut le générateur utilise l’ORM mais FOSUserBundle va plus loin et emploi un gestionnaire d’utilisateur. Bien sûr on pourrait utiliser l’ORM car l’application n’est pas destinée à autre chose, mais autant faire les choses selon les règles. (On autorise par défaut le compte utilisateur créé, qui n’est pas autorisé par défaut à moins d’avoir confirmé son e-mail)

    /**
     * Creates a new User entity.
     *
     * @Route("/create", name="admin_users_create")
     * @Method("POST")
     * @Template("AppBundle:User:new.html.twig")
     */
    public function createAction(Request $request)
    {
        $user = new User();
        $form = $this->createForm(new UserType(), $user);
        if ($form->handleRequest($request)->isValid()) {
            $user->setEnabled(true);
            $userManager = $this->get('fos_user.user_manager');
            $userManager->updateUser($user);

            return $this->redirect($this->generateUrl('admin_users_show', array('id' => $user->getId())));
        }

        return array(
            'user' => $user,
            'form'   => $form->createView(),
        );
    }

En modification cela devient :

    /**
     * Displays a form to edit an existing User entity.
     *
     * @Route("/{id}/edit", name="admin_users_edit", requirements={"id"="\d+"})
     * @Method("GET")
     * @Template()
     */
    public function editAction(User $user)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

    /**
     * Edits an existing User entity.
     *
     * @Route("/{id}/update", name="admin_users_update", requirements={"id"="\d+"})
     * @Method("PUT")
     * @Template("AppBundle:User:edit.html.twig")
     */
    public function updateAction(User $user, Request $request)
    {
        $editForm = $this->createForm(new UserType(), $user, array(
            'action' => $this->generateUrl('admin_users_update', array('id' => $user->getId())),
            'method' => 'PUT',
            'passwordRequired' => false,
            'lockedRequired' => true
        ));
        if ($editForm->handleRequest($request)->isValid()) {
            $userManager = $this->get('fos_user.user_manager');
            $userManager->updateUser($user);
            
            return $this->redirect($this->generateUrl('admin_users_edit', array('id' => $user->getId())));
        }
        $deleteForm = $this->createDeleteForm($user->getId(), 'admin_users_delete');

        return array(
            'user' => $user,
            'edit_form'   => $editForm->createView(),
            'delete_form' => $deleteForm->createView(),
        );
    }

Et maintenant un sujet épineux encore mal documenté, et seulement en anglais : comment je configure une relation many-to-many dans un filtre ? Oui, c’est beau les bundles qui fonctionnent dans 90% des cas simples mais n’avez vous pas l’impression d’être 100% du temps dans les 10% de cas plus complexes ?

LexikFormFilterBundle peut fonctionner avec une relation many-to-many utilisateur / groupe, le filtre est créé pour la liste des utilisateurs, j’ai restreint au minimum les propriétés. Les recherches sont cumulatives par défaut, c’est à dire que si l’on renseigne un nom d’utilisateur et un email, la recherche se traduira par une condition de ET logique. Les recherches supportent l’opérateur SQL LIKE, de ce fait on peut effectuer une recherche partielle à l’aide du caractère ‘%’ (ex. ‘sam%’ retournera tous les noms d’utilisateurs qui commencent par les caractères ‘sam’)

L’option magique s’appelle ‘apply_filter’, n’oubliez pas la directive use! Et le voici en action :

<?php // \AppBundle\Form\Type\UserFilterType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolverInterface; use Lexik\Bundle\FormFilterBundle\Filter\Query\QueryInterface; class UserFilterType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('username', 'filter_text', array('label' => "Nom d'utilisateur"))
                ->add('email', 'filter_text', array('label' => 'E-mail'))
                ->add('enabled', 'filter_boolean', array('label' => 'Autorisé'))
                ->add('groups', 'filter_entity', array(
                    'label' => 'Groupes',
                    'class' => 'AppBundle\Entity\Group',
                    'expanded' => true,
                    'multiple' => true,
                    'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
                        $query = $filterQuery->getQueryBuilder();
                        $query->leftJoin($field, 'm');
                        // Filter results using orWhere matching ID
                        foreach ($values['value'] as $value) {
                            $query->orWhere($query->expr()->in('m.id', $value->getId()));
                        }
                    },
                ))
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function setDefaultOptions(OptionsResolverInterface $resolver) {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\User',
            'csrf_protection' => false,
            'validation_groups' => array('filter'),
            'method' => 'GET',
        ));
    }

    /**
     * {@inheritdoc}
     */
    public function getName() {
        return 'user_filter';
    }
}

(A ce stade vous voulez certainement me payer une bière pour toutes ces journées économisées, mais je m’égare.)

Récapitulons, nous avons un CRUD fonctionnel pour la gestion des utilisateurs et des groupes, qu’il reste juste à personnaliser au cas par cas.
L’administration permet de sélectionner des rôles dans les groupes, et de relier les utilisateurs aux groupes, les utilisateurs possèdent donc les rôles cumulés des groupes auxquels ils appartiennent, ils possèdent leurs propres rôles définis en base et qui peuvent être promus en ligne de commande (que l’on administre pas dans les écrans) et ils bénéficient également de rôles par héritage tels que définis dans le composant de sécurité.

Par contre en l’état tout profil connecté avec un role ROLE_ADMIN peut tout modifier dans l’interface d’administration, nous allons voir comment gérer les autorisations et appliquer des restrictions sur les pages d’administration mais en premier faisons un peu de ménage.

Supprimer les routes inutilisées

En dehors des routes définies pour la sécurité, nous n’utilisons plus pour nos besoins d’administration FOSUserBundle.

fos_user_security:
    resource: "@FOSUserBundle/Resources/config/routing/security.xml"

#fos_user_profile:
#    resource: "@FOSUserBundle/Resources/config/routing/profile.xml"
#    prefix: /profile
#
#fos_user_register:
#    resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
#    prefix: /register
#
#fos_user_resetting:
#    resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"
#    prefix: /resetting
#
#fos_user_change_password:
#    resource: "@FOSUserBundle/Resources/config/routing/change_password.xml"
#    prefix: /profile
#
#fos_user_group:
#    resource: "@FOSUserBundle/Resources/config/routing/group.xml"
#    prefix: /group

Gérer les droits

Nous avons une base d’utilisateurs en mesure de se connecter, et comme désormais on est en mesure de gérer les groupes et les rôles associés, tous ces utilisateurs n’ont pas les mêmes permissions, et ce que vous ferez des autorisations sera à étudier au cas par cas. Voyons les principales conditions d’application.

Afficher un menu selon le contexte

Notre interface d’administration est assez peu pratique sans un menu, et il est facile d’en construire un en dur directement dans le rendu html, mais cela s’avère beaucoup plus pratique de gérer les autorisations lorsque le menu est construit dynamiquement et nous utiliserons à cette fin un bundle dédié à cela, KnpMenuBundle, pour lequel on personnalisera le rendu pour Twitter Bootstrap 3.

Commencez par télécharger les sources

composer require "knplabs/knp-menu-bundle"

Déclarez le bundle au sein de l’application dans le fichier AppKernel.php

...
new Knp\Bundle\MenuBundle\KnpMenuBundle(),
...

Le bundle fournit une méthode sous twig qui effectue le rendu du menu. Nous pouvons passer le gabarit personnalisé pour Twitter Boostrap 3 à cette méthode. Voici un extrait d’un fichier de mise en page pour l’interface d’administration :

//backend.html.twig
...
{% block header %}
    <!-- Header -->

<div id="header">

<div class="color-line">
        </div>


<div id="logo" class="light-version">
            <span>
                Administration
            </span>
        </div>


<nav role="navigation">

<div class="header-link hide-menu"><i class="fa fa-bars"></i></div>


<div class="small-logo">
                <span class="text-primary">Administration</span>
            </div>


<div id="navbar" class="navbar-collapse collapse">
                {{ knp_menu_render('AppBundle:MenuBuilder:buildMainMenu', {'currentClass': 'active', 'template': ':Menu:knp_menu.html.twig'}) }}
                {{ knp_menu_render('AppBundle:MenuBuilder:buildUserMenu', {'currentClass': 'active', 'template': ':Menu:knp_menu.html.twig'}) }}
            </div>

        </nav>

    </div>

{% endblock %}
...

Vous pouvez constater que l’on utilise deux fois la méthode, le premier appel sert à générer le menu principal qui s’affichera de gauche à droite en se décalant vers le centre après le rendu du logo. Ce menu servira à gérer les entités utilisateur et groupe. Le second appel quand à lui permet de générer un menu qui s’affichera à droite, ce menu est destiné à afficher les informations sur l’utilisateur connecté et lui permettre de se déconnecter ou se connecter sous un autre profil.

Vous pouvez créer le fichier qui correspond au gabarit, dans notre exemple il est situé sous app\Resources\views\Menu\knp_menu.html.twig, il reprend le travail précieux d’un internaute – merci à lui !-, à consulter.

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

{% block item %}
{% import "knp_menu.html.twig" as macros %}
{% if item.displayed %}
    {%- set attributes = item.attributes %}
    {%- set is_dropdown = attributes.dropdown|default(false) %}
    {%- set divider_prepend = attributes.divider_prepend|default(false) %}
    {%- set divider_append = attributes.divider_append|default(false) %}

{# unset bootstrap specific attributes #}
    {%- set attributes = attributes|merge({'dropdown': null, 'divider_prepend': null, 'divider_append': null }) %}

    {%- if divider_prepend %}
        {{ block('dividerElement') }}
    {%- endif %}

{# building the class of the item #}
    {%- set classes = item.attribute('class') is not empty ? [item.attribute('class')] : [] %}
    {%- if matcher.isCurrent(item) %}
        {%- set classes = classes|merge([options.currentClass]) %}
    {%- elseif matcher.isAncestor(item, options.depth) %}
        {%- set classes = classes|merge([options.ancestorClass]) %}
    {%- endif %}
    {%- if item.actsLikeFirst %}
        {%- set classes = classes|merge([options.firstClass]) %}
    {%- endif %}
    {%- if item.actsLikeLast %}
        {%- set classes = classes|merge([options.lastClass]) %}
    {%- endif %}

{# building the class of the children #}
    {%- set childrenClasses = item.childrenAttribute('class') is not empty ? [item.childrenAttribute('class')] : [] %}
    {%- set childrenClasses = childrenClasses|merge(['menu_level_' ~ item.level]) %}

{# adding classes for dropdown #}
    {%- if is_dropdown %}
        {%- set classes = classes|merge(['dropdown']) %}
        {%- set childrenClasses = childrenClasses|merge(['dropdown-menu']) %}
    {%- endif %}

{# putting classes together #}
    {%- if classes is not empty %}
        {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
    {%- endif %}
    {%- set listAttributes = item.childrenAttributes|merge({'class': childrenClasses|join(' ') }) %}

{# displaying the item #}
    <li{{ macros.attributes(attributes) }}>
        {%- if is_dropdown %}
            {{ block('dropdownElement') }}
        {%- elseif item.uri is not empty and (not item.current or options.currentAsLink) %}
            {{ block('linkElement') }}
        {%- else %}
            {{ block('spanElement') }}
        {%- endif %}
{# render the list of children#}
        {{ block('list') }}
    </li>


    {%- if divider_append %}
        {{ block('dividerElement') }}
    {%- endif %}
{% endif %}
{% endblock %}

{% block dividerElement %}
{% if item.level == 1 %}

<li class="divider-vertical"></li>

{% else %}

<li class="divider"></li>

{% endif %}
{% endblock %}

{% block linkElement %}
	<a href="{{ item.uri }}"{{ macros.attributes(item.linkAttributes) }}>
		{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
		{{ block('label') }}
	</a>
{% endblock %}

{% block spanElement %}
	<span>{{ macros.attributes(item.labelAttributes) }}>
		{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
		{{ block('label') }}
	</span>
{% endblock %}

{% block dropdownElement %}
    {%- set classes = item.linkAttribute('class') is not empty ? [item.linkAttribute('class')] : [] %}
    {%- set classes = classes|merge(['dropdown-toggle']) %}
    {%- set attributes = item.linkAttributes %}
    {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
    {%- set attributes = attributes|merge({'data-toggle': 'dropdown'}) %}
    <a href="#"{{ macros.attributes(attributes) }}>
    	{% if item.attribute('icon') is not empty  %}
    		<i class="{{ item.attribute('icon') }}"></i> 
    	{% endif %}
    	{{ block('label') }} 
    	<b class="caret"></b>
    </a>
{% endblock %}

{%- block label %}
{{ item.label|trans(
    item.getExtra('translation_params', {}),
    item.getExtra('translation_domain', 'messages')
) }}
{%- endblock %}

La construction des menus s’opère dans la classe déclarée dans le gabarit lors de l’appel à la méthode de rendu, ici vous avez à créer un fichier AppBundle\Menu\MenuBuilder.php

<?php namespace AppBundle\Menu; use Knp\Menu\FactoryInterface; use Symfony\Component\DependencyInjection\ContainerAware; class MenuBuilder extends ContainerAware { public function buildMainMenu(FactoryInterface $factory, array $options) { $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders');

        $menu->addChild('entities', array(
                    'label' => 'Gestion'
                ))
                ->setAttribute('dropdown', true)
                ->setAttribute('icon', 'fa fa-list');

        $menu['entities']
                ->addChild('users', array(
                    'route' => 'admin_users',
                    'label' => 'Utilisateurs'));

        $menu['entities']
                ->addChild('groups', array(
                    'route' => 'admin_groups',
                    'label' => 'Groupes'));

        return $menu;
    }

    public function buildUserMenu(FactoryInterface $factory, array $options) {
        $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders navbar-right');

        $context = $this->container->get('security.context');
        if ($context->isGranted('IS_AUTHENTICATED_REMEMBERED')) {

            $menu->addChild('profile', array(
                        'label' => $context->getToken()->getUser()->getUsername()))
                    ->setAttribute('dropdown', true)
                    ->setAttribute('icon', 'fa fa-user');

            $menu['profile']->addChild('Se déconnecter', array('route' => 'fos_user_security_logout'))
                    ->setAttribute('icon', 'fa fa-unlink');
            $menu['profile']->addChild("Se connecter sous un autre profil", array('route' => 'fos_user_security_login'))
                    ->setAttribute('icon', 'fa fa-link');
        }

        return $menu;
    }

}

Le principe est simple, nous avons deux méthodes, une par menu. KnpMenuBundle permet de construire des menus sous forme d’arborescence d’items, un peu à la façon dont on manipulerai un fichier xml par le code. Le rendu html du menu sera une liste html non ordonnée, elle débutera par un élément balisé UL et contiendra des balises LI. Premièrement on créé un élément qui sera à la racine de l’arborescence, auquel on fixe des attributs, dans notre cas la classe pour appliquer un style au rendu :

        $menu = $factory->createItem('root');
        $menu->setChildrenAttribute('class', 'nav navbar-nav no-borders');

donnera


<ul class="nav navbar-nav no-borders">

Ce premier élément est créé sous forme d’objet, et on va pouvoir lui ajouter des éléments : d’abord un élément nommé ‘Gestion’ puis des sous éléments à cet élément nommés ‘Groupes’ et ‘Utilisateurs’. Le code est suffisamment explicite, remarquez cependant dans le second cas comment l’on gère l’affichage du nom d’utilisateur.

On utilise simplement le composant de sécurité au travers d’un service, le contexte de sécurité possède une fonction isGranted() qui permet de déterminer si l’utilisateur de l’application possède une ou plusieurs attributions. Dans notre exemple le rôle IS_AUTHENTICATED_REMEMBERED est assigné automatiquement à l’utilisateur qui s’est authentifié au travers d’un cookie, il permet de déterminer si l’utilisateur est connecté ou non.

Dans l’immédiat nous ne définissons pas plus de restrictions au niveau du menu, nous verrons ultérieurement la sécurité. Ce que nous voulons au final c’est que l’utilisateur ne voit que les entrées du menu auxquelles il a accès, et cela implique à ce niveau d’utiliser des constructions if / then / else à maintenir en marge des règles d’accès définies dans le système de routage.

Complétez l’installation du bundle par la déclaration d’un service qui permettra lors de la construction du menu de déterminer quelle entrée du menu correspond à la page courante. Cette entrée active possède une classe css supplémentaire pour démarquer le style dans le rendu du menu.

Le service se paramètre sous le fichier services.yml

services: 
    ...
    menu.voter.request:
        class: AppBundle\Menu\RequestVoter
        arguments: [ @request_stack ]
        tags:
            - { name: knp_menu.voter }

Et il est donc défini dans la classe AppBundle\Menu\RequestVoter.php

<?php namespace AppBundle\Menu; use Knp\Menu\ItemInterface; use Knp\Menu\Matcher\Voter\VoterInterface; use Symfony\Component\HttpFoundation\RequestStack; class RequestVoter implements VoterInterface { private $requestStack; public function __construct(RequestStack $requestStack) { $this->requestStack = $requestStack;
    }

    public function matchItem(ItemInterface $item) {
        $request = $this->requestStack->getCurrentRequest();

        if ($item->getUri() === $request->getRequestUri()) {
            // URL's completely match
            return true;
        } else if ($item->getUri() !== $request->getBaseUrl() . '/' && substr($request->getRequestUri(), 0, strlen($item->getUri())) === $item->getUri()) {
            // URL isn't just "/" and the first part of the URL match
            return true;
        }

        return null;
    }

}

Vérifier les droits au niveau du moteur de rendu Twig

Comment tester si l’utilisateur possède les droits d’accès dans un gabarit ?
Twig offre la possibilité d’accéder au contexte de sécurité, il pourvoit en extension la fonction is_granted() que l’on peut employer à cette fin.

{# L'utilisateur est-il connecté ? #}
{% if app.user and is_granted("IS_AUTHENTICATED_REMEMBERED") %}
    {# L'utilisateur est connecté ! #}
{% else %}
    {# L'utilisateur n'est pas connecté ! #}
{% endif %}

L’exemple ci dessus permet de vérifier si l’utilisateur est connecté, bien entendu on peut vérifier n’importe quelle attribution grace à cette méthode. Le test d’existence de l’utilisateur permet d’éviter des pages cassées en environnement de production. (Ce test permet de vérifier si l’utilisateur est authentifié, il accepte les cas où il est authentifié grâce à un cookie « se souvenir de moi »)

{{ is_granted(role, object, field) }}

Un objet peut être passé en option au système de vote.

Sachant cela il est facile de désactiver le bouton de suppression d’un utilisateur s’il n’a pas les attributions ROLE_SUPER_ADMIN

                            {% if app.user and is_granted("ROLE_SUPER_ADMIN") %}
                            <button class="btn btn-danger" type="submit"><i class="fa fa-trash-o"></i> {{ 'Delete'|trans({}, 'admin') }}</button>
                            {% endif %}

Continuez, nous allons voir comment gérer l’interdiction au niveau du contrôleur…

Fixer les permissions d’accès au niveau des contrôleurs et des routes

L’utilisateur est stocké en session, dans le contexte de l’application et il peut tout d’abord s’avérer judicieux de le récupérer dans une action.

Avant Symfony 2.6

// Vérifier que l'utilisateur est authentifié puis...
$user = $this->container->get('security.context')->getToken()->getUser();

Depuis Symfony 2.6

    // Vérifier que l'utilisateur est authentifié puis...
    $user = $this->getUser();

    // c'est un raccourcit pour ceci...
    $user = $this->get('security.token_storage')->getToken()->getUser();

Toujours vérifier avant d’utiliser l’objet utilisateur que l’utilisateur est connecté, sinon l’objet pourrait être vide !

    if (!$this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) {
        throw $this->createAccessDeniedException();
    }

    $user = $this->getUser();

Les permissions se vérifient au niveau du composant de sécurité ! Avant Symfony 2.6 on peut vérifier qu’un utilisateur possède une attribution particulière dans une action en récupérant le service ‘security.context’, grâce à la méthode isGranted(), depuis Symfony 2.6 un nouveau service ‘security.authorization_checker’ a été introduit à cette fin.

Avant :

   use Symfony\Component\Security\Core\Exception\AccessDeniedException;
   // ...
    $securityContext = $this->container->get('security.context');
    if (!$securityContext->isGranted('CONTENT_EDIT')) {
        throw new AccessDeniedException('Vous ne possédez pas les droits suffisants pour accéder à la page!');
    }

Après :

$this->denyAccessUnlessGranted('CONTENT_EDIT', null, 'Vous ne possédez pas les droits suffisants pour accéder à la page!');

Dans les deux cas une exception AccessDeniedException() est soulevée, qui déclenche une réponse HTTP 403.

En détail dans la documentation Symfony.

Cette méthode requiert du code et interfère quelques peu avec des lignes de code plus utiles. Heureusement, il est possible de sécuriser les contrôleurs directement au niveau des annotations grâce au bundle SensioFrameworkExtraBundle, cette méthode est préférable pour la lisibilité et elle est recommandée dans les meilleures pratiques Symfony2. (Attention, suivant vos besoins elle induit un fort couplage des contrôleurs au Framework, et introduit un peu de magie)

// ...
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;

/**
 * @Security("has_role('ROLE_ADMIN') and is_granted('POST_SHOW')")
 */
public function helloAction($name)
{
    // ...
}

La première partie de l’expression has_role() diffère de is_granted() dans le sens où ce dernier passe tout le processus de votes pour vérifier l’attribution alors que le premier vérifie si l’utilisateur possède un rôle.

Les annotations permettent d’utiliser des expressions complexes où sont manipulés objets utilisateurs et paramètres de la route, il est possible de se référer à la documentation pour plus d’exemples.

A présent il nous est facile interdire la suppression d’un utilisateur au niveau du contrôleur afin que seul le super administrateur puisse utiliser cette fonctionnalité.

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
...
    /**
     * Deletes a User entity.
     *
     * @Security("has_role('ROLE_SUPER_ADMIN')")
     * @Route("/{id}/delete", name="admin_users_delete", requirements={"id"="\d+"})
     * @Method("DELETE")
     */
    public function deleteAction(User $user, Request $request)
    {
    ...
    }

Gérer les permissions grâce au système d’électeurs

Sous Symfony2 il est possible de vérifier les permissions d’accès en utilisant un module qui gère les ACL mais c’est une solution très lourde pour des cas d’utilisation très rares et dans la majorité des situations l’emploi d’un système plus simple est préférable, ce système consiste à mettre en place des élections auprès d’objets dédiés à vérifier des attributions, sous Symfony ces objets s’appellent des Voters. Documentation sur les électeurs.

Partons d’un contrôleur pour comprendre l’utilisation d’électeurs dans la détermination des permissions.

$this->denyAccessUnlessGranted('ROLE_USER', null, 'Vous ne possédez pas les droits suffisants pour accéder à la page!');

Lorsque l’on vérifie la permission (ROLE_USER dans l’exemple, cela s’applique également a is_granted()), Symfony2 ne se contente pas de vérifier si l’utilisateur possède ce rôle mais il passe l’attribut ROLE_USER à un certain nombre d’électeurs (objets Voters) et demande à chacun de voter si oui ou non l’utilisateur devrait se voir accorder la permission ROLE_USER.

Symfony2 possède 3 électeurs par défaut :

  • RoleVoter vote seulement si l’attribut commence par les caractères ‘ROLE_’ et vérifie si l’utilisateur possède exactement cet attribut en rôle
  • RoleHierarchyVoter vote seulement si l’attribut commence par les caractères ‘ROLE_’ et vérifie si l’utilisateur possède cet attribut en utilisant la hiérarchie de rôles (l’héritage de rôles est défini dans la configuration de la sécurité, fichier security.yml)
  • AuthenticatedVoter vote seulement si l’attribut est IS_AUTHENTICATED_FULLY, IS_AUTHENTICATED_REMEMBERED ou IS_AUTHENTICATED_ANONYMOUSLY

 

Si l’on passe un autre type d’attribut à is_granted(), aucun de ces électeurs ne votera et la méthode retournera une valeur booléenne faux, à moins d’avoir créé nos propres électeurs qui répondent à ce nouvel attribut. Il est également possible de passer un second argument à is_granted (qui remplace la valeur null dans l’exemple plus haut), qui est n’importe quel type d’objet. Chaque électeur se voit passer l’objet et peut prendre sa décision d’accès en s’appuyant sur des données spécifiques à l’objet : cette capacité de dire que tel utilisateur a accès en édition à tel objet permet d’établir des règles d’attribution complexes.

Au niveau de l’administration, le mieux est de ne pas mélanger les genres, un administrateur doit être responsable, et je n’ai pas de cas d’utilisation… mais maintenant vous pouvez à peu près tout faire !

Synchroniser les utilisateurs avec un annuaire LDAP

Les entreprises ou organisations se dotent de services d’annuaires LDAP pour conserver les informations relatives à leur organisation interne dès qu’elles atteignent une dimension respectable. Une des problématiques qui se pose lorsque l’on intègre une application dans le système d’information de l’entreprise est celle de la multiplication des identifiants de connexion, et de la synchronisation des utilisateurs avec l’annuaire d’entreprise. Nous allons tenter de palier à ces besoins.

Une très brève introduction à LDAP

LDAP est un protocole standard qui permet de gérer des annuaires, il fournit les méthodes d’accès aux données d’annuaires qui sont généralement des informations relatives à aux organisations, et permettent de recenser des utilisateurs, du matériel, etc…

Les possibilité offertes par LDAP incluent la connexion, la recherche d’informations, l’insertion/modification ou encore suppression d’entrées dans l’annuaire.

Les services d’annuaires LDAP sont nombreux, Microsoft Active Directory (AD) est la mise en oeuvre sur le serveur Windows qui permet d’identifier et d’authentifier les utilisateurs sur le réseau (ce n’est qu’une partie des nombreuses entrées mais c’est ce qui nous intéresse pour cette étude), on peut encore citer les serveurs OpenLDAP ou Apache Directory Server… l’essentiel c’est que ces serveurs respectent le protocole et répondent à une norme pour les systèmes d’annuaires : l’intégration de l’indentification et de l’authentification de l’utilisateur au travers de tel ou tel serveur ne sera au final que du paramétrage dans notre application.

Nous ne dépenserons pas une semaine à installer un annuaire LDAP de test… vous pouvez trouver sur internet des personnes bien avisées qui ont mis en place des serveurs que l’on peut exploiter dans ce seul but.

A quoi ressemble un annuaire LDAP ?

Les données d’annuaire sont sous forme de structure arborescente dont chaque nœud est constitué d’attributs et de valeurs, les attributs sont définis dans des schémas qui définissent leur type.

Wikipédia dispose d’un article assez complet sur LDAP, et je vais aller à l’essentiel pour effectuer un test de connexion et voir comment l’utilisateur est dupliqué après connexion dans notre propre application.

Les éléments de base des annuaires utilisent en général une nomenclature similaire, dc=… (domain component) pour la racine de l’annuaire et ses premières branches, ou= (organizational units) pour les organisations ou groupes, cn= (common name) pour le nom d’une entrée (çàd un objet abstrait un paramètre de configuration ou bien concret comme du matériel informatique), ou encore uid (user identifier) pour une personne.

Chaque entrée de l’annuaire possède un identifiant unique, DN (Distinguished Name), qui est composé de son RDN (Relative Distinguished Name) suivi du DN de son parent. Le RDN est généralement l’attribut uid pour une personne (user identifier) : le DN représente donc par récursivité le nom de l’entrée sous la forme de son chemin d’accès de la dernière branche jusqu’au sommet de l’arbre

Les exemples de DN (reportez ous à Wikipédia)

cn=ordinateur,ou=machines,dc=EXEMPLE,dc=FR
cn=Jean,ou=gens,dc=EXEMPLE,dc=FR

Nous en savons peu mais bien suffisamment pour avancer…

Serveur LDAP de test

Le plus compliqué chaque fois que l’on doit intervenir en développement sur une authentification LDAP est de disposer d’un serveur qui reflète fidèlement le serveur de production. Nous allons nous contenter d’être simple utilisateurs de services à notre disposition sur le web.

Quelques recherches internet ont suffi à trouver une société qui met à disposition un serveur LDAP de test. Bien entendu ce serveur pourra ne plus être hébergé dans quelques temps, mais il sera parfait pour mes propres tests, et je ne doutes pas que d’autres personnes fournissent les mêmes services ailleurs si celui ci venait à fermer.

La société préconise d’utiliser Apache Directory Studio comme client LDAP, ce que j’ai fait avec les paramètres de connexion fournis. Au final j’abouti à une connexion fonctionnelle et je peux naviguer dans la structure du répertoire, vous pouvez consulter la copie d’écran où apparaît notre jeu de données d’essai.

ldap

Authentification LDAP avec Symfony2 et FOSUserBundle

Le support LDAP sera du ressort de l’extension FR3DLdapBundle.

Avant d’installer le bundle, assurez vous d’avoir activé l’extension PHP LDAP dans le fichier de configuration de PHP. L’installeur vérifie la dépendance à cette extension et s’arrête si elle n’est pas satisfaite

Pour l’installation, commencez par télécharger les sources :

composer require "fr3d/ldap-bundle"

Puis intégrez le bundle au sein de l’application dans le fichier AppKernel.php :

...
new FR3D\LdapBundle\FR3DLdapBundle(),
...

Un utilisateur doit pouvoir se connecter depuis l’application ou depuis l’annuaire, et le mécanisme d’authentification utilise un seul fournisseur d’utilisateur, le premier déclaré dans la configuration.
Pour paramétrer correctement l’application ce fournisseur doit enchaîner les fournisseurs déclarés en aval, on déclare un fournisseur pour chaque bundle (fos_userbundle et fr3d_ldapbundle) qui seront exploités tous les deux pour récupérer un utilisateur. Le bundle LDAP doit impérativement être appelé en dernier dans la chaîne.

Editez le fichier app/config/security.yml

...
    providers:
        chain_provider:
            chain:
                providers: [fos_userbundle, fr3d_ldapbundle]
        fr3d_ldapbundle:
            id: fr3d_ldap.security.user.provider
        fos_userbundle:
            id: fos_user.user_provider.username_email

    firewalls:
   ...
        main:
      ...
            fr3d_ldap: ~
...

Pour fonctionner, vous devez paramétrer le FR3DLdapBundle dans le fichier de configuration config.yml, ce n’est pas la partie la plus simple car la documentation n’est pas à la hauteur de bundle. A l’heure où j’écris ces lignes je suis bien seul à fournir un exemple de configuration qui fonctionne sur le LDAP de Forumsys , après une demi journée d’essais vains, c’est une pépite !

fr3d_ldap:
    driver:
       host:     ldap.forumsys.com
       port:     389
       bindRequiresDn:      true
       baseDn:   cn=read-only-admin,dc=example,dc=com
       password: password
    user:
        baseDn: dc=example,dc=com
        filter: (&(objectClass=person))
        attributes:
           - { ldap_attr: uid,  user_method: setUsername }
           - { ldap_attr: mail, user_method: setEmail }
    service:
        ldap_manager:  app.ldap.ldap_manager

Les lignes host, baseDn et password sont les attributs de connexion fournis par Forumsys. Dans la section user on peut préciser le nom de domaine où trouver les utilisateurs (un niveau plus haut dans l’arborescence) et surtout on peut appliquer un filtre sur les attributs LDAP. L’annuaire que l’on interroge est un OPENLDAP en version 3, la plupart des exemples disponibles sur internet interrogent un ACTIVE DIRECTORY, le schéma qui décrit les attributs est sensiblement différent d’un serveur à l’autre et vous devez le personnaliser, ici vous pouvez vous référer à la copie d’écran pour voir la mise en correspondance du filtre avec le schéma.

Lorsque l’utilisateur est trouvé dans l’annuaire LDAP, une entité utilisateur est hydratée à partir des attributs de l’annuaire. La correspondance des attributs aux méthodes de l’entité utilisateur est définie dans la configuration. Je n’ai repris que les attributs uid et mail de l’annuaire (les méthodes setUsername() et setEmail() sont définies dans la classe de base de FOSUserBundle). L’uid est l’identifiant utilisateur, il est mis en correspondance avec la propriété username : comme il apparaît en premier dans la configuration des attributs c’est sur lui que la recherche dans l’annuaire sera effectuée. (On pourrait vouloir se connecter par e-mail)

Dans l’entité utilisateur vous pourriez créer une propriété pour chaque information du répertoire LDAP que vous souhaitez voir dupliquée dans la base de données, certains enregistrent le nom de domaine, je n’en ai pas l’utilité.

A ce stade vous pouvez vous connecter avec un des identifiants utilisateur, le mot de passe est toujours password.

Connectez vous par exemple avec les comptes de Marie Curie ou Louis Pasteur :

</pre>
Identifiant : [<strong>curie</strong>|<strong>pasteur</strong>]
Mot de passe : [<strong>password</strong>]
<pre>

Que se passe-t-il après connexion ?

FOSUserBundle ne peut pas fournir d’utilisateur qui correspond à l’identifiant et au mot de passe, par contre l’annuaire LDAP lui est en mesure, ce qui déclenche l’hydratation d’un utilisateur qui est en théorie persisté en base : l’utilisateur est dupliqué dans la table utilisateur de l’application et ses propriétés sont manipulables dans notre interface d’administration. A la prochaine connexion, l’utilisateur sera toujours identifié par le LDAP mais les propriétés de l’utilisateurs seront renseignées depuis la table utilisateur, pas depuis l’annuaire.

En théorie… car en pratique il peut vous rester un détail à régler, du moins sur une base de données où sont gérées les contraintes : PostgreSQL soulève une exception car le programme essaie d’insérer une valeur NULL dans la colonne du mot de passe qui est définie à NOT NULL.

Lors de l’hydratation de l’utilisateur le gestionnaire LDAP fixe la propriété mot de passe à la chaîne de caractère vide, ce qui est dérangeant par rapport à la méthode setPassword() de l’utilisateur telle que définie chez moi.

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

Ce setter n’accepte pas une chaîne vide comme valeur de mot de passe valide, et vous êtes nombreux à posséder le même.

Une solution évidente est de modifier le setter pour accepter les chaînes vide, une autre solution est de surcharger le gestionnaire LDAP pour insérer une chaîne quelconque lors de l’hydratation. Vous pouvez aussi cumuler les deux.

Dans les deux cas le mot de passe n’est pas encrypté, il n’y aura qu’une chaîne de caractère vide ou une chaîne prédéfinie dans la base par conséquent il ne pourra pas y avoir de mise en correspondance lors de l’authentification par le fournisseur FOSUserBundle, seul le fournisseur FR3DLdapBundle retournera l’utilisateur de la base après réussite de connexion à l’annuaire.

Solution 1.

    public function setPassword($password) {
        if ($password !== null)
            $this->password = $password;
        return $this;
    }

Solution 2.

Pour surcharger le gestionnaire LDAP il faut déclarer un service (fichier services.yml par ex.)

services: 
    ...
    app.ldap.ldap_manager:
        class: 'AppBundle\Ldap\LdapManager'
        arguments: ['@fr3d_ldap.ldap_driver', '@fr3d_ldap.user_manager', '%fr3d_ldap.ldap_manager.parameters%']

Créez la classe correspondante sous AppBundle\Ldap\Ldapmanager.php

<?php namespace AppBundle\Ldap; use FR3D\LdapBundle\Ldap\LdapManager as BaseLdapManager; use Symfony\Component\Security\Core\User\UserInterface; class LdapManager extends BaseLdapManager { protected function hydrate(UserInterface $user, array $entry) { parent::hydrate($user, $entry); $user->setPassword('LDAP_AUTH_ONLY');
    }
}

Cette dernière méthode permet de personnaliser comment seront renseignées les propriétés utilisateur, il peut être utile de se baser sur l’appartenance à un service ou une organisation dans les attributs LDAP pour accorder des privilèges par exemple ($user->setGroups() ou $user->setRoles())

Finalement, n’oubliez pas que si vous modifiez le mot de passe au travers de l’interface administrateur, FOSUserBundle reprendra le dessus à la connexion.

Maintenant que se passe-t-il si l’entrée utilisateur créé un doublon ? La tentative d’insertion d’un utilisateur ldap avec un nom d’utilisateur ou un e-mail déjà en base soulève une exception.

Afin de pouvoir maîtriser le rendu de la page de connexion et informer l’utilisateur il est nécessaire de prendre le contrôle de ces exceptions, et dans l’immédiat le bundle ne permet pas de le faire proprement. Cependant comme il est couplé à FOSUserBundle, il utilise le gestionnaire d’utilisateurs de ce dernier… et comme on a déjà surchargé le gestionnaire d’utilisateurs, il nous suffit d’attraper les exceptions lancées par Doctrine et propager une exception d’authentification qui sera interceptée lors du contrôle d’authentification par le gestionnaire de sécurité. Cette exception sera interceptée et affichée proprement sur notre formulaire de connexion.

Créez une exception qui étend les exceptions d’authentification Symfony sous AppBundle\Exception\DuplicateUserException.php

<?php

namespace AppBundle\Exception;

use Symfony\Component\Security\Core\Exception\AuthenticationException;

class DuplicateUserException extends AuthenticationException
{
    /**
     * {@inheritdoc}
     */
    public function getMessageKey()
    {
        return 'Un utilisateur possédant le même nom ou la même adresse e-mail existe déjà';
    }
}

Puis modifiez votre surcharge d’écouteur de connexion pour intercepter les exceptions provoquées par une entrée dupliquée en cas d’ajout ou de modification de l’utilisateur :

<?php

namespace AppBundle\Listener;

use FOS\UserBundle\FOSUserEvents;
use FOS\UserBundle\Event\UserEvent;
use FOS\UserBundle\Model\UserManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use AppBundle\Exception\DuplicateUserException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;

class LoginListener implements EventSubscriberInterface {

    protected $userManager;
    
    public function __construct(UserManagerInterface $userManager) 
    {
        $this->userManager = $userManager;
    }
    
    public static function getSubscribedEvents()
    {
        return array(
            FOSUserEvents::SECURITY_IMPLICIT_LOGIN => 'onImplicitLogin',
            SecurityEvents::INTERACTIVE_LOGIN => 'onSecurityInteractiveLogin',
        );
    }

    protected function updateUser($user) {
        try {
            if (!$user->getLoginCount())
                $user->setFirstLogin(new \DateTime());

            $user->setLoginCount((int) $user->getLoginCount() + 1);

            $this->userManager->updateUser($user);
        }
        catch (UniqueConstraintViolationException $e) {
            throw new DuplicateUserException();
        }
    }
    
    public function onImplicitLogin(UserEvent $event)
    {
        $this->updateUser($event->getUser());
    }
    
    public function onSecurityInteractiveLogin(InteractiveLoginEvent $event) {
        $user = $event->getAuthenticationToken()->getUser();
        //if ($user instanceof UserInterface)
            $this->updateUser($user);
    }
}

Pour conclure ce sujet

Retenez la leçon, ne chiffrez plus une gestion d’utilisateur à la légère dans vos évaluations projet !

Faites découvrir ce billet...Email this to someonePrint this pageShare on FacebookTweet about this on TwitterShare on Google+Share on LinkedIn