S'authentifier dans Symfony 6 avec LDAP

SymfonyTransparencyLogo

L’objectif de ce tutoriel est d’apprendre à générer un token et un refresh_token dans Symfony en utilisant un LDAP en tant qu’Identity Provider.

Sommaire

Prérequis

Mise en situation

On va imaginer qu’on a développé une API avec une route /v1/books. Cette route est protégée et accessible uniquement lorsqu’on est authentifié à l’API. Notre besoin est d’accéder à cette route pour récupérer la collection de livre.

Résultat attendu

Utiliser LDAP pour s'authentifier dans Symfony 6 via une API - Schéma général
Illustration d'accès à la route v1/books

Créer le projet

Initions le projet Symfony :

 symfony new ldap_demo
 

On se place ensuite à la racine du projet :

 cd ldap_demo
 

Bundles à installer

Ci-dessous la liste des bundles à installer :

 composer require lexik/jwt-authentication-bundle # Gère la génération du token à l'authentification
composer require doctrine # Gère la base de données
composer require gesdinet/jwt-refresh-token-bundle # Gère la génération du refresh_token
composer require symfony/ldap # Gère la fonctionnalité LDAP dans Symfony
composer require symfony/maker-bundle # Permet de créer des contrôleurs/entités/services plus rapidement
 

Création du contrôleur de sécurité

Grâce au dernier bundle que l’on a installé, on peut créer un contrôleur facilement :

 php bin/console make:controller
 

App\Controller\SecurityController.php

 namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class SecurityController extends AbstractController
{
    /**
     * @Route("/login", name="login", methods={"POST"})
     */
    public function login(): Response
    {
        /* THIS CONTROLLER NEVER RESPOND */
        return $this->json('', Response::HTTP_OK);
    }
}
 

Paramétrer le firewall dans Symfony

On va ajouter un firewall dans la configuration de Symfony :

config\packages\security.yaml

 security:
    # ...
provider:
    # ...
    firewalls:
    # ...
        login:
            pattern: ^/login
            lazy: true # Accepte les requêtes anonymes
            stateless: true
            provider: ldap_server
    # ...
     access_control:
    # ...
        - { path: ^/login, roles: PUBLIC_ACCESS }
 

Configurer le provider

Dans notre fichier de configuration security.yaml, on a déclaré :

 provider: ldap_server
 

Pour que Symfony interprète correctement cette information, on va devoir effectuer 2 actions :

  •  Déclarer le service LDAP
  •  Déclarer le provider

Déclarer le service LDAP

On commence par déclarer le service LDAP :

config\services.yaml

     Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
        tags: ['ldap']
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            -   host: '%env(LDAP_HOST)%'
                port: 389
                #encryption: tls
                options:
                    protocol_version: 3
                    referrals: false
# ...
 

.env

 # ...
LDAP_HOST=XXX.XXX.XXX.XXX
# ...
 

Déclarer le provider

Comme le LDAP est identifié en tant que service par Symfony , on peut déclarer correctement le provider :

config\packages\security.yaml

     providers:
        ldap_server:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: '%env(BASE_DN_LDAP_USER_PROVIDER)%' # Domaine dans le LDAP
                search_dn: '%env(SEARCH_DN_LDAP_USER_PROVIDER)%' # Utilisateur dans l'AD (readonly)
                search_password: '%env(SEARCH_PASSWORD_LDAP_USER_PROVIDER)%' # Mot passe de l'utilisateur dans l'AD (readonly)
                uid_key: sAMAccountName
                default_roles: ROLE_USER
# ...
 

.env

 # ...
BASE_DN_LDAP_USER_PROVIDER="OU=SERVICEQUICONTIENTUTILISATEUR,DC=EXAMPLE,DC=COM" # Unité d'organisation ou Domaine
SEARCH_DN_LDAP_USER_PROVIDER="CN=adm-account-readonly,OU=SERVICEQUICONTIENTUTILISATEUR,DC=EXAMPLE,DC=COM" # Nom d'utilisateur du compte administrateur (readonly)
SEARCH_PASSWORD_LDAP_USER_PROVIDER="password-adm-account-readonly" # Mot de passe du compte administrateur (readonly)
# ...
 

