feature #36600 [Security] Added LDAP support to Authenticator system (wouterj)
This PR was merged into the 5.1-dev branch.
Discussion
----------
[Security] Added LDAP support to Authenticator system
| Q | A
| ------------- | ---
| Branch? | master
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Tickets | -
| License | MIT
| Doc PR | -
The last missing authenticator in the new system 🎉
I have no experience with LDAP at all and I didn't succeed in setting up a server locally. So I can't test whether this works, but the unit test works (and also tested in a real app, while adding a `dd()` call in the listener).
---
I want to share with you the current state of Security LDAP, how this PR implements it and a possible other solution (which I think I would prefer most). Is there anyone who can share their opinions on this? (hopefully @weaverryan and @csarrazi can share their opinion, as they have most experience on this topic)
1. **Current Solution: An LDAP authentication provider + duplicated `SecurityFactory` classes**
LDAP is done in one centralized authentication provider. This provider is configured by security factories for each core factory (e.g. `form_login` becomes `form_login_ldap`, `http_basic` becomes `http_basic_ldap`).
2. **Implementation in this PR: A listener is executed before the default `VerifyCredentialsListener`, to verify `PasswordCredentials`**
This listener must be configured for each specific authenticator wanting to use LDAP. This is a technique similar to (1). It's a bit difficult to use this for your own authenticator (you need to configure a custom listener service) and still needs the duplicated factory classes
3. **Proposal: Introduce a `LdapCredentials` class and always register a listener**
If an authentictor returns `LdapCredentials`, it'll be checked using the LDAP verification listener. This is the easiest for custom authenticators and would remove the duplicated factories, I can imagine `form_login` getting a new `ldap` sub option to configure the settings.
The main disadvantage (I think) is that we would need to make `LdapCredentials` configure all options: ldap service, dnString, searchDn, searchPassword & queryString. Especially passing around the ldap service seems a bit weird. The main questions here are: Is it weird to pass all these things in the `LdapCredentials`? And, do we really need to support having multiple LDAP configuration sets for different authenticators? Or can we e.g. add a global `security.ldap` configuration, that registers the listener for all authenticators returning `LdapCredentials`?
Commits
-------
20962e604a
[Security] Added LDAP support to Authenticator system
This commit is contained in:
commit
09645a9103
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -24,6 +24,8 @@ use Symfony\Component\Security\Core\Exception\LogicException;
|
|||
*/
|
||||
class JsonLoginLdapFactory extends JsonLoginFactory
|
||||
{
|
||||
use LdapFactoryTrait;
|
||||
|
||||
public function getKey()
|
||||
{
|
||||
return 'json-login-ldap';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
-----
|
||||
|
||||
|
|
|
@ -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]];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue