[Security] Added LDAP support to Authenticator system

This commit is contained in:
Wouter de Jong 2020-04-26 22:45:51 +02:00 committed by Ryan Weaver
parent 017420b511
commit 20962e604a
12 changed files with 603 additions and 0 deletions

View File

@ -53,6 +53,7 @@ class UnusedTagsPass implements CompilerPassInterface
'kernel.fragment_renderer',
'kernel.locale_aware',
'kernel.reset',
'ldap',
'mailer.transport_factory',
'messenger.bus',
'messenger.message_handler',

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
/**
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @internal
*/
class RegisterLdapLocatorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
$definition = $container->setDefinition('security.ldap_locator', new Definition(ServiceLocator::class));
$locators = [];
foreach ($container->findTaggedServiceIds('ldap') as $serviceId => $tags) {
$locators[$serviceId] = new ServiceClosureArgument(new Reference($serviceId));
}
$definition->addArgument($locators);
}
}

View File

@ -27,6 +27,8 @@ use Symfony\Component\Security\Core\Exception\LogicException;
*/
class FormLoginLdapFactory extends FormLoginFactory
{
use LdapFactoryTrait;
protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
{
$provider = 'security.authentication.provider.ldap_bind.'.$id;

View File

@ -28,6 +28,8 @@ use Symfony\Component\Security\Core\Exception\LogicException;
*/
class HttpBasicLdapFactory extends HttpBasicFactory
{
use LdapFactoryTrait;
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
{
$provider = 'security.authentication.provider.ldap_bind.'.$id;

View File

@ -24,6 +24,8 @@ use Symfony\Component\Security\Core\Exception\LogicException;
*/
class JsonLoginLdapFactory extends JsonLoginFactory
{
use LdapFactoryTrait;
public function getKey()
{
return 'json-login-ldap';

View File

@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Ldap\Security\CheckLdapCredentialsListener;
use Symfony\Component\Ldap\Security\LdapAuthenticator;
/**
* A trait decorating the authenticator with LDAP functionality.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @internal
*/
trait LdapFactoryTrait
{
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
{
$key = str_replace('-', '_', $this->getKey());
if (!class_exists(LdapAuthenticator::class)) {
throw new \LogicException(sprintf('The "%s" authenticator requires the "symfony/ldap" package version "5.1" or higher.', $key));
}
$authenticatorId = parent::createAuthenticator($container, $firewallName, $config, $userProviderId);
$container->setDefinition('security.listener.'.$key.'.'.$firewallName, new Definition(CheckLdapCredentialsListener::class))
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName])
->addArgument(new Reference('security.ldap_locator'))
;
$ldapAuthenticatorId = 'security.authenticator.'.$key.'.'.$firewallName;
$definition = $container->setDefinition($ldapAuthenticatorId, new Definition(LdapAuthenticator::class))
->setArguments([
new Reference($authenticatorId),
$config['service'],
$config['dn_string'],
$config['search_dn'],
$config['search_password'],
]);
if (!empty($config['query_string'])) {
if ('' === $config['search_dn'] || '' === $config['search_password']) {
throw new InvalidConfigurationException('Using the "query_string" config without using a "search_dn" and a "search_password" is not supported.');
}
$definition->addArgument($config['query_string']);
}
return $ldapAuthenticatorId;
}
}

View File

@ -15,6 +15,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddExpressionLang
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSessionDomainConstraintPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfTokenClearingLogoutHandlerPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory;
@ -73,6 +74,7 @@ class SecurityBundle extends Bundle
$container->addCompilerPass(new AddSessionDomainConstraintPass(), PassConfig::TYPE_BEFORE_REMOVING);
$container->addCompilerPass(new RegisterCsrfTokenClearingLogoutHandlerPass());
$container->addCompilerPass(new RegisterTokenUsageTrackingPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 200);
$container->addCompilerPass(new RegisterLdapLocatorPass());
$container->addCompilerPass(new AddEventAliasesPass([
AuthenticationSuccessEvent::class => AuthenticationEvents::AUTHENTICATION_SUCCESS,

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.1.0
-----
* Added `Security\LdapBadge`, `Security\LdapAuthenticator` and `Security\CheckLdapCredentialsListener` to integrate with the authenticator Security system
5.0.0
-----

View File

@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Ldap\Security;
use Psr\Container\ContainerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/**
* Verifies password credentials using an LDAP service whenever the
* LdapBadge is attached to the Security passport.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*/
class CheckLdapCredentialsListener implements EventSubscriberInterface
{
private $ldapLocator;
public function __construct(ContainerInterface $ldapLocator)
{
$this->ldapLocator = $ldapLocator;
}
public function onCheckPassport(CheckPassportEvent $event)
{
$passport = $event->getPassport();
if (!$passport->hasBadge(LdapBadge::class)) {
return;
}
/** @var LdapBadge $ldapBadge */
$ldapBadge = $passport->getBadge(LdapBadge::class);
if ($ldapBadge->isResolved()) {
return;
}
if (!$passport instanceof UserPassportInterface || !$passport->hasBadge(PasswordCredentials::class)) {
throw new \LogicException(sprintf('LDAP authentication requires a passport containing a user and password credentials, authenticator "%s" does not fulfill these requirements.', \get_class($event->getAuthenticator())));
}
/** @var PasswordCredentials $passwordCredentials */
$passwordCredentials = $passport->getBadge(PasswordCredentials::class);
if ($passwordCredentials->isResolved()) {
throw new \LogicException('LDAP authentication password verification cannot be completed because something else has already resolved the PasswordCredentials.');
}
if (!$this->ldapLocator->has($ldapBadge->getLdapServiceId())) {
throw new \LogicException(sprintf('Cannot check credentials using the "%s" ldap service, as such service is not found. Did you maybe forget to add the "ldap" service tag to this service?', $ldapBadge->getLdapServiceId()));
}
$presentedPassword = $passwordCredentials->getPassword();
if ('' === $presentedPassword) {
throw new BadCredentialsException('The presented password cannot be empty.');
}
/** @var LdapInterface $ldap */
$ldap = $this->ldapLocator->get($ldapBadge->getLdapServiceId());
try {
if ($ldapBadge->getQueryString()) {
if ('' !== $ldapBadge->getSearchDn() && '' !== $ldapBadge->getSearchPassword()) {
$ldap->bind($ldapBadge->getSearchDn(), $ldapBadge->getSearchPassword());
} else {
throw new LogicException('Using the "query_string" config without using a "search_dn" and a "search_password" is not supported.');
}
$username = $ldap->escape($passport->getUser()->getUsername(), '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{username}', $username, $ldapBadge->getQueryString());
$result = $ldap->query($ldapBadge->getDnString(), $query)->execute();
if (1 !== $result->count()) {
throw new BadCredentialsException('The presented username is invalid.');
}
$dn = $result[0]->getDn();
} else {
$username = $ldap->escape($passport->getUser()->getUsername(), '', LdapInterface::ESCAPE_DN);
$dn = str_replace('{username}', $username, $ldapBadge->getDnString());
}
$ldap->bind($dn, $presentedPassword);
} catch (ConnectionException $e) {
throw new BadCredentialsException('The presented password is invalid.');
}
$passwordCredentials->markResolved();
$ldapBadge->markResolved();
}
public static function getSubscribedEvents(): array
{
return [CheckPassportEvent::class => ['onCheckPassport', 144]];
}
}

View File

@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Ldap\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
/**
* This class decorates internal authenticators to add the LDAP integration.
*
* In your own authenticators, it is recommended to directly use the
* LdapBadge in the authenticate() method. This class should only be
* used for Symfony or third party authenticators.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @final
* @experimental in Symfony 5.1
*/
class LdapAuthenticator implements AuthenticatorInterface
{
private $authenticator;
private $ldapServiceId;
private $dnString;
private $searchDn;
private $searchPassword;
private $queryString;
public function __construct(AuthenticatorInterface $authenticator, string $ldapServiceId, string $dnString = '{username}', string $searchDn = '', string $searchPassword = '', string $queryString = '')
{
$this->authenticator = $authenticator;
$this->ldapServiceId = $ldapServiceId;
$this->dnString = $dnString;
$this->searchDn = $searchDn;
$this->searchPassword = $searchPassword;
$this->queryString = $queryString;
}
public function supports(Request $request): ?bool
{
return $this->authenticator->supports($request);
}
public function authenticate(Request $request): PassportInterface
{
$passport = $this->authenticator->authenticate($request);
$passport->addBadge(new LdapBadge($this->ldapServiceId, $this->dnString, $this->searchDn, $this->searchPassword, $this->queryString));
return $passport;
}
public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
{
return $this->authenticator->createAuthenticatedToken($passport, $firewallName);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return $this->authenticator->onAuthenticationFailure($request, $exception);
}
}

View File

@ -0,0 +1,78 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Ldap\Security;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
/**
* A badge indicating that the credentials should be checked using LDAP.
*
* This badge must be used together with PasswordCredentials.
*
* @author Wouter de Jong <wouter@wouterj.nl>
*
* @final
* @experimental in Symfony 5.1
*/
class LdapBadge implements BadgeInterface
{
private $resolved = false;
private $ldapServiceId;
private $dnString;
private $searchDn;
private $searchPassword;
private $queryString;
public function __construct(string $ldapServiceId, string $dnString = '{username}', string $searchDn = '', string $searchPassword = '', ?string $queryString = null)
{
$this->ldapServiceId = $ldapServiceId;
$this->dnString = $dnString;
$this->searchDn = $searchDn;
$this->searchPassword = $searchPassword;
$this->queryString = $queryString;
}
public function getLdapServiceId(): string
{
return $this->ldapServiceId;
}
public function getDnString(): string
{
return $this->dnString;
}
public function getSearchDn(): string
{
return $this->searchDn;
}
public function getSearchPassword(): string
{
return $this->searchPassword;
}
public function getQueryString(): ?string
{
return $this->queryString;
}
public function markResolved(): void
{
$this->resolved = true;
}
public function isResolved(): bool
{
return $this->resolved;
}
}

View File

@ -0,0 +1,223 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Ldap\Tests\Security;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Ldap\Adapter\CollectionInterface;
use Symfony\Component\Ldap\Adapter\QueryInterface;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Ldap\Security\CheckLdapCredentialsListener;
use Symfony\Component\Ldap\Security\LdapBadge;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\User;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
use Symfony\Contracts\Service\ServiceLocatorTrait;
class CheckLdapCredentialsListenerTest extends TestCase
{
private $ldap;
protected function setUp(): void
{
if (!interface_exists(AuthenticatorInterface::class)) {
$this->markTestSkipped('This test requires symfony/security-http:^5.1');
}
$this->ldap = $this->createMock(LdapInterface::class);
}
/**
* @dataProvider provideShouldNotCheckPassport
*/
public function testShouldNotCheckPassport($authenticator, $passport)
{
$this->ldap->expects($this->never())->method('bind');
$listener = $this->createListener();
$listener->onCheckPassport(new CheckPassportEvent($authenticator, $passport));
}
public function provideShouldNotCheckPassport()
{
if (!interface_exists(AuthenticatorInterface::class)) {
$this->markTestSkipped('This test requires symfony/security-http:^5.1');
}
$user = new User('Wouter', null, ['ROLE_USER']);
// no LdapBadge
yield [new TestAuthenticator(), new Passport($user, new PasswordCredentials('s3cret'))];
// ldap already resolved
$badge = new LdapBadge('app.ldap');
$badge->markResolved();
yield [new TestAuthenticator(), new Passport($user, new PasswordCredentials('s3cret'), [$badge])];
}
public function testPasswordCredentialsAlreadyResolvedThrowsException()
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('LDAP authentication password verification cannot be completed because something else has already resolved the PasswordCredentials.');
$badge = new PasswordCredentials('s3cret');
$badge->markResolved();
$user = new User('Wouter', null, ['ROLE_USER']);
$passport = new Passport($user, $badge, [new LdapBadge('app.ldap')]);
$listener = $this->createListener();
$listener->onCheckPassport(new CheckPassportEvent(new TestAuthenticator(), $passport));
}
public function testInvalidLdapServiceId()
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Cannot check credentials using the "not_existing_ldap_service" ldap service, as such service is not found. Did you maybe forget to add the "ldap" service tag to this service?');
$listener = $this->createListener();
$listener->onCheckPassport($this->createEvent('s3cr3t', new LdapBadge('not_existing_ldap_service')));
}
/**
* @dataProvider provideWrongPassportData
*/
public function testWrongPassport($passport)
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('LDAP authentication requires a passport containing a user and password credentials, authenticator "'.TestAuthenticator::class.'" does not fulfill these requirements.');
$listener = $this->createListener();
$listener->onCheckPassport(new CheckPassportEvent(new TestAuthenticator(), $passport));
}
public function provideWrongPassportData()
{
if (!interface_exists(AuthenticatorInterface::class)) {
$this->markTestSkipped('This test requires symfony/security-http:^5.1');
}
// no password credentials
yield [new SelfValidatingPassport(new User('Wouter', null, ['ROLE_USER']), [new LdapBadge('app.ldap')])];
// no user passport
$passport = $this->createMock(PassportInterface::class);
$passport->expects($this->any())->method('hasBadge')->with(LdapBadge::class)->willReturn(true);
$passport->expects($this->any())->method('getBadge')->with(LdapBadge::class)->willReturn(new LdapBadge('app.ldap'));
yield [$passport];
}
public function testEmptyPasswordShouldThrowAnException()
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The presented password cannot be empty.');
$listener = $this->createListener();
$listener->onCheckPassport($this->createEvent(''));
}
public function testBindFailureShouldThrowAnException()
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The presented password is invalid.');
$this->ldap->expects($this->any())->method('bind')->willThrowException(new ConnectionException());
$listener = $this->createListener();
$listener->onCheckPassport($this->createEvent());
}
public function testQueryForDn()
{
$collection = new \ArrayIterator([new Entry('')]);
$query = $this->getMockBuilder(QueryInterface::class)->getMock();
$query->expects($this->once())->method('execute')->willReturn($collection);
$this->ldap->expects($this->at(0))->method('bind')->with('elsa', 'test1234A$');
$this->ldap->expects($this->any())->method('escape')->with('Wouter', '', LdapInterface::ESCAPE_FILTER)->willReturn('wouter');
$this->ldap->expects($this->once())->method('query')->with('{username}', 'wouter_test')->willReturn($query);
$listener = $this->createListener();
$listener->onCheckPassport($this->createEvent('s3cr3t', new LdapBadge('app.ldap', '{username}', 'elsa', 'test1234A$', '{username}_test')));
}
public function testEmptyQueryResultShouldThrowAnException()
{
$this->expectException(BadCredentialsException::class);
$this->expectExceptionMessage('The presented username is invalid.');
$collection = $this->getMockBuilder(CollectionInterface::class)->getMock();
$query = $this->getMockBuilder(QueryInterface::class)->getMock();
$query->expects($this->once())->method('execute')->willReturn($collection);
$this->ldap->expects($this->at(0))->method('bind')->with('elsa', 'test1234A$');
$this->ldap->expects($this->once())->method('query')->willReturn($query);
$listener = $this->createListener();
$listener->onCheckPassport($this->createEvent('s3cr3t', new LdapBadge('app.ldap', '{username}', 'elsa', 'test1234A$', '{username}_test')));
}
private function createEvent($password = 's3cr3t', $ldapBadge = null)
{
return new CheckPassportEvent(
new TestAuthenticator(),
new Passport(new User('Wouter', null, ['ROLE_USER']), new PasswordCredentials($password), [$ldapBadge ?? new LdapBadge('app.ldap')])
);
}
private function createListener()
{
$ldapLocator = new class(['app.ldap' => function () {
return $this->ldap;
}]) implements ContainerInterface {
use ServiceLocatorTrait;
};
return new CheckLdapCredentialsListener($ldapLocator);
}
}
if (interface_exists(AuthenticatorInterface::class)) {
class TestAuthenticator implements AuthenticatorInterface
{
public function supports(Request $request): ?bool
{
}
public function authenticate(Request $request): PassportInterface
{
}
public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
{
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
}
}
}