4

I'm using Symfony 5.1 and trying to implement a LDAP Authentication, while the User Properties (Username, Roles, etc.) are stored in a MySQL DB. Thus I added a User Entity for Doctrine and configurated the services.yml and security.yml corresponding to the Documentation.

I also used the Maker Bundle to generate a LoginFormAuthenticator which seems to use the Guard Authenticator Module.

When I'm trying to login it simply looks like it is not doing anything LDAP related. I also listened the TCP packages with tcpdump and didn't see any traffic to the LDAP server.

Here is my code:

services.yml:

services:
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    App\:
        resource: '../src/*'
        exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

    App\Controller\:
        resource: '../src/Controller'
        tags: ['controller.service_arguments']

    Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            -   host: <ldap-IP>
                port: 389
                options:
                    protocol_version: 3
                    referrals: false

security.yml:

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

        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

        my_ldap:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: "<base_dn>"
                search_dn: "<search_dn>"
                search_password: "<password>"
                default_roles: ROLE_USER
                uid_key: sAMAccountName

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            lazy: true
            provider: my_ldap

            form_login_ldap:
                login_path: login
                check_path: login
                service: Symfony\Component\Ldap\Ldap
                dn_string: 'uid={username},OU=Test,DC=domain,DC=domain'

            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            logout:
                path: app_logout
                # where to redirect after logout
                target: index

    access_control:
        - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

The LoginFormAuthenticator, I guess the issue lies here within the checkCredentials function. I found the LdapBindAuthenticationProvider class which's purpose seems to be exactly such user credential checking agains LDAP, but I'm totally unsure how I have to do it:

<?php

namespace App\Security;

use Psr\Log\LoggerInterface;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
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\Encoder\UserPasswordEncoderInterface;
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\Guard\PasswordAuthenticatedInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Component\Ldap\Ldap;
use Symfony\Component\Security\Core\Authentication\Provider\LdapBindAuthenticationProvider;

class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'app_login';

    private $logger;
    private $entityManager;
    private $urlGenerator;
    private $csrfTokenManager;
    private $passwordEncoder;
    private $ldap;

    public function __construct(LoggerInterface $logger, EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder, Ldap $ldap, LdapBindAuthenticationProvider $form_login_ldap)
    {
        $this->logger = $logger;
        $this->entityManager = $entityManager;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordEncoder = $passwordEncoder;
        $this->ldap = $ldap;
    }

    public function supports(Request $request): ?bool
    {
        return self::LOGIN_ROUTE === $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 = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['username']]);

        if (!$user) {
            // user not found in db, but may exist in ldap:
            $user = $userProvider->loadUserByUsername($credentials['username']);
            if (!$user) {
                // user simply doesn't exist
                throw new CustomUserMessageAuthenticationException('Email could not be found.');
            } else {
                // user never logged in before, create user in DB and proceed...
                // TODO
            }
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        // TODO: how to use the LdapBindAuthenticationProvider here to check the users credentials agains LDAP?
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    /**
     * Used to upgrade (rehash) the user's password automatically over time.
     */
    public function getPassword($credentials): ?string
    {
        return $credentials['password'];
    }

    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(self::LOGIN_ROUTE);
    }
}

Unfortunately I didn't find any example code for this.

UPDATE:

Thanks to the answer of T. van den Berg I finally managed to get the authentication part working. I removed the LoginFormAuthenticator Guard from the security.yml and tweaked the form_login_ldap a little bit.

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

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

        my_ldap:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: '<baseDN>'
                search_dn: '<bindDN>'
                search_password: '<bindDN password>'
                default_roles: ['ROLE_USER']

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

        main:
            anonymous: true
            lazy: true
            provider: my_ldap

            form_login_ldap:
                login_path: app_login
                check_path: app_login
                service: Symfony\Component\Ldap\Ldap
                dn_string: '<baseDN>'
                query_string: '(sAMAccountName={username})'
                search_dn: '<bindDN>'
                search_password: '<bindDN password>'

            logout:
                path: app_logout
                target: index

    access_control:
        - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

