Connexion LDAP ou AD

De Wiki de Jordan LE NUFF
Sauter à la navigation Sauter à la recherche
Ligne 430 : Ligne 430 :
  
 
Et voici les modifications à apporter :
 
Et voici les modifications à apporter :
<syntaxhighlight lang="php" line highlight="18-19,27,27,29,33,75-79,81,86-89,94">
+
<syntaxhighlight lang="php" line highlight="18-19,27,27,29,33,75-81,86-89,95">
 
<?php
 
<?php
  
Ligne 520 : Ligne 520 :
 
             'Bienvenue. Vous êtes connecté !'
 
             'Bienvenue. Vous êtes connecté !'
 
         );
 
         );
 +
 
         if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
 
         if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
 
             return new RedirectResponse($targetPath);
 
             return new RedirectResponse($targetPath);

Version du 13 mars 2020 à 17:01

Présentation

Cette page a pour objet de décrire une procédure pour interfacer Symfony avec une authentification LDAP ou AD.

Le contenu de cette procédure est majoritairement inspiré de la documentation officielle de Symfony :

Une autre partie de cette procédure s'est inspirée de la solution de Stanislav Drozdov de ré-écriture du fournisseur d'utilisateurs LDAP :

Objectifs

Cette procédure a deux objectifs :

  1. Rassembler dans une unique page les informations nécessaires à l'interfaçage avec un LDAP ou AD
    • En effet, pour ce type de besoin, les informations sont dispersées au sein de la documentation officielle de Symfony
  2. Aller au-delà des limitations du composant natif LDAP de Symfony
    • Bien que le composant LDAP simplifie énormément la mise en place de l'interfaçage avec Symfony, il n'embarque pas nativement d'option permettant la récupération automatique de certains champs. Il faut donc le surcharger en fonction des besoins

La finalité est de pouvoir réaliser une gestion des droits de l'utilisateur en fonction de ses groupes d'appartenance dans l'Active Directory.

Marche à suivre

La marche à suivre est plutôt bien présentée dans la documentation officielle.

Elle se présente sous la forme suivante :

Installer le module de sécurité

Cette partie est la plus simple de la procédure. Si Symfony a été installé avec l'option --full, elle n'est pas nécessaire.

Pour installer le module de sécurité, lancer la commande suivante à la racine du projet :

Putty icon.png Console SSH

Créer la gestion des utilisateurs

Le module security s'appuie sur des fournisseurs d'utilisateurs pour gérer les utilisateurs.

Comme indiqué dans la documentation à propos des fournisseurs d'utilisateurs, Symfony embarque nativement les fournisseurs suivants :

