Search code examples
symfonyldapsymfony5

Symfony 5: ldap authentication with custom user entity


I want to implement the following authentication scenario in symfony 5:

  • User sends a login form with username and password, authentication is processed against an LDAP server
    • if authentication against the LDAP server is successful :
      • if there is an instance of my App\Entity\User that as the same username as the ldap matching entry, refresh some of its attributes from the ldap server and return this entity
      • if there is no instance create a new instance of my App\Entity\User and return it

I have implemented a guard authenticator which authenticates well against the LDAP server but it's returning me an instance of Symfony\Component\Ldap\Security\LdapUser and I don't know how to use this object to make relation with others entities!

For instance, let's say I have a Car entity with an owner property that must be a reference to an user.

How can I manage that ?

Here is the code of my security.yaml file:

security:
    encoders:
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
        my_ldap:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: "%env(LDAP_BASE_DN)%"
                search_dn: "%env(LDAP_SEARCH_DN)%"
                search_password: "%env(LDAP_SEARCH_PASSWORD)%"
                default_roles: ROLE_USER
                uid_key: uid
                extra_fields: ['mail']
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            lazy: true
            provider: my_ldap
            guard:
                authenticators:
                    - App\Security\LdapFormAuthenticator

Solution

  • I finally found a good working solution. The missing piece was a custom user provider. This user provider has the responsibility to authenticate user against ldap and to return the matching App\Entity\User entity. This is done in getUserEntityCheckedFromLdap method of LdapUserProvider class.

    If there is no instance of App\Entity\User saved in the database, the custom user provider will instantiate one and persist it. This is the first user connection use case.

    Full code is available in this public github repository.

    You will find below, the detailed steps I follow to make the ldap connection work.

    So, let's declare the custom user provider in security.yaml.

    security.yaml:

        providers:
            ldap_user_provider:
                id: App\Security\LdapUserProvider
    

    Now, configure it as a service, to pass some ldap usefull string arguments in services.yaml. Note since we are going to autowire the Symfony\Component\Ldap\Ldap service, let's add this service configuration too: services.yaml:

    #see https://symfony.com/doc/current/security/ldap.html
      Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
      Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
          -   host: ldap
              port: 389
    #          encryption: tls
              options:
                protocol_version: 3
                referrals: false
    
      App\Security\LdapUserProvider:
        arguments:
          $ldapBaseDn: '%env(LDAP_BASE_DN)%'
          $ldapSearchDn: '%env(LDAP_SEARCH_DN)%'
          $ldapSearchPassword: '%env(LDAP_SEARCH_PASSWORD)%'
          $ldapSearchDnString:  '%env(LDAP_SEARCH_DN_STRING)%'
    

    Note the arguments of the App\Security\LdapUserProvider come from env vars.

    .env:

    LDAP_URL=ldap://ldap:389
    LDAP_BASE_DN=dc=mycorp,dc=com
    LDAP_SEARCH_DN=cn=admin,dc=mycorp,dc=com
    LDAP_SEARCH_PASSWORD=s3cr3tpassw0rd
    LDAP_SEARCH_DN_STRING='uid=%s,ou=People,dc=mycorp,dc=com'
    

    Implement the custom user provider : App\Security\LdapUserProvider:

    <?php
    
        namespace App\Security;
    
        use App\Entity\User;
        use Doctrine\ORM\EntityManager;
        use Doctrine\ORM\EntityManagerInterface;
        use Symfony\Component\Ldap\Ldap;
        use Symfony\Component\Ldap\LdapInterface;
        use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
        use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
        use Symfony\Component\Security\Core\User\UserInterface;
        use Symfony\Component\Security\Core\User\UserProviderInterface;
    
        class LdapUserProvider implements UserProviderInterface
        {
            /**
             * @var Ldap
             */
            private $ldap;
            /**
             * @var EntityManager
             */
            private $entityManager;
            /**
             * @var string
             */
            private $ldapSearchDn;
            /**
             * @var string
             */
            private $ldapSearchPassword;
            /**
             * @var string
             */
            private $ldapBaseDn;
            /**
             * @var string
             */
            private $ldapSearchDnString;
    
    
            public function __construct(EntityManagerInterface $entityManager, Ldap $ldap, string $ldapSearchDn, string $ldapSearchPassword, string $ldapBaseDn, string $ldapSearchDnString)
            {
            $this->ldap = $ldap;
            $this->entityManager = $entityManager;
            $this->ldapSearchDn = $ldapSearchDn;
            $this->ldapSearchPassword = $ldapSearchPassword;
            $this->ldapBaseDn = $ldapBaseDn;
            $this->ldapSearchDnString = $ldapSearchDnString;
            }
    
            /**
             * @param string $username
             * @return UserInterface|void
             * @see getUserEntityCheckedFromLdap(string $username, string $password)
             */
            public function loadUserByUsername($username)
            {
            // must be present because UserProviders must implement UserProviderInterface
            }
    
            /**
             * search user against ldap and returns the matching App\Entity\User. The $user entity will be created if not exists.
             * @param string $username
             * @param string $password
             * @return User|object|null
             */
            public function getUserEntityCheckedFromLdap(string $username, string $password)
            {
            $this->ldap->bind(sprintf($this->ldapSearchDnString, $username), $password);
            $username = $this->ldap->escape($username, '', LdapInterface::ESCAPE_FILTER);
            $search = $this->ldap->query($this->ldapBaseDn, 'uid=' . $username);
            $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');
            }
            $ldapEntry = $entries[0];
            $userRepository = $this->entityManager->getRepository('App\Entity\User');
            if (!$user = $userRepository->findOneBy(['userName' => $username])) {
                $user = new User();
                $user->setUserName($username);
                $user->setEmail($ldapEntry->getAttribute('mail')[0]);
                $this->entityManager->persist($user);
                $this->entityManager->flush();
            }
            return $user;
            }
    
            /**
             * Refreshes the user after being reloaded from the session.
             *
             * When a user is logged in, at the beginning of each request, the
             * User object is loaded from the session and then this method is
             * called. Your job is to make sure the user's data is still fresh by,
             * for example, re-querying for fresh User data.
             *
             * If your firewall is "stateless: true" (for a pure API), this
             * method is not called.
             *
             * @return UserInterface
             */
            public function refreshUser(UserInterface $user)
            {
            if (!$user instanceof User) {
                throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
            }
            return $user;
    
            // Return a User object after making sure its data is "fresh".
            // Or throw a UsernameNotFoundException if the user no longer exists.
            throw new \Exception('TODO: fill in refreshUser() inside ' . __FILE__);
            }
    
            /**
             * Tells Symfony to use this provider for this User class.
             */
            public function supportsClass($class)
            {
            return User::class === $class || is_subclass_of($class, User::class);
            }
        }
    

    Configure the firewall to use our custom user provider:

    security.yaml

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            lazy: true
            provider: ldap_user_provider
            logout:
                path:   app_logout
            guard:
                authenticators:
                    - App\Security\LdapFormAuthenticator
    

    Write an authentication guard:

    App\SecurityLdapFormAuthenticator:

    <?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();
            }
            $user = $userProvider->getUserEntityCheckedFromLdap($credentials['username'], $credentials['password']);
            if (!$user) {
                throw new CustomUserMessageAuthenticationException('Username could not be found.');
            }
            return $user;
        }
    
    
        public function checkCredentials($credentials, UserInterface $user)
        {
            //in this scenario, this method is by-passed since user authentication need to be managed before in getUser method.
            return true;
        }
    
    
        public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
        {
            $request->getSession()->getFlashBag()->add('info', 'connected!');
            if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
                return new RedirectResponse($targetPath);
            }
            return new RedirectResponse($this->urlGenerator->generate('app_homepage'));
        }
    
        protected function getLoginUrl()
        {
            return $this->urlGenerator->generate('app_login');
        }
    }
    

    My user entity looks like this:

    `App\Entity\User`: 
    
        <?php
    
        namespace App\Entity;
    
        use App\Repository\UserRepository;
        use Doctrine\ORM\Mapping as ORM;
        use Symfony\Component\Security\Core\User\UserInterface;
    
        /**
         * @ORM\Entity(repositoryClass=UserRepository::class)
         */
        class User implements UserInterface
        {
            /**
             * @ORM\Id()
             * @ORM\GeneratedValue()
             * @ORM\Column(type="integer")
             */
            private $id;
    
            /**
             * @ORM\Column(type="string", length=180, unique=true)
             */
            private $email;
    
            /**
             * @var string The hashed password
             * @ORM\Column(type="string")
             */
            private $password = 'password is not managed in entity but in ldap';
    
            /**
             * @ORM\Column(type="string", length=255)
             */
            private $userName;
    
            /**
             * @ORM\Column(type="json")
             */
            private $roles = [];
    
    
            public function getId(): ?int
            {
            return $this->id;
            }
    
            public function getEmail(): ?string
            {
            return $this->email;
            }
    
            public function setEmail(string $email): self
            {
            $this->email = $email;
    
            return $this;
            }
    
            /**
             * A visual identifier that represents this user.
             *
             * @see UserInterface
             */
            public function getUsername(): string
            {
            return (string) $this->email;
            }
    
            /**
             * @see UserInterface
             */
            public function getRoles(): array
            {
            $roles = $this->roles;
            // guarantee every user at least has ROLE_USER
            $roles[] = 'ROLE_USER';
    
            return array_unique($roles);
            }
    
            public function setRoles(array $roles): self
            {
            $this->roles = $roles;
    
            return $this;
            }
    
            /**
             * @see UserInterface
             */
            public function getPassword(): string
            {
            return (string) $this->password;
            }
    
            public function setPassword(string $password): self
            {
            $this->password = $password;
    
            return $this;
            }
    
            /**
             * @see UserInterface
             */
            public function getSalt()
            {
            // not needed when using the "bcrypt" algorithm in security.yaml
            }
    
            /**
             * @see UserInterface
             */
            public function eraseCredentials()
            {
            // If you store any temporary, sensitive data on the user, clear it here
            // $this->plainPassword = null;
            }
    
            public function setUserName(string $userName): self
            {
            $this->userName = $userName;
    
            return $this;
            }
        }