Kit de survie : Connexion avec le composant LDAP de Symfony

Difficulté : | 15' Publié il y a 2 semaines
L'équipe a eu besoin d'intégrer une connexion LDAP dans un projet et nous avons trouvé la documentation assez restreinte sur cette partie. Kit de survie : cet article va vous présenter comment utiliser LDAP avec votre projet Symfony !

Nous avons récemment développé un intranet pour l'entreprise qui permet entre autres : la gestion des congés, du télétravail, des frais pros, des projets etc... Historiquement, nous avions mis en place un LDAP pour centraliser l'authentification de nos différents services (Gitlab, Sentry, Redmine, Observium, Rancher, Owncloud etc...). Le choix s'imposait donc de devoir rattacher cet intranet développé avec Symfony avec notre LDAP. On vous explique...

LDAP : papy fait de la résistance

Le LDAP est un protocole créé en 1993 permettant la communication avec une structure de données d'utilisateurs (annuaire). LDAP permet donc la gestion des utilisateurs, leurs droits et leurs mots de passe.

Concrètement, LDAP est une arborescence qui à partir d'une nomenclature standardisée va permettre d'organiser vos utilisateurs dans une structure logique. Il est donc possible de rajouter des attributs à des utilisateurs (email, téléphone etc...) mais aussi de créer et attribuer des groupes (Service RH, admin sys etc...)

LDAP étant un protocole, il est nécessaire d'utiliser des serveurs pour l'utiliser. Les plus connus sont OpenLDAP (open-source) et Active Directory (Microsoft). Chaque serveur vous proposera sa propre gestion des utilisateurs et sa propre arborescence. Ce protocole utilise par défaut le port 389 et 636 (SSL).

Pour vous faciliter l'accès et la modification de vos données, nous vous conseillons d'installer LDAP Explorer. Cet outil très simple va vous permettre de parcourir facilement votre arborescence LDAP.

Chaque donnée dans LDAP est identifié par un DN qui vous permet de pointer directement l'information lors d'une requête LDAP. Le DN reprend en cascade la parenté de la donnée. Si vous souhaitez filtrer des résultats d'un DN (dans le cas d'une recherche d'un ou plusieurs utilisateurs par exemple), il sera nécessaire de préciser la notion de filter qui utilise une syntaxe spécifique.

LDAP et Symfony, j'y vais ou j'y vais pas ?!

Bon on va pas vous le cacher, l’histoire du composant LDAP dans Symfony est relativement compliquée.

Mais avant cela, il faut savoir que PHP propose une extension pour gérer entièrement LDAP. Facile d'implémentation, elle vous permet aussi de bricoler le protocole LDAP pour vous permettre de bien comprendre son fonctionnement. Le composant LDAP de Symfony utilise bien entendu cette extension (donc ne pas oublier de l'installer hein !)

Le composant LDAP est arrivé chaud bouillant fin 2015 dans la version 2.8 de Symfony ! Eureka : http://symfony.com/blog/new-in-symfony-2-8-ldap-component !

Bon, bien vite, on a compris que c'était pas une mince affaire de l'utiliser et qu'il manquait pas mal de chose !

Après plusieurs gros BC break sur le composant, Fabien Potencier décide de mettre en place le système des "Experimental features" pour indiquer qu'une fonctionnalité n'est pas entièrement stabilisée. Évidemment, le composant LDAP est l'un des premiers à bénéficier de cette nouvelle mention !