Création d'un authenticator

Initions l’authenticator :

 php bin/console make:auth
 

On va répondre aux questions :

  What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 0

 
  The class name of the authenticator to create (e.g. AppCustomAuthenticator):                    
 
 > customLdapAuthenticator

 
  Which firewall do you want to update? [login]:                      
  [0] login
  [1] main
 > 0


 

La configuration est automatiquement mise à jour par Symfony :

config\packages\security.yaml

     firewalls:
        login:
            pattern: ^/login
            lazy: true # Accepte les requêtes anonymes
            stateless: true
            provider: ldap_server
            custom_authenticator: App\Security\CustomLdapAuthenticator # Ligne ajoutée par Symfony
# ...
 

Code initial

On va effacer le code automatiquement généré dans l’authenticator pour le remplacer par ce contenu :

App\Security\CustomLdapAuthenticator.php

 namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class CustomLdapAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        return false;
    }

    public function authenticate(Request $request): SelfValidatingPassport
    {
        return new SelfValidatingPassport(new UserBadge(''));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return new JsonResponse('');
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse('');
    }
}
 

Implémentation de l'authenticator

Pour implémenter l’authenticator, nous avons besoin de créer quelques éléments :

 

2

Classes

Qui représentent des exceptions

1

Entité

Qui représente un utilisateur

1

Service

Qui va implémenter l'Active Directory.

Création des exceptions

App\Exception\CustomBadRequestException.php

 namespace App\Exception;

use Symfony\Component\Config\Definition\Exception\Exception;

class CustomBadRequestException extends Exception
{
}
 

App\Exception\CustomUnsupportedMediaTypeException.php

 namespace App\Exception;

use Symfony\Component\Config\Definition\Exception\Exception;

class CustomUnsupportedMediaTypeException extends Exception
{
}
 

Création de l'entité "User"

Voici le modèle de l’entité :
 
  • login (string), non nulle
  • roles (json), non nulle
  • nomUser (string), possiblement nulle.
  • prenomUser (string), possiblement nulle.
  • isActive (bool), non nulle.
  • lastLogin (datetime), non nulle.
  • lastRefresh (datetime), possiblement nulle.

On créer l’entité :

 php bin/console make:entity
 

App\Entity\User.php

 namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $login;

    #[ORM\Column(type: 'json')]
    private $roles = [];

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private $nomUser;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private $prenomUser;

    #[ORM\Column(type: 'boolean')]
    private $isActive;

    #[ORM\Column(type: 'datetime')]
    private $lastLogin;

    #[ORM\Column(type: 'datetime', nullable: true)]
    private $lastRefresh;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getLogin(): ?string
    {
        return $this->login;
    }

    public function setLogin(string $login): self
    {
        $this->login = $login;
        return $this;
    }

    public function getRoles(): ?array
    {
        return $this->roles;
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;
        return $this;
    }

    public function getNomUser(): ?string
    {
        return $this->nomUser;
    }

    public function setNomUser(?string $nomUser): self
    {
        $this->nomUser = $nomUser;
        return $this;
    }

    public function getPrenomUser(): ?string
    {
        return $this->prenomUser;
    }

    public function setPrenomUser(?string $prenomUser): self
    {
        $this->prenomUser = $prenomUser;
        return $this;
    }

    public function getIsActive(): ?bool
    {
        return $this->isActive;
    }

    public function setIsActive(bool $isActive): self
    {
        $this->isActive = $isActive;
        return $this;
    }

    public function getLastLogin(): ?\DateTimeInterface
    {
        return $this->lastLogin;
    }

    public function setLastLogin(\DateTimeInterface $lastLogin): self
    {
        $this->lastLogin = $lastLogin;
        return $this;
    }

    public function getLastRefresh(): ?\DateTimeInterface
    {
        return $this->lastRefresh;
    }

    public function setLastRefresh(?\DateTimeInterface $lastRefresh): self
    {
        $this->lastRefresh = $lastRefresh;
        return $this;
    }
}
 

Création du service AD

Il faut commencer par déclarer le service dans la configuration :

