10000 feature #36600 [Security] Added LDAP support to Authenticator system … · symfony/symfony@09645a9 · GitHub
[go: up one dir, main page]

Skip to content

Commit 09645a9

Browse files
committed
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 ------- 20962e6 [Security] Added LDAP support to Authenticator system
2 parents 956d547 + 20962e6 commit 09645a9

File tree

12 files changed

+603
-0
lines changed

12 files changed

+603
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class UnusedTagsPass implements CompilerPassInterface
5353
'kernel.fragment_renderer',
5454
'kernel.locale_aware',
5555
'kernel.reset',
56+
'ldap',
5657
'mailer.transport_factory',
5758
'messenger.bus',
5859
'messenger.message_handler',
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler;
13+
14+
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
15+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
16+
use Symfony\Component\DependencyInjection\ContainerBuilder;
17+
use Symfony\Component\DependencyInjection\Definition;
18+
use Symfony\Component\DependencyInjection\Reference;
19+
use Symfony\Component\DependencyInjection\ServiceLocator;
20+
21+
/**
22+
* @author Wouter de Jong <wouter@wouterj.nl>
23+
*
24+
* @internal
25+
*/
26+
class RegisterLdapLocatorPass implements CompilerPassInterface
27+
{
28+
public function process(ContainerBuilder $container)
29+
{
30+
$definition = $container->setDefinition('security.ldap_locator', new Definition(ServiceLocator::class));
31+
32+
$locators = [];
33+
foreach ($container->findTaggedServiceIds('ldap') as $serviceId => $tags) {
34+
$locators[$serviceId] = new ServiceClosureArgument(new Reference($serviceId));
35+
}
36+
37+
$definition->addArgument($locators);
38+
}
39+
}

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
*/
2828
class FormLoginLdapFactory extends FormLoginFactory
2929
{
30+
use LdapFactoryTrait;
31+
3032
protected function createAuthProvider(ContainerBuilder $container, string $id, array $config, string $userProviderId)
3133
{
3234
$provider = 'security.authentication.provider.ldap_bind.'.$id;

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
*/
2929
class HttpBasicLdapFactory extends HttpBasicFactory
3030
{
31+
use LdapFactoryTrait;
32+
3133
public function create(ContainerBuilder $container, string $id, array $config, string $userProvider, ?string $defaultEntryPoint)
3234
{
3335
$provider = 'security.authentication.provider.ldap_bind.'.$id;

src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/JsonLoginLdapFactory.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
*/
2525
class JsonLoginLdapFactory extends JsonLoginFactory
2626
{
27+
use LdapFactoryTrait;
28+
2729
public function getKey()
2830
{
2931
return 'json-login-ldap';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory;
13+
14+
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
15+
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Definition;
17+
use Symfony\Component\DependencyInjection\Reference;
18+
use Symfony\Component\Ldap\Security\CheckLdapCredentialsListener;
19+
use Symfony\Component\Ldap\Security\LdapAuthenticator;
20+
21+
/**
22+
* A trait decorating the authenticator with LDAP functionality.
23+
*
24+
* @author Wouter de Jong <wouter@wouterj.nl>
25+
*
26+
* @internal
27+
*/
28+
trait LdapFactoryTrait
29+
{
30+
public function createAuthenticator(ContainerBuilder $container, string $firewallName, array $config, string $userProviderId): string
31+
{
32+
$key = str_replace('-', '_', $this->getKey());
33+
if (!class_exists(LdapAuthenticator::class)) {
34+
throw new \LogicException(sprintf('The "%s" authenticator requires the "symfony/ldap" package version "5.1" or higher.', $key));
35+
}
36+
37+
$authenticatorId = parent::createAuthenticator($container, $firewallName, $config, $userProviderId);
38+
39+
$container->setDefinition('security.listener.'.$key.'.'.$firewallName, new Definition(CheckLdapCredentialsListener::class))
40+
->addTag('kernel.event_subscriber', ['dispatcher' => 'security.event_dispatcher.'.$firewallName])
41+
->addArgument(new Reference('security.ldap_locator'))
42+
;
43+
44+
$ldapAuthenticatorId = 'security.authenticator.'.$key.'.'.$firewallName;
45+
$definition = $container->setDefinition($ldapAuthenticatorId, new Definition(LdapAuthenticator::class))
46+
->setArguments([
47+
new Reference($authenticatorId),
48+
$config['service'],
49+
$config['dn_string'],
50+
$config['search_dn'],
51+
$config['search_password'],
52+
]);
53+
54+
if (!empty($config['query_string'])) {
55+
if ('' === $config['search_dn'] || '' === $config['search_password']) {
56+
throw new InvalidConfigurationException('Using the "query_string" config without using a "search_dn" and a "search_password" is not supported.');
57+
}
58+
59+
$definition->addArgument($config['query_string']);
60+
}
61+
62+
return $ldapAuthenticatorId;
63+
}
64+
}

src/Symfony/Bundle/SecurityBundle/SecurityBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass;
1616
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSessionDomainConstraintPass;
1717
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterCsrfTokenClearingLogoutHandlerPass;
18+
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterLdapLocatorPass;
1819
use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass;
1920
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\AnonymousFactory;
2021
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\CustomAuthenticatorFactory;
@@ -73,6 +74,7 @@ public function build(ContainerBuilder $container)
7374
$container->addCompilerPass(new AddSessionDomainConstraintPass(), PassConfig::TYPE_BEFORE_REMOVING);
7475
$container->addCompilerPass(new RegisterCsrfTokenClearingLogoutHandlerPass());
7576
$container->addCompilerPass(new RegisterTokenUsageTrackingPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 200);
77+
$container->addCompilerPass(new RegisterLdapLocatorPass());
7678

7779
$container->addCompilerPass(new AddEventAliasesPass([
7880
AuthenticationSuccessEvent::class => AuthenticationEvents::AUTHENTICATION_SUCCESS,

src/Symfony/Component/Ldap/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
5.1.0
5+
-----
6+
7+
* Added `Security\LdapBadge`, `Security\LdapAuthenticator` and `Security\CheckLdapCredentialsListener` to integrate with the authenticator Security system
8+
49
5.0.0
510
-----
611

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/*
4+
* This file is part of t F438 he Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Ldap\Security;
13+
14+
use Psr\Container\ContainerInterface;
15+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16+
use Symfony\Component\Ldap\Exception\ConnectionException;
17+
use Symfony\Component\Ldap\LdapInterface;
18+
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
19+
use Symfony\Component\Security\Core\Exception\LogicException;
20+
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
21+
use Symfony\Component\Security\Http\Authenticator\Passport\UserPassportInterface;
22+
use Symfony\Component\Security\Http\Event\CheckPassportEvent;
23+
24+
/**
25+
* Verifies password credentials using an LDAP service whenever the
26+
* LdapBadge is attached to the Security passport.
27+
*
28+
* @author Wouter de Jong <wouter@wouterj.nl>
29+
*/
30+
class CheckLdapCredentialsListener implements EventSubscriberInterface
31+
{
32+
private $ldapLocator;
33+
34+
public function __construct(ContainerInterface $ldapLocator)
35+
{
36+
$this->ldapLocator = $ldapLocator;
37+
}
38+
39+
public function onCheckPassport(CheckPassportEvent $event)
40+
{
41+
$passport = $event->getPassport();
42+
if (!$passport->hasBadge(LdapBadge::class)) {
43+
return;
44+
}
45+
46+
/** @var LdapBadge $ldapBadge */
47+
$ldapBadge = $passport->getBadge(LdapBadge::class);
48+
if ($ldapBadge->isResolved()) {
49+
return;
50+
}
51+
52+
if (!$passport instanceof UserPassportInterface || !$passport->hasBadge(PasswordCredentials::class)) {
53+
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())));
54+
}
55+
56+
/** @var PasswordCredentials $passwordCredentials */
57+
$passwordCredentials = $passport->getBadge(PasswordCredentials::class);
58+
if ($passwordCredentials->isResolved()) {
59+
throw new \LogicException('LDAP authentication password verification cannot be completed because something else has already resolved the PasswordCredentials.');
60+
}
61+
62+
if (!$this->ldapLocator->has($ldapBadge->getLdapServiceId())) {
63+
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()));
64+
}
65+
66+
$presentedPassword = $passwordCredentials->getPassword();
67+
if ('' === $presentedPassword) {
68+
throw new BadCredentialsException('The presented password cannot be empty.');
69+
}
70+
71+
/** @var LdapInterface $ldap */
72+
$ldap = $this->ldapLocator->get($ldapBadge->getLdapServiceId());
73+
try {
74+
if ($ldapBadge->getQueryString()) {
75+
if ('' !== $ldapBadge->getSearchDn() && '' !== $ldapBadge->getSearchPassword()) {
76+
$ldap->bind($ldapBadge->getSearchDn(), $ldapBadge->getSearchPassword());
77+
} else {
78+
throw new LogicException('Using the "query_string" config without using a "search_dn" and a "search_password" is not supported.');
79+
}
80+
$username = $ldap->escape($passport->getUser()->getUsername(), '', LdapInterface::ESCAPE_FILTER);
81+
$query = str_replace('{username}', $username, $ldapBadge->getQueryString());
82+
$result = $ldap->query($ldapBadge->getDnString(), $query)->execute();
83+
if (1 !== $result->count()) {
84+
throw new BadCredentialsException('The presented username is invalid.');
85+
}
86+
87+
$dn = $result[0]->getDn();
88+
} else {
89+
$username = $ldap->escape($passport->getUser()->getUsername(), '', LdapInterface::ESCAPE_DN);
90+
$dn = str_replace('{username}', $username, $ldapBadge->getDnString());
91+
}
92+
93+
$ldap->bind($dn, $presentedPassword);
94+
} catch (ConnectionException $e) {
95+
throw new BadCredentialsException('The presented password is invalid.');
96+
}
97+
98+
$passwordCredentials->markResolved();
99+
$ldapBadge->markResolved();
100+
}
101+
102+
public static function getSubscribedEvents(): array
103+
{
104+
return [CheckPassportEvent::class => ['onCheckPassport', 144]];
105+
}
106+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Ldap\Security;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
17+
use Symfony\Component\Security\Core\Exception\AuthenticationException;
18+
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
19+
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
20+
21+
/**
22+
* This class decorates internal authenticators to add the LDAP integration.
23+
*
24+
* In your own authenticators, it is recommended to directly use the
25+
* LdapBadge in the authenticate() method. This class should only be
26+
* used for Symfony or third party authenticators.
27+
*
28+
* @author Wouter de Jong <wouter@wouterj.nl>
29+
*
30+
* @final
31+
* @experimental in Symfony 5.1
32+
*/
33+
class LdapAuthenticator implements AuthenticatorInterface
34+
{
35+
private $authenticator;
36+
private $ldapServiceId;
37+
private $dnString;
38+
private $searchDn;
39+
private $searchPassword;
40+
private $queryString;
41+
42+
public function __construct(AuthenticatorInterface $authenticator, string $ldapServiceId, string $dnString = '{username}', string $searchDn = '', string $searchPassword = '', string $queryString = '')
43+
{
44+
$this->authenticator = $authenticator;
45+
$this->ldapServiceId = $ldapServiceId;
46+
$this->dnString = $dnString;
47+
$this->searchDn = $searchDn;
48+
$this->searchPassword = $searchPassword;
49+
$this->queryString = $queryString;
50+
}
51+
52+
public function supports(Request $request): ?bool
53+
{
54+
return $this->authenticator->supports($request);
55+
}
56+
57+
public function authenticate(Request $request): PassportInterface
58+
{
59+
$passport = $this->authenticator->authenticate($request);
60+
$passport->addBadge(new LdapBadge($this->ldapServiceId, $this->dnString, $this->searchDn, $this->searchPassword, $this->queryString));
61+
62+
return $passport;
63+
}
64+
65+
public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
66+
{
67+
return $this->authenticator->createAuthenticatedToken($passport, $firewallName);
68+
}
69+
70+
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
71+
{
72+
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
73+
}
74+
75+
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
76+
{
77+
return $this->authenticator->onAuthenticationFailure($request, $exception);
78+
}
79+
}

0 commit comments

Comments
 (0)
0