Search code examples
symfonyldap

Symfony LDAP with custom User Entity and auto creation of DB user


I am trying to implement a simple LDAP authentication in my Symfony application.

A user should first be authenticated against LDAP, whereby a custom user entity should be returned from the database.

If a user is not in the database but could be authenticated successfully, I want to create the user.

Except for the automatic creation of the user in the database, it works so far.

providers:
        users_db:
            entity:
                # the class of the entity that represents users
                class: 'App\Entity\User'
                # the property to query by - e.g. email, username, etc
                property: 'username'
        users_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
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: users_db
            http_basic_ldap:
                service: Symfony\Component\Ldap\Ldap
                dn_string: '%env(LDAP_DN_STRING)%'

With the above configuration, the auth runs against LDAP but the user comes from the DB. So if I have not created a corresponding user, the login attempt won't work.

I have tried the automatic creation of users via a UserChecker, a UserProvider and a LoginEventListener (listens to event onAuthenticationSuccess), unfortunately without success.

The onAuthenticationSuccess event is only called after successful authentication and can therefore not be used in the configuration described above, because the users_db provider does not (yet) contain the user, even if the LDAP Basic auth works.

Then I tried it with a UserChecker and chained provider [users_ldap, users_db]. This is also executed, but then I no longer get a user object but an LDAP user. So I tried to create my own UserProvider, unfortunately without success.

If anyone knows a good way to do this, I would appreciate an answer or a short comment. Thank you!


Solution

  • Thanks to this answer I now got it working.

    config/services.yaml

    
    services:
    
      App\Security\UserProvider:
        arguments:
          $em: '@Doctrine\ORM\EntityManagerInterface'
          $ldap: '@Symfony\Component\Ldap\Ldap'
          $baseDn: "%env(LDAP_BASE_DN)%"
          $searchDn: "%env(LDAP_SEARCH_DN)%"
          $searchPassword: "%env(LDAP_SEARCH_PASSWORD)%"
          $defaultRoles: ["ROLE_USER"]
          $uidKey: "uid"
          $extraFields: []
    
      App\EventListener\LoginListener:
        arguments:
          - "@doctrine.orm.entity_manager"
    
    

    config/packages/security.yml

    security:
        enable_authenticator_manager: true
        password_hashers:
            App\Entity\User: 'auto'
        providers:
            users:
                id: App\Security\UserProvider
        firewalls:
            dev:
                pattern: ^/(_(profiler|wdt)|css|images|js)/
                security: false
            main:
                lazy: true
                provider: users
                stateless: false
                http_basic_ldap:
                    service: Symfony\Component\Ldap\Ldap
                    dn_string: 'uid={username},ou=accounts,dc=example,dc=com'
                
    

    src/Security/UserProvider.php

    <?php
    
    namespace App\Security;
    
    use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
    use Symfony\Component\Security\Core\Exception\UserNotFoundException;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Ldap\Security\LdapUserProvider;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    use App\Entity\User;
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\Ldap\Ldap;
    use Symfony\Component\Ldap\Security\LdapUser;
    
    class UserProvider extends LdapUserProvider
    {
      private $em;
    
      public function __construct(EntityManagerInterface $em, Ldap $ldap, string $baseDn, string $searchDn = null, string $searchPassword = null, array $defaultRoles = [], string $uidKey = null, string $filter = null, string $passwordAttribute = null, array $extraFields = [])
      {
        parent::__construct($ldap, $baseDn, $searchDn, $searchPassword, $defaultRoles, $uidKey, $filter, $passwordAttribute, $extraFields);
        $this->em = $em;
      }
    
      /**
       * Refreshes the user after being reloaded from the session.
       *
       * @return UserInterface
       */
      public function refreshUser(UserInterface $user)
      {
        if (!$user instanceof User) {
          throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }
        $refreshUser = $this->em->getRepository(User::class)->findOneBy(['username' => $user->getUserIdentifier()]);
    
        return $refreshUser;
      }
    
      /**
       * Tells Symfony to use this provider for this User class.
       */
      public function supportsClass(string $class): bool
      {
        return User::class === $class || is_subclass_of($class, User::class) || LdapUser::class === $class || is_subclass_of($class, LdapUser::class);
      }
    }
    

    src/EventListener/LoginListener.php

    <?php
    
    namespace App\EventListener;
    
    use App\Entity\User;
    use Doctrine\ORM\EntityManager;
    use Symfony\Component\EventDispatcher\EventSubscriberInterface;
    use Symfony\Component\Ldap\Security\LdapUser;
    use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
    use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
    use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
    
    class LoginListener implements EventSubscriberInterface
    {
    
      private $em;
      private $tokenStorage;
    
      function __construct(EntityManager $em, TokenStorageInterface $tokenStorage)
      {
        $this->em = $em;
        $this->tokenStorage = $tokenStorage;
      }
    
      public static function getSubscribedEvents(): array
      {
        return [LoginSuccessEvent::class => 'onLoginSuccess'];
      }
    
      public function onLoginSuccess(LoginSuccessEvent $loginSuccessEvent)
      {
    
        $ldapUser = $loginSuccessEvent->getAuthenticatedToken()->getUser();
        if (!($ldapUser instanceof LdapUser)) {
          return;
        }
    
        $localUser = $this->em->getRepository(User::class)->findOneBy(['username' => $ldapUser->getUserIdentifier()]);
    
        if (!$localUser) {
          // No local user found in database -> create new user
          $localUser = new User();
          $localUser->setUsername($ldapUser->getUserIdentifier());
        }
        // We don't store user passwords -> generate random token
        $rmdBytes = random_bytes(32);
        $localUser->setPassword($rmdBytes);
    
        $this->em->persist($localUser);
        $this->em->flush();
    
        // Login user
        $token = new UsernamePasswordToken($localUser, $rmdBytes, 'main', $localUser->getRoles());
        $this->tokenStorage->setToken($token);
      }
    }
    

    I also implemented EquatableInterface in the User entity as suggested in the referenced stackoverflow.