config\services.yaml

     App\Service\ActiveDirectory:
        bind:
            $ldapServiceDn: '%env(SERVICE_DN_AD)%'
            $ldapServiceUser: '%env(SERVICE_USER_AD)%'
            $ldapServicePassword: '%env(SERVICE_PASSWORD_AD)%'
# ...
 
Après la configuration, on écrit le code qui implémente le service AD :

App\Service\ActiveDirectory.php

 namespace App\Service;

use Symfony\Component\Ldap\Adapter\ExtLdap\Adapter;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\Ldap;

class ActiveDirectory
{
    public function __construct(
        private Adapter $ldapAdapter,
        private string $ldapServiceDn,
        private string $ldapServiceUser,
        private string $ldapServicePassword,
        private Ldap $ldap,
    ) {
        $this->ldap = new Ldap($this->ldapAdapter);
        $this->ldap->bind(implode(',', [$ldapServiceUser, $ldapServiceDn]), $ldapServicePassword);
    }

    // Récupère un utilisateur AD via le LDAP à partir des informations envoyées
    public function getEntryFromActiveDirectory(string $username, string $password): ?Entry
    {
        $ldap = new Ldap($this->ldapAdapter);
        $search = false;
        $value = null;
        try {
            $ldap->bind(implode(',', ['CN='.$username, $this->ldapServiceDn]), $password);
            if ($this->ldapAdapter->getConnection()->isBound()) {
                $search = $ldap->query(
                    'DC=example,DC=com',
                    '(&(objectClass=user)(| (cn='.$username.')))'
                )->execute()->toArray();
            }
        } catch (ConnectionException) {
            return null;
        }
        if ($search && 1 === count($search)) {
            $value = $search[0];
        }
        return $value;
    }
}
 

Code actualisé

Maintenant que les préparations sont terminées, nous pouvons implémenter l’authenticator :

App\Security\CustomLdapAuthenticator.php

 namespace App\Security;

