Integrating the new Symfony Authenticator with Microsoft Active Directory via LDAPS

3/18/2021

Last December during SymfonyWorld Online 2020, I attended Ryan Weaver's "All about Symfony's new Security Component" workshop. Ryan is a Symfony core member, the enthusiastic voice behind the SymfonyCasts website, and heads the Symfony documentation team. Ryan was presenting the work he and Wouter de Jong have done on revamping the Symfony security system (read Wouter's blog post for some of the team's motivations). I really like the new system and am looking forward to seeing it mature, but it's not well-documented yet as it's still flagged experimental

In this post I'll walk through a full demo of the new system. You can clone my example code and follow along, but you'll need a PHP 8 development environment to run the code on (check out AracPac over on Vagrant Cloud if you need one).

The plan is to have a login form authenticate users via an LDAPS connection to Active Directory (in our case, we'll use the free public test server that ForumSystems generously maintains). We'll achieve this by writing a guard authenticator. Functionally, the main difference in the new guard interface is that the getCredentials and getUser methods have been merged into a single authenticate method, which must now return a Security Passport instance:

public function authenticate(Request $request): PassportInterface
{

    $csrfToken = $request->request->get('_csrf_token');
    $password = $request->request->get('password');
    $username = $request->request->get('username');

    $request->getSession()->set(
        Security::LAST_USERNAME,
        $username
    );

    // get a local user entity if a matching one exists
    $user = $this->entityManager->getRepository(User::class)->findOneBy(['username' => $username]);

    // get an Active Directory entry if one exists
    $ldapEntry = $this->activeDirectory->getEntryFromActiveDirectory($username, $password);

    if (!$user && $ldapEntry && ($ldapEntry::class === Entry::class)) {
        // if a local user doesn't exist, but an Active Directory one does, create a local user
        $user = $this->activeDirectory->createUserFromActiveDirectory($password, $ldapEntry);
    } elseif (!$ldapEntry) {
        // if an Active Directory user doesn't exist, throw an error
        throw new CustomUserMessageAuthenticationException('No such user in Active Directory.');
    }

    if ($user !== null) {
        // sync the local user with the Active Directory user if both exist
        $this->activeDirectory->updateUserFromActiveDirectory($user, $ldapEntry, $password);
    }

    return new Passport(
        new UserBadge($username),
        new PasswordCredentials($password),
        [
            new CsrfTokenBadge('login_form', $csrfToken),
            new RememberMeBadge(),
        ]
    );
}

The passport, which is a new concept to Symfony authenticators, wraps the user object and badges, which are then resolved by listeners (all badges must be resolved for successful authentication).

Lines 4-6 just parse the form data, then on line 14, we check if there's an existing local user that corresponds to the submitted username. On line 17, we check for an Active Directory user via the getEntryFromActiveDirectory method:

$ldap->bind(implode(',', ['uid=' . $username, $this->ldapServiceDn]), $password);
if ($this->ldapAdapter->getConnection()->isBound()) {
    $search = $ldap->query(
        'dc=example,dc=com',
        'uid=' . $username
    )->execute()->toArray();
}

This authenticates the user against Active Directory by binding as them and querying for their user, so if we receive an entry from our LDAPS query, we consider the user authenticated.

On line 21, if we've authenticated successfully but could not find a local user, we create one. The createUserFromActiveDirectory method in this example is very simple, but in real word usage we could assign application roles and attributes based on Active Directory attributes and group memberships. This is especially powerful when an organization already uses Active Directory extensively because it allows existing business rules to propagate to the application.

On line 29, if we've authenticated and found a local user, we update the local user so that any changes made in Active Directory since the last login are correctly reflected on our local object.

At this point, we have remotely authenticated our user, so all we need to do is return a Passport with the appropriate security badges. These badges are resolved by listeners of the CheckPassport event, which then mark them as resolved. For example, the CsrfProtectionListener really just boils down to this:

$csrfToken = new CsrfToken($badge->getCsrfTokenId(), $badge->getCsrfToken());
if (false === $this->csrfTokenManager->isTokenValid($csrfToken)) {
    throw new InvalidCsrfTokenException('Invalid CSRF token.');
}
$badge->markResolved();

If the CSRF token badge we embedded via new CsrfTokenBadge('login_form', $csrfToken) turns out to be valid, the listener marks the badge as resolved. This structure allows a much nicer way to hook into the login flow using listeners and simplifies the guard itself. In fact, that's it for our example, the listeners handle the rest of the validation flow so we don't need anything else. Try it out locally and you'll see the logged-in dashboard.

As I mentioned earlier, the new authenticator is still marked experimental but is the future of Symfony authentication, so it's a good time to review the changes and try things out!