Mais depuis la version 3.2, les choses se sont grandement améliorées ! En résumé, vous pouvez y aller (mais jetez toujours un coup d’œil sur le repo avant d'upgrade votre Symfony) !

Dernière chose avant de parler de l'implémentation, il faut savoir que le composant permet uniquement la consultation de données LDAP mais pas de création ou de modification !

L’implémentation de LDAP dans Symfony pas à pas

Dans un premier temps je vais vous résumer ce que nous utilisons pour ce projet :

  • Symfony 3.2
  • PHP 7
  • L'extension LDAP de PHP

Mise en place des services

Un LDAP Provider personnalisé

Tout d'abord pour mettre en place la connexion LDAP, nous allons créer un Provider personnalisé pour pouvoir gérer les rôles de tous nos utilisateurs. Voici le code de notre Provider :

<?php

namespace AppBundle\Security;

use Doctrine\ORM\EntityManager;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use AppBundle\Entity\User;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class LdapUserProvider implements UserProviderInterface
{
    private $ldap;
    private $baseDn;
    private $searchDn;
    private $searchPassword;
    private $defaultRoles;
    private $defaultSearch;
    private $filterAdmin;
    private $passwordAttribute;
    private $em;

    /**
     * @param LdapInterface $ldap
     * @param string        $baseDn
     * @param string        $searchDn
     * @param string        $searchPassword
     * @param array         $defaultRoles
     * @param string        $uidKey
     * @param string        $filter
     * @param string        $passwordAttribute
     */
    public function __construct(LdapInterface $ldap, $baseDn, $searchDn = null, $searchPassword = null, array $defaultRoles = array(), $uidKey = 'sAMAccountName', EntityManager $em, $filterAdmin = '(memberUid={username})', $filter = '({uid_key}={username})', $passwordAttribute = null)
    {
        $this->ldap = $ldap;
        $this->baseDn = $baseDn;
        $this->searchDn = $searchDn;
        $this->searchPassword = $searchPassword;
        $this->defaultRoles = $defaultRoles;
        $this->em = $em;
        $this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter);
        $this->filterAdmin = $filterAdmin;
        $this->passwordAttribute = $passwordAttribute;
    }

    /**
     * {@inheritdoc}
     */
    public function loadUserByUsername($username)
    {
        try {
            $this->ldap->bind($this->searchDn, $this->searchPassword);
            $username = $this->ldap->escape($username, '', LdapInterface::ESCAPE_FILTER);
            $query = str_replace('{username}', $username, $this->defaultSearch);
            $search = $this->ldap->query($this->baseDn, $query);
        } catch (ConnectionException $e) {
            throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username), 0, $e);
        }

        $entries = $search->execute();
        $count = count($entries);

        if (!$count) {
            throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
        }

        if ($count > 1) {
            throw new UsernameNotFoundException('More than one user found');
        }

        // Lorsque nous avons vérifier que l'utilisateur existe bien dans le LDAP on peut le loader/créer son compte utilisateur
        return $this->loadUser($username, $entries[0]);
    }

    /**
     * {@inheritdoc}
     */
    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof User) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        $userRepository = $this->em->getRepository("AppBundle:User");
        $user = $userRepository->findOneBy(array("username" => $user->getUsername()));

        if ($user === null) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        return $user;
    }

    /**
     * {@inheritdoc}
     */
    public function supportsClass($class)
    {
        return $class === User::class;
    }

    /**
     * Loads a user from an LDAP entry.
     *
     * @param string $username
     * @param Entry  $entry
     *
     * @return User
     */
    protected function loadUser($username, Entry $entry)
    {
        $userRepository = $this->em->getRepository("AppBundle:User");
        $user = $userRepository->findOneBy(array("username" => $username));

        if ($user === null) {
            $user = new User();
            $user->setFirstname($entry->getAttribute("givenName")[0]);
            $user->setLastname($entry->getAttribute("sn")[0]);
            $user->setEmail($entry->getAttribute("mail")[0]);
            $user->setUsername($entry->getAttribute("uid")[0]);
            $user->setRoles($this->defaultRoles);

            $this->em->persist($user);
            $this->em->flush();
        } else {

            $this->em->flush();
        }

        return $user;
    }

    /**
     * Fetches the password from an LDAP entry.
     *
     * @param null|Entry $entry
     */
    private function getPassword(Entry $entry)
    {
        if (null === $this->passwordAttribute) {
            return;
        }

        if (!$entry->hasAttribute($this->passwordAttribute)) {
            throw new InvalidArgumentException(sprintf('Missing attribute "%s" for user "%s".', $this->passwordAttribute, $entry->getDn()));
        }

        $values = $entry->getAttribute($this->passwordAttribute);

        if (1 !== count($values)) {
            throw new InvalidArgumentException(sprintf('Attribute "%s" has multiple values.', $this->passwordAttribute));
        }

        return $values[0];
    }
}

Voici un exemple assez simple de LdapUserProvider, vous pouvez le récupérer et l'adapter suivant vos besoins. Il est assez simple à comprendre. La première fonction appelée lors d'une connexion est la fonction loadUserByUsername, elle permettra, si l'utilisateur n'existe pas à sa connexion, de lui créer un compte utilisateur dans notre base de données.

Les LdapProvider fonctionnent généralement comme les providers basiques.

Déclarer les services

services:
    app.ldap:
        class: Symfony\Component\Ldap\Ldap
        factory:
            - 'Symfony\Component\Ldap\Ldap'
            - 'create'
        arguments:
            - 'ext_ldap'
            -
                host: ldap.wanadev.lan
                port: 389
                version: 3
                encryption: 'none'

    app.provider.ldap:
        class: AppBundle\Security\LdapUserProvider
        arguments:
            - "@app.ldap"
            - "%ldap_base_dn%"
            - "%ldap_dn_admin%"
            - "%ldap_password%"
            - [ROLE_USER]
            - "uid"
            - "@doctrine.orm.default_entity_manager"

Dans cette partie, nous allons déclarer exactement deux services. Le premier dont l'alias sera app.ldap. Pour l'instancier, nous allons procéder d'une manière différente à d'habitude. Nous allons utiliser une Factory. Ce service servira de point de connexion entre notre Symfony et notre LDAP.

Pour le second service, nous enverrons toutes les informations susceptibles de nous intéresser pour gérer les rôles ainsi que les requêtes dans notre annuaire LDAP.

Ajouter son Provider au security

security:
    providers:
        app_users:
            id: app.provider.ldap
    main:
        anonymous: true
        provider: app_users
        pattern: ^/
        logout:
            path: app_security_logout
            target: app_security_login
        form_login_ldap:
            service: app.ldap
            dn_string: uid={username},OU=people,O=Wanadev,DC=wanadev,DC=org
            check_path: app_security_login_check
            login_path: app_security_login
            default_target_path: /
        remember_me:
            secret: "%secret%"
            lifetime: 2678000
            path: /

Ici, nous mettons en lien les différents services pour que notre Firewall "main" utilise notre Provider et notre connexion LDAP configurée plus haut. Cet exemple d'utilisation de LDAP comme connexion à un Symfony n'est pas fixe et est susceptible d'évoluer. Chacun peut le modifier en fonction de son besoin.

LDAP et Symfony : retours, et amélioration

Nous vous invitons à enrichir cet article en commentaire ci-dessous, vos retours d'expériences sont très bons à prendre aussi !

Tags de l'article :

bonnes pratiques Performance Sécurité

Commentaires