use App\Entity\User;
use App\Exception\CustomBadRequestException;
use App\Exception\CustomUnsupportedMediaTypeException;
use App\Repository\UserRepository;
use App\Service\ActiveDirectory;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Gesdinet\JWTRefreshTokenBundle\Generator\RefreshTokenGeneratorInterface;
use Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface;
use InvalidArgumentException;
use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTNotFoundEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\JWTEncodeFailureException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\MissingTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class CustomLdapAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    /* AUTOWIRING DES OBJETS PAR SYMFONY */
    public function __construct(
        private ActiveDirectory $activeDirectory,
        private JWTEncoderInterface $encoder,
        private UserRepository $userRepository,
        private EntityManagerInterface $entityManager,
        private RefreshTokenGeneratorInterface $refreshTokenGenerator,
        private RefreshTokenManagerInterface $refreshTokenManager)
    {}

    /* CETTE FONCTION EST APPELLE POUR CHAQUE REQUÊTE, C'EST A NOUS DE DÉCIDER SI ON APPLIQUE LE CONTRÔLE D'IDENTIFICATION OU NON */
    public function supports(Request $request): ?bool
    {
        /* TEST SI LA ROUTE est /login A PARTIR DE L'API  && SI LA METHODE EST DE TYPE POST
			-> SI C'EST LE CAS, ON DECLENCHE L'AUTHENTIFICATION, 
			-> SINON, ON IGNORE L'AUTHENTICATOR 
	*/
        return 'login' === $request->attributes->get('_route') && $request->isMethod(Request::METHOD_POST);
    }

    public function authenticate(Request $request): SelfValidatingPassport
    {
        /* TEST SI LE CONTENT-TYPE EST OK */
        if ('json' != $request->getContentType() || null == $request->getContentType()) {
            throw new CustomUnsupportedMediaTypeException('WRONG CONTENT-TYPE');
        }

        /* ON RECUPERE LES INFOS A PARTIR DU CORPS DE LA REQUÊTE */
        $body = json_decode($request->getContent());
        if (!isset($body->password) || !isset($body->username) || null == $body->username || null == $body->password) {
            throw new CustomBadRequestException('ERROR IN REQUEST');
        }

        $loginFromRequest = $body->username;
        $passwordFromRequest = $body->password;

        /* RECUPERE L'UTILISATEUR DANS LE LDAP (AUTHENTIFIE AU PASSAGE L'UTILISATEUR) */
        $ldapEntry = $this->activeDirectory->getEntryFromActiveDirectory($loginFromRequest, $passwordFromRequest);

        if (null == $ldapEntry) { /* SI ON NE RECUPERE RIEN */
            throw new UserNotFoundException('IMPOSSIBLE TO RETRIEVE THE RESOURCE'); /* ON RENVOIE UN ERREUR D'AUTHENTIFICATION */
        } else { /* SINON L'UTILISATEUR EXISTE DANS LE LDAP */
            $userFromRepo = $this->userRepository->findOneBy(['login' => $loginFromRequest]);  /* ON VERIFIE QU'IL EXISTE EN BDD */   
            if (null == $userFromRepo) { /* SI NON ON LE CREE */
                $userToPersist = new User();
                $userToPersist->setLogin($loginFromRequest);
                try {
                    $userToPersist->setRoles(['ROLE_USER']);
                    $userToPersist->setLastLogin(new DateTime());
                    $userToPersist->setIsActive(true);
                } catch (InvalidArgumentException) {
                    throw new AuthenticationException();
                }
                $this->entityManager->persist($userToPersist);
            } else { /* SI OUI, ON METS UNIQUEMENT A JOUR SA DATE DE DERNIERE CONNEXION */
                $userFromRepo->setLastLogin(new Datetime());
                $this->entityManager->persist($userFromRepo);
            }
            $this->entityManager->flush();
        }
        /* SI TOUT S'EST BIEN PASSE ON RENVOIE UN BADGE AUTORISÉ A PASSER DANS SYMFONY */
        return new SelfValidatingPassport(new UserBadge($loginFromRequest));
    }

    /* CETTE FONCTION EST APPELLÉE SI L'AUTHENTIFICATION A RÉUSSIE */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
	/* ON RECUPERE LES ROLES DE L'UTILISATEUR A PARTIR DE LA BDD */
        try {
            $user = $this->userRepository->findOneBy(['login' => $token->getUserIdentifier()]);
	    /* RECUPERE LES ROLES DE L'UTILISATEUR */
            if (null !== $user) {
                $roles = $user->getRoles();
            } else {
                $roles = null;
            }
		
	    /* ON GENERE LE TOKEN AVEC UNE DUREE DE VIE DE 1200s (20mn) */
            $jwtToken = $this->encoder->encode(['username' => $token->getUserIdentifier(), 'roles' => $roles, 'id' => $user->getId(), 'exp' => time() + 1200]);

            /* ON VA VERIFIER QU'UN REFRESH_TOKEN N'EXISTE PAS DEJA ET SI C'EST LE CAS ON LE RETIRE */

            $refreshToken = $this->refreshTokenManager->getLastFromUsername($token->getUserIdentifier());
            if( null !== $refreshToken) {
                $this->refreshTokenManager->delete($refreshToken);
            }

            /* ON GENERE LE REFRESH TOKEN AVEC UNE DUREE DE VIE DE 30 JOURS */
            $refreshToken = $this->refreshTokenGenerator->createForUserWithTtl($token->getUser(), 2592000);

            /* ON PERSISTE LE REFRESH TOKEN EN BASE */
            $this->entityManager->persist($refreshToken);

	    /* ON ENREGISTRE TOUS LES TOKENS */
            $this->entityManager->flush();
        } catch (JWTEncodeFailureException $JWTEncodeFailureException) {
            return new JsonResponse(['message' => $JWTEncodeFailureException->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
        } catch (Exception $e) {
            return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
        }

	/* ON RENVOIE LE TOKEN A L'UTILISATEUR */
        return new JsonResponse(['token' => $jwtToken, 'refresh_token' => $refreshToken->getRefreshToken()], Response::HTTP_CREATED);
    }

    /* CETTE FONCTION EST APPELLEE SI L'AUTHENTIFICATION A ÉCHOUÉE */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        /* ON GENERE L'ERREUR DE FACON DYNAMIQUE */
        if ($exception instanceof CustomUnsupportedMediaTypeException) {
            $codeResponse = Response::HTTP_UNSUPPORTED_MEDIA_TYPE;
        } elseif ($exception instanceof CustomBadRequestException) {
            $codeResponse = Response::HTTP_BAD_REQUEST;
        } elseif ($exception instanceof UserNotFoundException) {
            $codeResponse = Response::HTTP_NOT_FOUND;
        } else {
            $codeResponse = Response::HTTP_UNAUTHORIZED;
        }
        $data = [
            /* ON RECUPERE LE MESSAGE D'EXCEPTION */
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
        ];

	/* ON RETOURNE LE MESSAGE D'ERREUR A L'UTILISATEUR */
        return new JsonResponse($data, $codeResponse);
    }

    /* CETTE FONCTION EST APPELLÉE SI L'UTILISATEUR CONSOMME UNE ROUTE PROTEGEE, SANS FOURNIR D'INFORMATION D'AUTHENTIFICATION */
    public function start(Request $request, AuthenticationException $authException = null): ?Response
    {
        $exception = new MissingTokenException('JWT Token not found', 0, $authException);
        $event = new JWTNotFoundEvent($exception, new JWTAuthenticationFailureResponse($exception->getMessageKey()));
        return $event->getResponse();
    }
}
 