It is now using the LDAPUserProvider to use the LDAP service user (bind DN) to fetch the user LDAP object by its login name (sAMAccountName) and then in a second request use the distinguished name (DN) of this LDAP object to make another authentication against the LDAP server with the provided password. That's fine so far.

The only thing missing is the database stored User entity. My Idea was as follows:

  • login form is submitted
  • search the in the DB for a User entity with the provided username
  • if User entity is not found, use the LDAPUserProvider to ask the LDAP for the username
  • if the User exists in LDAP, create a User entity in the DB
  • authenticate the User against LDAP with the provided password

The password is not saved in the database, but other application specific information not available in LDAP (e.g. last activity).

Does anyone have an idea how to implement this?

smogm
  • 53
  • 1
  • 8
  • Could you try to remove the guard part of the main firewall? I think it conflicts with form_login_ldap. – T. van den Berg Jul 14 '20 at 16:31
  • This actually worked to get the authentication working! Now I want to store the username + user role (and maybe more additional data) in an entity class in the database. This entity shall be created on the first successfull LDAP authentication and be updated on all following logins. – smogm Jul 15 '20 at 09:15

1 Answers1

1

You can use this bundle ldaptools/ldaptools-bundle (or Maks3w/FR3DLdapBundle) if you want to save your LDAP user to a local database after they login.

for more information see: https://github.com/ldaptools/ldaptools-bundle/blob/master/Resources/doc/Save-LDAP-Users-to-the-Database-After-Login.md

UPDATED :

This is how I got it working (without an external bundles) :

  1. Security.yaml
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: username

        ldap_server:
            ldap:
                service: Symfony\Component\Ldap\Ldap
                base_dn: "dc=example,dc=com"
                search_dn: "cn=read-only-admin,dc=example,dc=com"
                search_password: "password"
                default_roles: ROLE_USER
                uid_key: uid

        chain_provider:
            chain:
                providers: [ 'app_user_provider', 'ldap_server' ]

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: lazy
            provider: chain_provider


            form_login:
                login_path: app_login
                check_path: app_login

            form_login_ldap:
                login_path: app_login
                check_path: app_login
                service: Symfony\Component\Ldap\Ldap
                dn_string: 'uid={username},dc=example,dc=com'

            logout:
                path: app_logout

  1. an Eventlistener
<?php


namespace App\EventListener;


use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

class LoginEventListener
{
    /**
     * @var EntityManagerInterface
     */
    protected $em;

    /**
     * @var UserPasswordEncoderInterface
     */
    private $encoder;

    /**
     * LoginEventListener constructor.
     * @param EntityManagerInterface $em
     * @param UserPasswordEncoderInterface $encoder
     */
    public function __construct(EntityManagerInterface $em, UserPasswordEncoderInterface $encoder)
    {
        $this->em = $em;
        $this->encoder = $encoder;
    }

    /**
     * @param InteractiveLoginEvent $event
     */
    public function onLoginSuccess(InteractiveLoginEvent $event)
    {
        $request = $event->getRequest();
        $token = $event->getAuthenticationToken();
        $loggedUser = $token->getUser();

//     If the logged user is not an instance of User (not ldapUser), then it hasn't been saved to the database. So save it..
        if(!($loggedUser instanceof User)) {
            $user = new User();
            $user->setUsername($request->request->get('_username'));
            $user->setPassword($this->encoder->encodePassword($user, $request->request->get('_password')));
            $user->setRoles($loggedUser->getRoles());
            $this->em->persist($user);
            $this->em->flush();
        }

    }

  1. services.yaml
# ldap service
    Symfony\Component\Ldap\Ldap:
        arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            - host: ldap.forumsys.com
              port: 389
              options:
                  protocol_version: 3
                  referrals: false

    app_bundle.event.login_listener:
        class: App\EventListener\LoginEventListener
        arguments: [ '@doctrine.orm.entity_manager', '@security.user_password_encoder.generic' ]
        tags:
            - { name: kernel.event_listener, event: security.interactive_login, method: onLoginSuccess }