From 20962e604a761a71c1acb5d3c2f04bddda651703 Mon Sep 17 00:00:00 2001 From: Wouter de Jong Date: Sun, 26 Apr 2020 22:45:51 +0200 Subject: [PATCH] [Security] Added LDAP support to Authenticator system --- .../Compiler/UnusedTagsPass.php | 1 + .../Compiler/RegisterLdapLocatorPass.php | 39 +++ .../Security/Factory/FormLoginLdapFactory.php | 2 + .../Security/Factory/HttpBasicLdapFactory.php | 2 + .../Security/Factory/JsonLoginLdapFactory.php | 2 + .../Security/Factory/LdapFactoryTrait.php | 64 +++++ .../Bundle/SecurityBundle/SecurityBundle.php | 2 + src/Symfony/Component/Ldap/CHANGELOG.md | 5 + .../Security/CheckLdapCredentialsListener.php | 106 +++++++++ .../Ldap/Security/LdapAuthenticator.php | 79 +++++++ .../Component/Ldap/Security/LdapBadge.php | 78 ++++++ .../CheckLdapCredentialsListenerTest.php | 223 ++++++++++++++++++ 12 files changed, 603 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterLdapLocatorPass.php create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LdapFactoryTrait.php create mode 100644 src/Symfony/Component/Ldap/Security/CheckLdapCredentialsListener.php create mode 100644 src/Symfony/Component/Ldap/Security/LdapAuthenticator.php create mode 100644 src/Symfony/Component/Ldap/Security/LdapBadge.php create mode 100644 src/Symfony/Component/Ldap/Tests/Security/CheckLdapCredentialsListenerTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index e4ef2b291d..4c6c5e834e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -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', diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterLdapLocatorPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterLdapLocatorPass.php new file mode 100644 index 0000000000..295f363292 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterLdapLocatorPass.php @@ -0,0 +1,39 @@ + + * + * 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 + * + * @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); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php index 3d6d119b8c..3b58b8bd3f 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php @@ -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; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php index d614e9f137..c1fac1a631 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php @@ -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; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginLdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginLdapFactory.php index ba0d713664..9d74f01cff 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginLdapFactory.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginLdapFactory.php @@ -24,6 +24,8 @@ use Symfony\Component\Security\Core\Exception\LogicException; */ class JsonLoginLdapFactory extends JsonLoginFactory { + use LdapFactoryTrait; + public function getKey() { return 'json-login-ldap'; diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LdapFactoryTrait.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LdapFactoryTrait.php new file mode 100644 index 0000000000..434383049d --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/LdapFactoryTrait.php @@ -0,0 +1,64 @@ + + * + * 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 + * + * @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; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index d8e6590736..a666737118 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -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, diff --git a/src/Symfony/Component/Ldap/CHANGELOG.md b/src/Symfony/Component/Ldap/CHANGELOG.md index a05ea5ba3f..f54a3e8241 100644 --- a/src/Symfony/Component/Ldap/CHANGELOG.md +++ b/src/Symfony/Component/Ldap/CHANGELOG.md @@ -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 ----- diff --git a/src/Symfony/Component/Ldap/Security/CheckLdapCredentialsListener.php b/src/Symfony/Component/Ldap/Security/CheckLdapCredentialsListener.php new file mode 100644 index 0000000000..c9abc92f26 --- /dev/null +++ b/src/Symfony/Component/Ldap/Security/CheckLdapCredentialsListener.php @@ -0,0 +1,106 @@ + + * + * 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 + */ +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]]; + } +} diff --git a/src/Symfony/Component/Ldap/Security/LdapAuthenticator.php b/src/Symfony/Component/Ldap/Security/LdapAuthenticator.php new file mode 100644 index 0000000000..984e5d5424 --- /dev/null +++ b/src/Symfony/Component/Ldap/Security/LdapAuthenticator.php @@ -0,0 +1,79 @@ + + * + * 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 + * + * @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); + } +} diff --git a/src/Symfony/Component/Ldap/Security/LdapBadge.php b/src/Symfony/Component/Ldap/Security/LdapBadge.php new file mode 100644 index 0000000000..83f20edb77 --- /dev/null +++ b/src/Symfony/Component/Ldap/Security/LdapBadge.php @@ -0,0 +1,78 @@ + + * + * 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 + * + * @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; + } +} diff --git a/src/Symfony/Component/Ldap/Tests/Security/CheckLdapCredentialsListenerTest.php b/src/Symfony/Component/Ldap/Tests/Security/CheckLdapCredentialsListenerTest.php new file mode 100644 index 0000000000..abc964eb85 --- /dev/null +++ b/src/Symfony/Component/Ldap/Tests/Security/CheckLdapCredentialsListenerTest.php @@ -0,0 +1,223 @@ + + * + * 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 + { + } + } +}