Configurer les clés JWT

Notre code principal est terminé pour le moment, il ne reste qu’à générer les clés JWT :
 php bin/console lexik:jwt:generate-keypair
 

Générer la base de données

On va se servir des fonctionnalités de Symfony pour générer la base de données :
 php bin/console d:s:d --force
php bin/console d:s:c
 
La base de données devrait ressembler à ceci :
database_ldap_demo

Authentification à l'API

Notre application est paramétrée, la route /login est censée nous répondre par un token et un refresh_token lorsqu’on la consomme avec un username et un password qui existe dans l’Active Directory.

Démarrer l'API

Il faut commencer par démarrer l’API :

 symfony server:start --no-tls
 

Créer le token avec Postman

On va essayer de consommer la route http://127.0.0.1:8000/login pour récupérer nos tokens :

loginuserwithldap
Illustration de la requête dans Postman

Résultat

tokenresponsefromapi

Exposer la collection de livre

On souhaite exposer une  ressource qui expose la collection de livre, pour ça il faut générer un nouveau contrôleur :

 php bin/console make:controller
 

App\Controller\BookController.php

 namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class BookController extends AbstractController
{
    /**
     * @Route("/v1/books", name="getAllBooks", methods={"GET"})
     */
    public function getAllBooks(): Response
    {
        return $this->json([[ "id" => 1, "nomLivre" => "nomLivre1", "nombrePageLivre1" => 10], [ "id" => 2, "nomLivre" => "nomLivre2", "nombrePageLivre2" => 15 ]]);
    }
}
 

Sécuriser l'accès à l'API

On adapte ensuite le firewall “main” pour indiquer le comportement à avoir lorsqu’un utilisateur consomme une route qui commence par /v1/ :

