Implement LDAP authentication in symfony 5.4

Lightweight Directory Access Protocol is originally a protocol for querying and modifying directory services. This protocol is based on TCP/IP.

Install LDAP Module #

$ composer require symfony/ldap

Create the .env variables #

# LDAP / AD host
LDAP_HOST=
# LDAP / AD port
LDAP_PORT=
# LDAP / AD base dn ("cn=example,cn=com")
LDAP_BASEDN=
# LDAP / AD search user
LDAP_SEARCHDN=
# LDAP / AD search user password
LDAP_SEARCHPASSWORD=
# LDAP / AD encryption method
LDAP_ENCRYPT=
# LDAP / AD authorized groups
# '["group","group"]'
# Optionnal if you accept all ldap users
LDAP_AUTHORIZED_GROUPS=

Configure LDAP Client #

config/services.yaml

services:
  Symfony\Component\Ldap\Ldap:
    arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
    tags:
      - ldap

  Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
    arguments:
      - host: "%env(resolve:LDAP_HOST)%"
        port: "%env(resolve:LDAP_PORT)%"
        encryption: "%env(resolve:LDAP_ENCRYPT)%"
        options:
          protocol_version: 3
          referrals: false

Create custom LDAP User Provicder #

src/Security/CustomLdapUserProvider.php

<?php

namespace App\Security;

/* ... */

class CustomLdapUserProvider extends LdapUserProvider
{
    private $defaultRoles;
    private $passwordAttribute;
    private $extraFields = array();
    private $authorizedGroups;

    public function __construct(
        LdapInterface $ldap,
        string $baseDn,
        string $searchDn = null,
        string $searchPassword = null,
        array $authorizedGroups = null,
        array $defaultRoles = [],
        string $uidKey = null,
        string $filter = null,
        string $passwordAttribute = null,
        array $extraFields = []
    ){
        $this->authorizedGroups = $authorizedGroups;
        parent::__construct($ldap, $baseDn, $searchDn, $searchPassword, $defaultRoles, $uidKey, $filter, $passwordAttribute, $extraFields);
    }

    protected function loadUser(string $username, Entry $entry)
    {
        $password = null;
        $extraFields = [];

        if (null !== $this->passwordAttribute) {
            $password = $this->getAttributeValue($entry, $this->passwordAttribute);
        }

        foreach ($this->extraFields as $field) {
            $extraFields[$field] = $this->getAttributeValue($entry, $field);
        }
        if ($this->authorizedGroups) {
            $isAuthorized = false;
            foreach ($entry->getAttribute("memberOf") as $ldapGroupDn) {
                $isAuthorized = $isAuthorized || in_array($ldapGroupDn, $this->authorizedGroups);
            }
            if (!$isAuthorized) {
                throw new NotAuthorizedException();
            }
        }

        $results = array("ROLE_USER");
        foreach ($entry->getAttribute("memberOf") as $ldapGroupDn) {
            $results[] = "ROLE_" . ldap_explode_dn($ldapGroupDn, 1)[0];
        }

        if (!empty($results))
            $roles = $results;
        else
            $roles = $this->defaultRoles;

        return new LdapUser($entry, $username, $password, $roles, $extraFields);
    }

    private function getAttributeValue(Entry $entry, string $attribute)
    {
        if (!$entry->hasAttribute($attribute)) {
            throw new InvalidArgumentException(sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
        }

        $values = $entry->getAttribute($attribute);

        if (1 !== \count($values)) {
            throw new InvalidArgumentException(sprintf('Attribute "%s" has multiple values.', $attribute));
        }

        return $values[0];
    }
}

src/Security/NotAuthorizedException.php

<?php

namespace App\Security;

use Symfony\Component\Security\Core\Exception\AuthenticationException;

class NotAuthorizedException extends AuthenticationException
{
    /**
     * {@inheritdoc}
     */
    public function getMessageKey(): string
    {
        return "Your not authorized message";
    }
}

Configure the security component #

config/packages/security.yaml

security:
  enable_authenticator_manager: true
  providers:
    ldap_users:
      id: App\Security\CustomLdapUserProvider
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      provider: ldap_users
      guard:
        authenticators:
          - App\Security\LdapFormAuthenticator
      logout:
        path: logout
        target: login

  access_control:
    - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
    - { path: ^/, roles: ROLE_USER }

Create the authenticator #

src/Security/LdapFormAuthenticator.php

<?php

namespace App\Security;

/* ... */

class LdapFormAuthenticator extends AbstractFormLoginAuthenticator
{
    use TargetPathTrait;

    private $urlGenerator;
    private $csrfTokenManager;
    protected $ldap;

    public function __construct(UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, Ldap $ldap)
    {
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->ldap = $ldap;
    }

    public function supports(Request $request)
    {
        return '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->loadUserByIdentifier($credentials['username']);
        if (!$user) {
            throw new CustomUserMessageAuthenticationException('Username could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        try {
            $this->ldap->bind($user->getEntry()->getDn(), $credentials['password']);
        } catch (ConnectionException $e) {
            return false;
        }

        return true;
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('index'));
    }

    protected function getLoginUrl()
    {
        return $this->urlGenerator->generate('login');
    }
}

Create the authentication form / controller #

The authentication controller #

src/Controller/AuthController.php

<?php

namespace App\Controller;

/* */

class AuthController extends AbstractController
{
    /**
     * @Route("/login", name="login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('security/login.html.twig', [
            'last_username' => $lastUsername,
            'error' => $error
        ]);
    }

    /**
     * @Route("/logout", name="logout")
     * @throws Exception
     */
    public function logout()
    {
        throw new Exception('This method can be blank - it will be intercepted by the logout key on your firewall');
    }
}

Login template #

templates/security/login.html.twig

<form action="{{ path('login') }}" method="POST">
  {% if error %}
    <div>{{ error.messageKey|trans(error.messageData, 'security') }}</div>
  {% endif %}
  <div>
    <label for="_username" class="text-lg">Username</label>
    <input type="text" id="_username" name="_username" placeholder="Username" />
  </div>
  <div>
    <label for="_password" class="text-lg">Password</label>
    <input type="text" id="_password" name="_password" placeholder="Password" />
  </div>
  <div>
    <button type="submit">Login</button>
  </div>
  <input
    type="hidden"
    name="_csrf_token"
    value="{{ csrf_token('authenticate') }}"
  />
</form>