Bien que la documentation principale relative au module de sécurité laisse entendre qu'il faut créer une classe d'utilisateur, cette étape est inutile pour des utilisateurs LDAP (à moins de vouloir utiliser une classe d'utilisateur personnalisée). En effet, le composant symfony/ldap embarque nativement une classe d'utilisateur LdapUser.

Installer le composant LDAP

Pour installer le composant LDAP, lancer la commande suivante à la racine du projet :

Putty icon.png Console SSH

Configurer le client LDAP

Pour configurer le client LDAP, ajouter la section suivante dans le fichier config/services.yaml :

parameters:
...
    app_ldap_host_default: my-server
    app_ldap_port_default: 389
    app_ldap_encryption_default: tls
...
    env(APP_LDAP_HOST): '%env(default:app_ldap_host_default:LDAP_HOST)%'
    env(APP_LDAP_PORT): '%env(default:app_ldap_port_default:LDAP_PORT)%'
    env(APP_LDAP_ENCRYPTION): '%env(default:app_ldap_encryption_default:LDAP_ENCRYPTION)%'
...
services:
...
    # LDAP configuration
    Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            -   host: '%env(resolve:APP_LDAP_HOST)%'
                port: '%env(resolve:APP_LDAP_PORT)%'
                encryption: '%env(resolve:APP_LDAP_ENCRYPTION)%'
                options:
                    protocol_version: 3
                    referrals: false

Le contenu de cette section diffère par rapport à la documentation officielle au niveau du contenu des variables host, port et encryption. En effet, afin de rendre l'application complètement paramétrable depuis des variables d'environnement du serveur, il a été défini des paramètres par défaut et des variables internes à l'application.

Attention ! Il faut que l'extension LDAP pour PHP soit installée pour que le composant soit fonctionnel.

Par ailleurs, il est recommandé d'ajouter "ext-ldap": "*", dans la section "require" du fichier composer.json afin d'indiquer que l'application Symfony a besoin de cette extension PHP pour fonctionner correctement.

Plus d'information sur : https://symfony.com/doc/current/security/ldap.html#configuring-the-ldap-client

Définir le fournisseur d'utilisateurs de type "LDAP"

Comme indiqué dans la documentation Symfony pour le paramétrage du fournisseur d'utilisateurs de type "LDAP", il est possible d'utiliser le fournisseur natif en suivant l'exemple donné dans la documentation. Toutefois, comme évoqué en introduction de cette procédure, les besoins de l'application peuvent se heurter aux limites de ce fournisseur. Il va donc falloir utiliser un fournisseur d'utilisateurs LDAP personnalisé.

Ainsi, en s'appuyant sur la relative à la mise en place d'un fournisseur d'utilisateur personnalisé, la définition du nouveau fournisseur se fait dans les fichiers config/services.yaml et config/packages/security.yaml.

Dans le fichier config/services.yaml, ajouter la section suivante :

parameters:
...
    app_ldap_basedn_default: dc=example,dc=com
    app_ldap_searchdn_default: "cn=read-only-admin,dc=example,dc=com"
    app_ldap_searchpassword_default: password
...
    env(APP_LDAP_BASEDN): '%env(default:app_ldap_basedn_default:LDAP_BASEDN)%'
    env(APP_LDAP_SEARCHDN): '%env(default:app_ldap_searchdn_default:LDAP_SEARCHDN)%'
    env(APP_LDAP_SEARCHPASSWORD): '%env(default:app_ldap_searchpassword_default:LDAP_SEARCHPASSWORD)%'
...
services:
...
    # Custom LDAP User Provider
    App\Security\CustomLdapUserProvider:
        arguments:
            - '@Symfony\Component\Ldap\Ldap'
            - '%env(resolve:APP_LDAP_BASEDN)%'
            - '%env(resolve:APP_LDAP_SEARCHDN)%'
            - '%env(resolve:APP_LDAP_SEARCHPASSWORD)%'

Explication : Cette section permet de définir un service déclaré dans la classe App\Security\CustomLdapUserProvider (cette classe est ensuite à créer) et s'appuie sur le client LDAP existant (Symfony\Component\Ldap\Ldap). C'est ce service qui va récupérer de façon personnalisée les utilisateurs LDAP ou AD. Les variables APP_LDAP_BASEDN, APP_LDAP_SEARCHDN et APP_LDAP_SEARCHPASSWORD reposent sur l'existence de variables d'environnement du serveur, comme expliqué précédemment.

Dans le fichier config/packages/security.yaml, ajouter la section suivante :

security:
    providers:
        ldap_users:
            id: App\Security\CustomLdapUserProvider
...

Explication : Le paramètre "providers:ldap_users:id" permet de définir un fournisseur d'utilisateur (providers) référencé ldap_users (ldap_users) personnalisé (id) pointant vers le service précédemment défini App\Security\CustomLdapUserProvider.

Surcharger le fournisseur d'utilisateurs de type "LDAP"

Une fois que les groupes d'appartenance de l'utilisateur seront extraits depuis l'Active Directory, il faudra les stockés dans une variable. Le but étant de réaliser un filtrage selon ces derniers, il faudra donc les stocker dans la propriété $roles exigée pour tout utilisateur de Symfony fonctionnant avec le module de sécurité de base.

Ainsi, créer le fichier src/Security/CustomLdapUserProvider.php avec le contenu suivant :

<?php

namespace App\Security;

use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Security\LdapUser;
use Symfony\Component\Ldap\Security\LdapUserProvider;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\User\UserInterface;

class CustomLdapUserProvider extends LdapUserProvider
{
    private $defaultRoles;
    private $passwordAttribute;
    private $extraFields = array();

    /**
     * Loads a user from an LDAP entry.
     *
     * @param string $username
     * @param Entry $entry
     * @return UserInterface
     */
    protected function loadUser(string $username, Entry $entry)
    {
        $password = null;
        $extraFields = [];

        if (null !== $this->passwordAttribute) {
            $password = $this->getAttributeValue($entry, $this->passwordAttribute);
        }

        foreach ($this->extraFields as $field) {
            $extraFields[$field] = $this->getAttributeValue($entry, $field);
        }

        $results=array();
        foreach ($entry->getAttribute("memberOf") as $LdapGroupDn)
        {
            $results[]= "ROLE_".ldap_explode_dn($LdapGroupDn,1)[0];
        }

        if (!empty($results))
            $roles=$results;
        else
            $roles=$this->defaultRoles;

        return new LdapUser($entry, $username, $password, $roles, $extraFields);
    }

    private function getAttributeValue(Entry $entry, string $attribute)
    {
        if (!$entry->hasAttribute($attribute)) {
            throw new InvalidArgumentException(sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
        }

        $values = $entry->getAttribute($attribute);

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

        return $values[0];
    }

}

Explications :

  • class CustomLdapUserProvider extends LdapUserProvider
    • Création d'une classe enfant CustomLdapUserProvider qui hérite de toutes les propriétés et méthodes de la classe parente LdapUserProvider
  • Propriétés privées $defaultRoles, $passwordAttribute et $extraFields
    • Déclaration des propriétés nécessaires à l'utilisation de classe CustomLdapUserProvider
    • Obligation de surcharger la propriété $extraFields avec un = array() pour assurer la compatibilité avec la classe parente (problème d'incompatibilité encore inexpliqué)
  • protected function loadUser(string $username, Entry $entry)
    • C'est cette fonction loadUser qu'il est nécessaire de ré-écrire. En effet, c'est à cette étape qu'il faudra alimenter l'utilisateur Symfony avec les données désirées extraites depuis le LDAP
  • $results=array();
    • Variable qui contiendra les groupes au format attendu par Symfony
  • foreach ($entry->getAttribute("memberOf") as $LdapGroupDn)
    • Boucle pour récupérer chaque contenu de l'attribut memberOf depuis l'Active Directory dans une variable $LdapGroupDn
  • $results[]= "ROLE_".ldap_explode_dn($LdapGroupDn,1)[0];
    • Utilisation de la fonction PHP ldap_explode_dn pour éclater la variable $LdapGroupDn et récupérer la ligne 0 du tableau PHP (correspondant au CN du groupe AD)
    • Ajout de la chaîne de caractère "ROLE_" au début du CN pour que cela corresponde au format attendu par Symfony
    • Ajout du résultat du traitement dans le tableau de variable $results[]
  • if (!empty($results)) $roles=$results; else $roles=$this->defaultRoles;
    • Si la variable $results[] n'est pas vide, on affecte les résultats aux rôles de l'utilisateur Symfony
    • Si la variable $results[] est vide, on affecte à l'utilisateur Symfony les rôles par défaut
  • private function getAttributeValue(Entry $entry, string $attribute)
    • Simple copier/coller de la fonction privée getAttributeValue de la classe parente (car utilisée dans dans la fonction loadUser)

Activer le fournisseur d'utilisateurs de type "LDAP"

Pour activer le fournisseur d'utilisateurs de type "LDAP" précédemment créé, définir dans le fichier config/packages/security.yaml le paramètre "security:firewalls:main:provider" à ldap_users (par défaut users_in_memory). Cela donner le code suivant :

security:
...
    firewalls:
...
        main:
...
            provider: ldap_users
...

Mise en place de l'authentification

Principes de base

Pare-feux

Le principe du système d'authentification du module de sécurité de Symfony repose sur la définition de "pare-feux" (firewalls) dans le fichier config/packages/security.yaml. Dans la suite de cette procédure, le nom du pare-feu utilisé est main (pare-feu par défaut).

Un seul pare-feu est actif à chaque requête. Le nom du pare-feu importe peu. Cela est seulement utile pour savoir quel chemin a pris la requête d'un utilisateur pour s'authentifier.

Les paramètres pattern, host et methods du pare-feu permettent de restreindre l'accès aux ressources de l'application. C'est la première restriction qui correspond à la requête qui activera le pare-feu Symfony correspondant. Voir la documentation sur les restrictions dans un pare-feu Symfony.

A moins de définir le paramètre "security:firewalls:myfirewall:security" à false, l'authentification est activée par défaut dans le pare-feu par défaut (main). Cela implique que l'utilisateur doit être authentifié pour accéder à la ressource demandée.

Or, un utilisateur qui arrive sur l'application pour la première fois est, par définition, non authentifié. De ce fait, sans aucun autre paramètre supplémentaire, cet utilisateur ne pourra jamais accéder à l'application.

Pour pouvoir accorder au minimum un accès aux pages de base de l'application, il faut que l'option "security:firewalls:myfirewall:anonymous" soit présente et définie à "lazy" (valeur par défaut). Ainsi, techniquement, du point de vue de Symfony, les utilisateurs non identifiés sont considérés authentifiés comme anonyme (anonymous).

Plus d'informations sur https://symfony.com/doc/current/security.html#a-authentication-firewalls.

Authentification

De la même manière qu'il y a des fournisseurs d'utilisateurs au sein de Symfony, il y a des fournisseurs d'authentification.

Les différents fournisseurs d'authentification natifs à Symfony sont décrits ici : https://symfony.com/doc/current/security/auth_providers.html.

Toutefois, Symfony recommande vivement d'utiliser "Guard", un système d'authentification apparu dans la version 2.8 de Symfony ayant pour vocation de simplifier la mise en place d'une stratégie d'authentification. Il suffit de créer une classe implémentant l'interface AuthenticatorInterface ou étendant la classe AbstractGuardAuthenticator, et d'adapter les méthodes de "Guard" en fonction des besoin. Se référer à la documentation de Guard Authentifcator pour plus d'informations.

Ainsi, il faudra créer un système d'authentification avec "Guard" et indiquer au pare-feu de l'utiliser.

Appel des méthodes Guard par Symfony

Mise en oeuvre

Utiliser MakerBundle pour générer le système d'authentification

Grâce au bundle MakerBundle de Symfony, il est possible de générer automatiquement beaucoup de choses dans le processus d'authentification.

Pour ce faire, lancer la commande make:auth. Des questions seront posées, il faudra répondre en fonction des besoins. Cela ressemblera donc au résultat suivant :

Putty icon.png Console SSH

Cette commande a généré les éléments suivants :

  • Un gestionnaire d'authentification src/Security/LdapFormAuthenticator.php
  • Un contrôleur src/Controller/SecurityController.php
  • Un modèle Twig de formulaire d'identification templates/security/login.html.twig

Et a mis à jour le fichier de configuration config/packages/security.yaml en y ajoutant les paramètres suivants :

security:
...
    firewalls:
...
        main:
...
            guard:
                authenticators:
                    - App\Security\LdapFormAuthenticator
            logout:
                path: app_logout
                # where to redirect after logout
                # target: app_any_route
...

Adapter les fichiers gérénés

Comme indiqué par la commande à la fin de son exécution, il faut modifier le fichier src/Security/LdapFormAuthenticator.php pour l'adapter aux besoins. Voici le fichier généré :

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class LdapFormAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    private $urlGenerator;
    private $csrfTokenManager;

    public function __construct(UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager)
    {
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
    }

    public function supports(Request $request)
    {
        return 'app_login' === $request->attributes->get('_route')
            && $request->isMethod('POST');
    }

    public function getCredentials(Request $request)
    {
        $credentials = [
            'username' => $request->request->get('username'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['username']
        );

        return $credentials;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        // Load / create our user however you need.
        // You can do this by calling the user provider, or with custom logic here.
        $user = $userProvider->loadUserByUsername($credentials['username']);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Username could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        // Check the user's password or other credentials and return true or false
        // If there are no credentials to check, you can just return true
        throw new \Exception('TODO: check the credentials inside '.__FILE__);
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        // For example : return new RedirectResponse($this->urlGenerator->generate('some_route'));
        throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate('app_login');
    }
}

Et voici les modifications à apporter :

  1 <?php
  2 
  3 namespace App\Security;
  4 
  5 use Symfony\Component\HttpFoundation\RedirectResponse;
  6 use Symfony\Component\HttpFoundation\Request;
  7 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  8 use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  9 use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
 10 use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
 11 use Symfony\Component\Security\Core\Security;
 12 use Symfony\Component\Security\Core\User\UserInterface;
 13 use Symfony\Component\Security\Core\User\UserProviderInterface;
 14 use Symfony\Component\Security\Csrf\CsrfToken;
 15 use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
 16 use Symfony\Component\Security\Guard\Authenticator\AbstractFormLoginAuthenticator;
 17 use Symfony\Component\Security\Http\Util\TargetPathTrait;
 18 use Symfony\Component\Ldap\Exception\ConnectionException;
 19 use Symfony\Component\Ldap\Ldap;
 20 
 21 class LdapFormAuthenticator extends AbstractFormLoginAuthenticator
 22 {
 23     use TargetPathTrait;
 24 
 25     private $urlGenerator;
 26     private $csrfTokenManager;
 27     protected $ldap;
 28 
 29     public function __construct(UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, Ldap $ldap)
 30     {
 31         $this->urlGenerator = $urlGenerator;
 32         $this->csrfTokenManager = $csrfTokenManager;
 33         $this->ldap = $ldap;
 34     }
 35 
 36     public function supports(Request $request)
 37     {
 38         return 'app_login' === $request->attributes->get('_route')
 39             && $request->isMethod('POST');
 40     }
 41 
 42     public function getCredentials(Request $request)
 43     {
 44         $credentials = [
 45             'username' => $request->request->get('username'),
 46             'password' => $request->request->get('password'),
 47             'csrf_token' => $request->request->get('_csrf_token'),
 48         ];
 49         $request->getSession()->set(
 50             Security::LAST_USERNAME,
 51             $credentials['username']
 52         );
 53 
 54         return $credentials;
 55     }
 56 
 57     public function getUser($credentials, UserProviderInterface $userProvider)
 58     {
 59         $token = new CsrfToken('authenticate', $credentials['csrf_token']);
 60         if (!$this->csrfTokenManager->isTokenValid($token)) {
 61             throw new InvalidCsrfTokenException();
 62         }
 63 
 64         $user = $userProvider->loadUserByUsername($credentials['username']);
 65 
 66         if (!$user) {
 67             throw new CustomUserMessageAuthenticationException('Username could not be found.');
 68         }
 69 
 70         return $user;
 71     }
 72 
 73     public function checkCredentials($credentials, UserInterface $user)
 74     {
 75         try {
 76             $this->ldap->bind($user->getEntry()->getDn(), $credentials['password']);
 77         } catch (ConnectionException $e) {
 78             return false;
 79         }
 80 
 81         return true;
 82     }
 83 
 84     public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
 85     {
 86         $request->getSession()->getFlashBag()->add(
 87             'info',
 88             'Bienvenue. Vous êtes connecté !'
 89         );
 90 
 91         if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
 92             return new RedirectResponse($targetPath);
 93         }
 94 
 95         return new RedirectResponse($this->urlGenerator->generate('home_index')); // <== Adapter la route selon les besoins
 96     }
 97 
 98     protected function getLoginUrl()
 99     {
100         return $this->urlGenerator->generate('app_login');
101     }
102 }

Mise en place du filtrage et des rôles

Résumé