config\packages\security.yaml

 main:
    stateless: true # Stateless veut dire qu'on va essayer d'authentifier le consommateur de la requête à chaque fois.
    pattern: ^/(v1/*) # On sécurise toutes les routes qui comment par /v1/
    provider: ldap_server # Le user provider est notre LDAP
    entry_point: App\Security\CustomLdapAuthenticator # Le point d'entrée est l'authenticator custom que l'on a crée, il est appelé si aucun token n'est détécté lorsque la requête s'exécute.
# ...
 

En complément, on va ajouter un contrôle d’accès sur les routes  :

config\packages\security.yaml

     access_control:
        - { path: ^/login, roles: PUBLIC_ACCESS } # La route /login est accessible pour tout le monde
        - { path: ^/v1/*, roles: ROLE_USER} # Toutes les routes qui commencent par /v1/ sont soumise à la condition que l'utilisateur aie au moins le rôle ROLE_USER pour accéder à la ressource
# ...
 

Accéder à la ressource protégée

Tout est maintenant configuré, on va essayer de consommer la route http://127.0.0.1:8000/v1/books avec notre token (Il faut le passer en tant que Bearer Token) :
NotAuthorizedRequest
Illustration de la consommation de la route /v1/books

Créer le nouvel authenticator

On va générer un nouvel authenticator :
 php bin/console make:auth
 

App\Security\CustomJWTAuthenticator.php

 namespace App\Security;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
use Lexik\Bundle\JWTAuthenticationBundle\Events;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\ExpiredTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidPayloadException;
use Lexik\Bundle\JWTAuthenticationBundle\Exception\InvalidTokenException;
use Lexik\Bundle\JWTAuthenticationBundle\Response\JWTAuthenticationFailureResponse;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\TokenExtractor\TokenExtractorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

class CustomJWTAuthenticator extends AbstractAuthenticator
{
    /** AUTOWIRING PAR SYMFONY  */
    public function __construct(
        private JWTTokenManagerInterface $jwtManager,
        private EventDispatcherInterface $eventDispatcher,
        private TokenExtractorInterface $tokenExtractor,
        private UserProviderInterface $userProvider)
    {}

    /* CONTRÔLE SI ON DÉCLENCHE L'AUTHENTIFICATION OU NON -> BASÉ SUR L'EXISTENCE DU TOKEN DANS LA REQUÊTE */
    public function supports(Request $request): ?bool
    {
        return false !== $this->getTokenExtractor()->extract($request);
    }

    /* SI LE TOKEN EXISTE, ALORS ON VA VERIFIER QU'ON RETROUVE BIEN NOTRE USER DANS L'AD */
    public function authenticate(Request $request): SelfValidatingPassport
    {
        /* RÉCUPERE LE TOKEN A PARTIR DE LA REQUÊTE */
        $token = $this->getTokenExtractor()->extract($request);

        if (!$payload = $this->jwtManager->parse($token)) {
            throw new InvalidTokenException('Invalid JWT Token');
        }

        /* RÉCUPERE L'IDENTIFIANT DE L'UTILISATEUR */
        $idClaim = $this->jwtManager->getUserIdClaim();
        if (!isset($payload[$idClaim])) {
            throw new InvalidPayloadException($idClaim);
        }

        /* GENERE AUTOMATIQUEMENT LE BADGE POUR PASSER LA SECURITÉ DE SYMFONY */
        $passport = new SelfValidatingPassport(
            new UserBadge($payload[$idClaim],
                function ($userIdentifier) { /* CALLBACK QUI CONTACTE LE LDAP POUR CHERCHER L'UTILISATEUR DANS L'AD, LE SYSTEME VA AUTOMATIQUEMENT CHERCHER VIA LE LDAP CAR ON A CONFIGURÉ LE PROVIDER A "ldap_server" DANS LE FIREWALL "main" */
                    return $this->userProvider->loadUserByIdentifier($userIdentifier);
                })
        );

        /* ON AJOUTE LE CONTENU DE LA REPONSE AU PAYLOAD DU PASSPORT POUR QUE SYMFONY PUISSE FONCTIONNER */
        $passport->setAttribute('payload', $payload);

        return $passport;
    }

    /* SI L'AUTHENTIFICATION EST UN SUCCESS, ALORS ON LAISSE PASSER LA REQUÊTE SANS RIEN FAIRE  */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }


    /* SI L'AUTHENTIFICATION EST UN ÉCHEC */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        /* ON GÉNÉRE UN MESSAGE D'ERREUR CLAIR */
        $errorMessage = strtr($exception->getMessageKey(), $exception->getMessageData());
        $response = new JWTAuthenticationFailureResponse($errorMessage);
        if ($exception instanceof ExpiredTokenException) {
            $event = new JWTExpiredEvent($exception, $response);
            $eventName = Events::JWT_EXPIRED;
        } else {
            $event = new JWTInvalidEvent($exception, $response);
            $eventName = Events::JWT_INVALID;
        }
        $this->eventDispatcher->dispatch($event, $eventName);
        return $event->getResponse();
    }

    /* FONCTION QUI RETOURNE LE TOKEN EXTRACTOR POUR EXTRAIRE LE TOKEN DE LA REQUÊTE */
    public function getTokenExtractor(): ?TokenExtractorInterface
    {
        return $this->tokenExtractor;
    }
}
 

