Working on my local Ubuntu 20.04 installation, I will set up a development environment and implement Symfony authentication with LDAP.

Citing from the the documentation I’m interested in this scenario:

Checking a user’s password against an LDAP server while fetching user information from another source (database using FOSUserBundle, for example).

Setup Symfony and create new project as per symfony.com/download:

# Make sure you have PHP extensions we will use and ldapsearch.
sudo apt install php-ldap php-sqlite3 ldap-utils

# Create new Symfony project.
symfony new ldap_test --version=5.4 --full
cd ldap_test
composer require symfony/ldap

To make it simple, I will use sqlite to store the user records, just uncomment in .env:

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Create user entity. Answer with all defaults just for “Does this app need to hash/check user passwords?” I said no.

php bin/console make:user
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Create controller from a template:

php bin/console make:controller Protected

In config/packages/security.yaml make the firewall to protect the access to our newly created action:

    access_control:
        - { path: ^/protected, roles: ROLE_USER }

Run the server and make sure that the access to http://127.0.0.1:8000/protected is not allowed:

symfony_ldap_1

Let’s create sample user record in the database

sqlite> .schema user
CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
);
CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON user (email);
sqlite> INSERT INTO user VALUES (NULL,'test@example.invalid','[ROLE_USER]');

We will use Apache Directory Studio to run local LDAP server. Download it from (directory.apache.org/studio/downloads.html)[https://directory.apache.org/studio/downloads.html], extract and run.

Click on the “LDAP servers” tab, create new server.

symfony_ldap_2

Then start it.

symfony_ldap_3

When you double-click it, you can see that its running on port 10389:

symfony_ldap_4

Right click it and “Create a connection”. Let’s create sample user. Download Sample LDAP LDIF file. In LDAP browser tab, right-click the root node, select import and choose the file.

Let’s test the connection using ldapsearch:

$ ldapsearch -H ldap://localhost:10389 -D 'cn=test@example.invalid,dc=example,dc=com' -w xyz sn=Muras
# extended LDIF
#
# LDAPv3
# base <> (default) with scope subtree
# filter: sn=Muras
# requesting: ALL
#

# Tomek Muras, example.com
dn: cn=Tomek Muras,dc=example,dc=com
mail: test@example.invalid
sn: Muras
cn: Tomek Muras
objectClass: top
objectClass: inetOrgPerson
objectClass: person
objectClass: organizationalPerson
userPassword:: e1NTSEF9WmtQNm9NTGFTK1hiZ2c5RG1WODg4T2FmaktUTXhCNkpGSEVrUWc9PQ=
=
uid: tmuras

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1

Add login form, as per symfony.com/doc/5.4/security.html. I have put login action in the same ProtectedController:

    /**
     * @Route("/login", name="login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();

        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();

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

Create service in Symfony, based on symfony.com/doc/5.4/security/ldap.html, add to config/services.yaml:

    Symfony\Component\Ldap\Ldap:
        arguments: ['@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter']
        tags:
            - ldap
    Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
        arguments:
            -   host: localhost
                port: 10389
                encryption: false
                options:
                    protocol_version: 3
                    referrals: false

Update config/packages/security.yaml:

security:
  enable_authenticator_manager: true
  # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
  password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
  # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
  providers:
    # used to reload user from session & other features (e.g. switch_user)
    app_user_provider:
      entity:
        class: App\Entity\User
        property: email
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      lazy: true
      provider: app_user_provider

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

  # 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: ^/protected, roles: ROLE_USER }

Now we can access the protected resource, once valid LDAP password is entered:

symfony_ldap_5

The relevant logs during the login:

[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39400 Accepted
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39400 [302]: POST /login
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39400 Closing
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39404 Accepted
[Web Server ] Dec 12 16:03:34 |INFO   | SERVER POST (302) /login host="127.0.0.1:8004" ip="127.0.0.1" scheme="https"
[Web Server ] Dec 12 16:03:34 |INFO   | SERVER GET  (200) /protected 
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39404 [200]: GET /protected
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39404 Closing
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39406 Accepted
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39406 [200]: GET /_wdt/f011c6
[Web Server ] Dec 12 16:03:34 |INFO   | SERVER GET  (200) /_wdt/f011c6 ip="127.0.0.1"
[PHP        ] [Sun Dec 12 16:03:34 2021] 127.0.0.1:39406 Closing
[Application] Dec 12 16:03:34 |INFO   | REQUES Matched route "login". method="POST" request_uri="http://127.0.0.1:8000/login" route="login" route_parameters={"_controller":"App\\Controller\\ProtectedController::login","_route":"login"}
[Application] Dec 12 16:03:34 |DEBUG  | SECURI Checking for authenticator support. authenticators=1 firewall_name="main"
[Application] Dec 12 16:03:34 |DEBUG  | SECURI Checking support on authenticator. authenticator="Symfony\\Component\\Ldap\\Security\\LdapAuthenticator"
[Application] Dec 12 16:03:34 |DEBUG  | DOCTRI SELECT t0.id AS id_1, t0.email AS email_2, t0.roles AS roles_3 FROM user t0 WHERE t0.email = ? LIMIT 1 0="test@example.invalid"
[Application] Dec 12 16:03:34 |INFO   | PHP    User Deprecated: Since symfony/ldap 5.3: Not implementing the "Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface" interface in class "App\Entity\User" while using password-based authenticators is deprecated. 
[Application] Dec 12 16:03:34 |INFO   | PHP    User Deprecated: Since symfony/security-http 5.3: Not implementing the "Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface" interface in class "App\Entity\User" while using password-based authentication is deprecated. 
[Application] Dec 12 16:03:34 |INFO   | SECURI Authenticator successful! authenticator="Symfony\\Component\\Security\\Http\\Authenticator\\Debug\\TraceableAuthenticator" token={"Symfony\\Component\\Security\\Core\\Authentication\\Token\\UsernamePasswordToken":"UsernamePasswordToken(user=\"test@example.invalid\", authenticated=true, roles=\"ROLE_USER\")"}
[Application] Dec 12 16:03:34 |DEBUG  | SECURI The "Symfony\Component\Security\Http\Authenticator\Debug\TraceableAuthenticator" authenticator set the response. Any later authenticator will not be called 
[Application] Dec 12 16:03:34 |DEBUG  | SECURI Stored the security token in the session. key="_security_main"

The full code is available on github.com/tmuras/symfony-ldap-authentication.