Symfony 5: ldap authentication with custom user entity
Asked Answered
C

2

5

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
Constant answered 9/10, 2020 at 12:42 Comment(0)
C
6

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;
        }
    }
Constant answered 13/10, 2020 at 10:8 Comment(2)
I'm struggling with this too, but I was under the impression that this was what Symfony provided Symfony\Component\Ldap\Security\LdapUserProvider for - so we wouldn't have to implement our own version.Infeudation
I too am struggling with this. I work with 5.3 and have been trying to follow the documentation but I don't understand how to use properly the extra_fields attribute. I can't find much info on it. I'm not confortable with OP's solution as guard authentication seems to be deprecated since 5.3Wisniewski
D
0

For Symfony 6 I do like this.

No extra implementation

security:
role_hierarchy:
    ROLE_USER: ROLE_USER
    ROLE_ADMIN: [ROLE_USER, ROLE_ADMIN]
    ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

providers:
    pmh_db:
        entity:
            class: App\Entity\User
            property: username

    pmh_ldap:
        ldap:
            service: Symfony\Component\Ldap\Ldap
            base_dn: '%base_dn%'
            search_dn: '%search_dn%'
            search_password: '%search_password%'
            default_roles: 'ROLE_USER'
            uid_key: '%uid_key%'
            extra_fields: ['email']

firewalls:
    dev:
        pattern: ^/(_(profiler|wdt)|css|images|js)/
        security: false

    main:
        lazy: true
        pattern: ^/
        provider: pmh_db
        switch_user: { role: ROLE_ALLOWED_TO_SWITCH }

        login_throttling:
            max_attempts: 5

        form_login_ldap:
            login_path: app_login
            check_path: app_login
            service: Symfony\Component\Ldap\Ldap
            dn_string: 'DOMAIN\{username}'
            query_string: null
            default_target_path: /
        logout:
            path: /logout
            target: /
        remember_me:
            secret:   '%kernel.secret%'
            lifetime: 604800 # 1 week in seconds
            path:     /
            # by default, the feature is enabled by checking a
            # checkbox in the login form (see below), uncomment the
            # following line to always enable it.
            always_remember_me: true

# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
    - { path: '^/login', roles: PUBLIC_ACCESS }
    - { path: '^/admin', roles: [IS_AUTHENTICATED_FULLY, ROLE_ADMIN] }
    - { path: '^/', roles: ROLE_USER }
Derby answered 27/7, 2022 at 19:26 Comment(3)
I don't understand how it can create a user in your DB, how it can link a User object instead of a LdapUser ? You don't even use your pmh_ldap provider.Caird
@Caird it's working like that for the folloing scenary... user have to be registered in the database but when login I check Ldap username/password. if you like I can post my lastest working setupDerby
OK got it ! But the question was not that, that's why I didn't understand your answer. Author asked for creation of user in local after login check in LDAP. I fount a solution since then :)Caird

© 2022 - 2024 — McMap. All rights reserved.