Accès final

Cette fois, notre configuration est complète, on va pouvoir réessayer de consommer la route http://127.0.0.1:8000/v1/books  :
getrequestbookcollection

18 réflexions au sujet de « S’authentifier dans Symfony 6 avec LDAP »

  1. Salut, merci pour ce tuto très clair

    j’ai essayé de le mettre en place, j’ai eu aucun soucis jusqu’à la fin avec le CustomJWTAuthenticator.

    Cannot autowire service “App\Security\CustomJWTAuthenticator”: argument “$userProvider” of method “__construct()” references interface “Symfony\Component\Security\Core\User\UserProviderInterface” but no such service exists.

    1. A priori, vu le message d’erreur que tu obtiens, ton injection de service est mal configuré dans le fichier services.yaml ou ton userProvider est mal déclaré dans security.yaml!

  2. Bonjour,

    Merci pour le tuto mais j’ai un souci.
    La connexion fonctionne parfaitement mais j’ai un souci lorsque j’utilise le token.
    Symfony ne rentre jamais dans “CustomJWTAuthenticator”.
    J’ai un message d’erreur : {“code”:401,”message”:”JWT Token not found”} qui vient de “CustomLdapAuthenticator” dans la fonction start.
    Ce qui me semble logique.
    Aurais-tu une idée de mon erreur ?
    Merci

    1. Bonjour Laurent,

      C’est parce qu’il manque un bout dans le tutoriel (c’est ma faute, j’ai oublié de la rajouter, il faudra que je mette à jour cet article), voici en gros :

      https://github.com/markitosgv/JWTRefreshTokenBundle#step-4-symfony-54

      Et ce qu’il faut faire ensuite :

      1) Il faut définir une route de refresh dans la config/routes.yml
      2) Et après t’as juste a renseigner ça dans le security.yaml :

      refresh_jwt: check_path: /api/token/refresh # or, you may use the `api_refresh_token` route name

      Mes excuses pour le temps de réponse, j’ai été débordé de travail ces derniers temps.

  3. Super ! Merci beaucoup !
    j’ai un problème consternent les trois variables, SERVICE_DN_AD, SERVICE_USER_AD, SERVICE_PASSWORD_AD, c’est quoi le contenues des c’est trois variable

    1. Bonjour !

      Les valeurs à renseigner dans des constantes viennent de l’active directory, l’url de connexion à l’AD ressemble à quelquechose comme ça je crois (SERVICE_DN_AD) = DN=localhost,DOMAIN=local…etc , sinon pour les constantes SERVICE_USER_AD, ça va être un nom d’utilisateur de l’AD et la constante SERVICE_PASSWORD_AD, c’est le mot de passe du user dans l’AD (de mémoire l’user doit avoir des accès admin)

      1. Bonjour!
        J’ai un problème de credentials lié aux variables SERVICE_DN_AD, SERVICE_USER_AD et SERVICE_PASSWORD_AD. que faut-il exactement mettre dans ces variables?
        Merci!

        1. Bonjour !

          Les valeurs à renseigner dans des constantes viennent de l’active directory, l’url de connexion à l’AD ressemble à quelquechose comme ça je crois (SERVICE_DN_AD) = DN=localhost,DOMAIN=local…etc , sinon pour les constantes SERVICE_USER_AD, ça va être un nom d’utilisateur de l’AD et la constante SERVICE_PASSWORD_AD, c’est le mot de passe du user dans l’AD (de mémoire l’user doit avoir des accès admin)

  4. mercii pour votre tuto mais j’aimerai savoir quel est le systeme d’exploitation utilise ici ?

  5. merci beaucoup pour ce tuto détaillé. Je suis à l’étape Code actualisé et j’ai cette erreur: Cannot autowire service “App\Security\CustomLdapAuthenticator”: argument “$refreshTokenGenerator” of method “__construct()” references interface “Gesdinet\JWTRefreshTokenBundle\Generator\RefreshTokenGeneratorInterface” but no such service exists. Did you create a class that implements this interface?

    1. Bonjour,

      Je t’invites à regarder ma réponse à Laurent plus haut, je pense que c’est le même problème que tu rencontres (bien que tu aies un message d’erreur différents).

      Bonne journée.

  6. Bonjour,
    Super tuto merci beaucoup. Je bloque par contre sur le même problème que sam et Hugo :
    Cannot autowire service “App\Security\CustomLdapAuthenticator”: argument “$refreshTokenGenerator” of method “__construct()” references interface “Gesdinet\JWTRefreshTokenBundle\Generator\RefreshTokenGeneratorInterfa
    ce” but no such service exists. Did you create a class that implements this interface?

    Voici mon fichier services.yaml :
    parameters:

    services:
    # default configuration for services in *this* file
    _defaults:
    autowire: true # Automatically injects dependencies in your services.
    autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
    resource: ‘../src/’
    exclude:
    – ‘../src/DependencyInjection/’
    – ‘../src/Entity/’
    – ‘../src/Kernel.php’
    Symfony\Component\Ldap\Ldap:
    arguments: [‘@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter’]
    tags: [‘ldap’]
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
    arguments:
    – host: ‘%env(LDAP_HOST)%’
    port: 389
    #encryption: tls
    options:
    protocol_version: 3
    referrals: false
    App\Service\ActiveDirectory:
    bind:
    $ldapServiceDn: ‘%env(SERVICE_DN_AD)%’
    $ldapServiceUser: ‘%env(SERVICE_USER_AD)%’
    $ldapServicePassword: ‘%env(SERVICE_PASSWORD_AD)%’

    et mon fichier security.yaml :
    security:
    password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: ‘auto’
    providers:
    ldap_server:
    ldap:
    service: Symfony\Component\Ldap\Ldap
    base_dn: ‘%env(BASE_DN_LDAP_USER_PROVIDER)%’ # Domaine dans le LDAP
    search_dn: ‘%env(SEARCH_DN_LDAP_USER_PROVIDER)%’ # Utilisateur dans l’AD (readonly)
    search_password: ‘%env(SEARCH_PASSWORD_LDAP_USER_PROVIDER)%’ # Mot passe de l’utilisateur dans l’AD (readonly)
    uid_key: sAMAccountName
    default_roles: ROLE_USER

    firewalls:
    dev:
    pattern: ^/(_(profiler|wdt)|css|images|js)/
    security: false
    main:
    lazy: true
    provider: ldap_server
    custom_authenticator: App\Security\CustomLdapAuthenticator
    login:
    pattern: ^/login
    lazy: true # Accepte les requêtes anonymes
    stateless: true
    provider: ldap_server

    access_control:
    – { path: ^/login, roles: PUBLIC_ACCESS }

    y’a t il quelque chose que j’ai loupé? Merci beaucoup.

    Bonne journée

    1. Bonjour Krish, voici ce que j’avais répondu plus haut pour ce problème :

      —–
      C’est parce qu’il manque un bout dans le tutoriel (c’est ma faute, j’ai oublié de la rajouter, il faudra que je mette à jour cet article), voici en gros :

      https://github.com/markitosgv/JWTRefreshTokenBundle#step-4-symfony-54

      Et ce qu’il faut faire ensuite :

      1) Il faut définir une route de refresh dans la config/routes.yml
      2) Et après t’as juste a renseigner ça dans le security.yaml :

      refresh_jwt: check_path: /api/token/refresh # or, you may use the `api_refresh_token` route name
      —-

  7. Bonjour,
    J’ai essayé d’implémenter en suivant à la lettre le tuto, mais le token n’est pas généré.
    Je tombe sur ‘ ‘ juste
    #[Route(path: ‘/login’, name: ‘login’, methods: [‘POST’])]
    public function login(): Response
    {
    /* THIS CONTROLLER NEVER RESPOND */
    return $this->json(‘ICI’, Response::HTTP_OK);
    }
    j’ai donc ‘ICI’ quand je lance la requête sur postman

    Il faut changer cette route?

    Merci d’avance

    1. Bonjour Mathieu, je me demande s’il te manque pas un module Symfony pour que l’authenticator fonctionne !

      Bon courage !
      Sinon peut-être un réglage dans security.yaml

      Et bonne journée !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *