diff --git a/.travis.yml b/.travis.yml index 23533ea632..764897ff66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,6 +35,7 @@ before_install: - if [[ "$TRAVIS_PHP_VERSION" = 5.* ]]; then (pecl install -f memcached-2.1.0 && echo "extension = memcache.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini) || echo "Let's continue without memcache extension"; fi; - if [[ "$TRAVIS_PHP_VERSION" = 5.* ]] && [ "$deps" = "no" ]; then (cd src/Symfony/Component/Debug/Resources/ext && phpize && ./configure && make && echo "extension = $(pwd)/modules/symfony_debug.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini); fi; - if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]]; then php -i; fi; + - if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]]; then echo "extension = ldap.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi; - ./phpunit install - export PHPUNIT="$(readlink -f ./phpunit)" diff --git a/appveyor.yml b/appveyor.yml index 2e4d1cce40..069bee46c5 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -45,6 +45,7 @@ install: - IF %PHP_EXT%==1 echo extension=php_mbstring.dll >> php.ini - IF %PHP_EXT%==1 echo extension=php_fileinfo.dll >> php.ini - IF %PHP_EXT%==1 echo extension=php_pdo_sqlite.dll >> php.ini + - IF %PHP_EXT%==1 echo extension=php_ldap.dll >> php.ini - cd c:\projects\symfony - php phpunit install - IF %APPVEYOR_REPO_BRANCH%==master (SET COMPOSER_ROOT_VERSION=dev-master) ELSE (SET COMPOSER_ROOT_VERSION=%APPVEYOR_REPO_BRANCH%.x-dev) diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php new file mode 100644 index 0000000000..c758b32b8d --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/FormLoginLdapFactory.php @@ -0,0 +1,57 @@ + + * + * 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\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * FormLoginLdapFactory creates services for form login ldap authentication. + * + * @author Grégoire Pineau + * @author Charles Sarrazin + */ +class FormLoginLdapFactory extends FormLoginFactory +{ + protected function createAuthProvider(ContainerBuilder $container, $id, $config, $userProviderId) + { + $provider = 'security.authentication.provider.ldap_bind.'.$id; + $container + ->setDefinition($provider, new DefinitionDecorator('security.authentication.provider.ldap_bind')) + ->replaceArgument(0, new Reference($userProviderId)) + ->replaceArgument(2, $id) + ->replaceArgument(3, new Reference($config['service'])) + ->replaceArgument(4, $config['dn_string']) + ; + + return $provider; + } + + public function addConfiguration(NodeDefinition $node) + { + parent::addConfiguration($node); + + $node + ->children() + ->scalarNode('service')->end() + ->scalarNode('dn_string')->defaultValue('{username}')->end() + ->end() + ; + } + + public function getKey() + { + return 'form-login-ldap'; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php new file mode 100644 index 0000000000..23c0130584 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/HttpBasicLdapFactory.php @@ -0,0 +1,67 @@ + + * + * 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\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * HttpBasicFactory creates services for HTTP basic authentication. + * + * @author Fabien Potencier + * @author Grégoire Pineau + * @author Charles Sarrazin + */ +class HttpBasicLdapFactory extends HttpBasicFactory +{ + public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint) + { + $provider = 'security.authentication.provider.ldap_bind.'.$id; + $container + ->setDefinition($provider, new DefinitionDecorator('security.authentication.provider.ldap_bind')) + ->replaceArgument(0, new Reference($userProvider)) + ->replaceArgument(2, $id) + ->replaceArgument(3, new Reference($config['service'])) + ->replaceArgument(4, $config['dn_string']) + ; + + // entry point + $entryPointId = $this->createEntryPoint($container, $id, $config, $defaultEntryPoint); + + // listener + $listenerId = 'security.authentication.listener.basic.'.$id; + $listener = $container->setDefinition($listenerId, new DefinitionDecorator('security.authentication.listener.basic')); + $listener->replaceArgument(2, $id); + $listener->replaceArgument(3, new Reference($entryPointId)); + + return array($provider, $listenerId, $entryPointId); + } + + public function addConfiguration(NodeDefinition $node) + { + parent::addConfiguration($node); + + $node + ->children() + ->scalarNode('service')->end() + ->scalarNode('dn_string')->defaultValue('{username}')->end() + ->end() + ; + } + + public function getKey() + { + return 'http-basic-ldap'; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.php new file mode 100644 index 0000000000..068cda6a1f --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/UserProvider/LdapFactory.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\UserProvider; + +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\DependencyInjection\DefinitionDecorator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * LdapFactory creates services for Ldap user provider. + * + * @author Grégoire Pineau + * @author Charles Sarrazin + */ +class LdapFactory implements UserProviderFactoryInterface +{ + public function create(ContainerBuilder $container, $id, $config) + { + $container + ->setDefinition($id, new DefinitionDecorator('security.user.provider.ldap')) + ->replaceArgument(0, new Reference($config['service'])) + ->replaceArgument(1, $config['base_dn']) + ->replaceArgument(2, $config['search_dn']) + ->replaceArgument(3, $config['search_password']) + ->replaceArgument(4, $config['default_roles']) + ->replaceArgument(5, $config['uid_key']) + ->replaceArgument(6, $config['filter']) + ; + } + + public function getKey() + { + return 'ldap'; + } + + public function addConfiguration(NodeDefinition $node) + { + $node + ->children() + ->scalarNode('service')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('base_dn')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('search_dn')->end() + ->scalarNode('search_password')->end() + ->arrayNode('default_roles') + ->beforeNormalization()->ifString()->then(function($v) { return preg_split('/\s*,\s*/', $v); })->end() + ->requiresAtLeastOneElement() + ->prototype('scalar')->end() + ->end() + ->scalarNode('uid_key')->defaultValue('sAMAccountName')->end() + ->scalarNode('filter')->defaultValue('({uid_key}={username})')->end() + ->end() + ; + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml index b7c1407c1c..b1a2cdfc80 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.xml @@ -158,10 +158,21 @@ + + + + + + + + + + + @@ -169,6 +180,7 @@ + diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml index 917e90f792..948286bb5a 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security_listeners.xml @@ -223,6 +223,15 @@ %security.authentication.hide_user_not_found% + + + + + + + %security.authentication.hide_user_not_found% + + diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index f2dcab1061..f2dfc991fb 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -15,7 +15,9 @@ use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\AddSecurityVotersPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\FormLoginLdapFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpBasicLdapFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\HttpDigestFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\RememberMeFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\X509Factory; @@ -24,6 +26,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SimplePre use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SimpleFormFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\InMemoryFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\GuardAuthenticationFactory; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\UserProvider\LdapFactory; /** * Bundle. @@ -38,7 +41,9 @@ class SecurityBundle extends Bundle $extension = $container->getExtension('security'); $extension->addSecurityListenerFactory(new FormLoginFactory()); + $extension->addSecurityListenerFactory(new FormLoginLdapFactory()); $extension->addSecurityListenerFactory(new HttpBasicFactory()); + $extension->addSecurityListenerFactory(new HttpBasicLdapFactory()); $extension->addSecurityListenerFactory(new HttpDigestFactory()); $extension->addSecurityListenerFactory(new RememberMeFactory()); $extension->addSecurityListenerFactory(new X509Factory()); @@ -48,6 +53,7 @@ class SecurityBundle extends Bundle $extension->addSecurityListenerFactory(new GuardAuthenticationFactory()); $extension->addUserProviderFactory(new InMemoryFactory()); + $extension->addUserProviderFactory(new LdapFactory()); $container->addCompilerPass(new AddSecurityVotersPass()); } } diff --git a/src/Symfony/Component/Ldap/.gitignore b/src/Symfony/Component/Ldap/.gitignore new file mode 100644 index 0000000000..c49a5d8df5 --- /dev/null +++ b/src/Symfony/Component/Ldap/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/Ldap/Exception/ConnectionException.php b/src/Symfony/Component/Ldap/Exception/ConnectionException.php new file mode 100644 index 0000000000..80d9af51ea --- /dev/null +++ b/src/Symfony/Component/Ldap/Exception/ConnectionException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Ldap\Exception; + +/** + * ConnectionException is throw if binding to ldap can not be established. + * + * @author Grégoire Pineau + */ +class ConnectionException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/Ldap/Exception/LdapException.php b/src/Symfony/Component/Ldap/Exception/LdapException.php new file mode 100644 index 0000000000..213625b021 --- /dev/null +++ b/src/Symfony/Component/Ldap/Exception/LdapException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Ldap\Exception; + +/** + * LdapException is throw if php ldap module is not loaded. + * + * @author Grégoire Pineau + */ +class LdapException extends \RuntimeException +{ +} diff --git a/src/Symfony/Component/Ldap/LICENSE b/src/Symfony/Component/Ldap/LICENSE new file mode 100644 index 0000000000..43028bc600 --- /dev/null +++ b/src/Symfony/Component/Ldap/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-2015 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Ldap/LdapClient.php b/src/Symfony/Component/Ldap/LdapClient.php new file mode 100644 index 0000000000..5887a623ad --- /dev/null +++ b/src/Symfony/Component/Ldap/LdapClient.php @@ -0,0 +1,220 @@ + + * @author Francis Besset + * @author Charles Sarrazin + */ +class LdapClient implements LdapClientInterface +{ + private $host; + private $port; + private $version; + private $useSsl; + private $useStartTls; + private $optReferrals; + private $connection; + private $charmaps; + + /** + * Constructor. + * + * @param string $host + * @param int $port + * @param int $version + * @param bool $useSsl + * @param bool $useStartTls + * @param bool $optReferrals + */ + public function __construct($host = null, $port = 389, $version = 3, $useSsl = false, $useStartTls = false, $optReferrals = false) + { + if (!extension_loaded('ldap')) { + throw new LdapException('The ldap module is needed.'); + } + + $this->host = $host; + $this->port = $port; + $this->version = $version; + $this->useSsl = (bool) $useSsl; + $this->useStartTls = (bool) $useStartTls; + $this->optReferrals = (bool) $optReferrals; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * {@inheritdoc} + */ + public function bind($dn = null, $password = null) + { + if (!$this->connection) { + $this->connect(); + } + + if (false === @ldap_bind($this->connection, $dn, $password)) { + throw new ConnectionException(ldap_error($this->connection)); + } + } + + /** + * {@inheritdoc} + */ + public function find($dn, $query, $filter = '*') + { + if (!is_array($filter)) { + $filter = array($filter); + } + + $search = ldap_search($this->connection, $dn, $query, $filter); + $infos = ldap_get_entries($this->connection, $search); + + if (0 === $infos['count']) { + return; + } + + return $infos; + } + + /** + * {@inheritdoc} + */ + public function escape($subject, $ignore = '', $flags = 0) + { + if (function_exists('ldap_escape')) { + return ldap_escape($subject, $ignore, $flags); + } + + return $this->doEscape($subject, $ignore, $flags); + } + + private function connect() + { + if (!$this->connection) { + $host = $this->host; + + if ($this->useSsl) { + $host = 'ldaps://'.$host; + } + + ldap_set_option($this->connection, LDAP_OPT_PROTOCOL_VERSION, $this->version); + ldap_set_option($this->connection, LDAP_OPT_REFERRALS, $this->optReferrals); + + $this->connection = ldap_connect($host, $this->port); + + if ($this->useStartTls) { + ldap_start_tls($this->connection); + } + } + } + + private function disconnect() + { + if ($this->connection && is_resource($this->connection)) { + ldap_unbind($this->connection); + } + + $this->connection = null; + } + + /** + * Stub implementation of the {@link ldap_escape()} function of the ldap + * extension. + * + * Escape strings for safe use in LDAP filters and DNs. + * + * @author Chris Wright + * + * @param string $subject + * @param string $ignore + * @param int $flags + * + * @return string + * + * @see http://stackoverflow.com/a/8561604 + */ + private function doEscape($subject, $ignore = '', $flags = 0) + { + $charMaps = $this->getCharmaps(); + + // Create the base char map to escape + $flags = (int) $flags; + $charMap = array(); + + if ($flags & self::LDAP_ESCAPE_FILTER) { + $charMap += $charMaps[self::LDAP_ESCAPE_FILTER]; + } + + if ($flags & self::LDAP_ESCAPE_DN) { + $charMap += $charMaps[self::LDAP_ESCAPE_DN]; + } + + if (!$charMap) { + $charMap = $charMaps[0]; + } + + // Remove any chars to ignore from the list + $ignore = (string) $ignore; + + for ($i = 0, $l = strlen($ignore); $i < $l; ++$i) { + unset($charMap[$ignore[$i]]); + } + + // Do the main replacement + $result = strtr($subject, $charMap); + + // Encode leading/trailing spaces if LDAP_ESCAPE_DN is passed + if ($flags & self::LDAP_ESCAPE_DN) { + if ($result[0] === ' ') { + $result = '\\20'.substr($result, 1); + } + + if ($result[strlen($result) - 1] === ' ') { + $result = substr($result, 0, -1).'\\20'; + } + } + + return $result; + } + + private function getCharmaps() + { + if (null !== $this->charmaps) { + return $this->charmaps; + } + + $charMaps = array( + self::LDAP_ESCAPE_FILTER => array('\\', '*', '(', ')', "\x00"), + self::LDAP_ESCAPE_DN => array('\\', ',', '=', '+', '<', '>', ';', '"', '#'), + ); + + $charMaps[0] = array(); + + for ($i = 0; $i < 256; ++$i) { + $charMaps[0][chr($i)] = sprintf('\\%02x', $i); + } + + for ($i = 0, $l = count($charMaps[self::LDAP_ESCAPE_FILTER]); $i < $l; ++$i) { + $chr = $charMaps[self::LDAP_ESCAPE_FILTER][$i]; + unset($charMaps[self::LDAP_ESCAPE_FILTER][$i]); + $charMaps[self::LDAP_ESCAPE_FILTER][$chr] = $charMaps[0][$chr]; + } + + for ($i = 0, $l = count($charMaps[self::LDAP_ESCAPE_DN]); $i < $l; ++$i) { + $chr = $charMaps[self::LDAP_ESCAPE_DN][$i]; + unset($charMaps[self::LDAP_ESCAPE_DN][$i]); + $charMaps[self::LDAP_ESCAPE_DN][$chr] = $charMaps[0][$chr]; + } + + $this->charmaps = $charMaps; + + return $this->charmaps; + } +} diff --git a/src/Symfony/Component/Ldap/LdapClientInterface.php b/src/Symfony/Component/Ldap/LdapClientInterface.php new file mode 100644 index 0000000000..75e063d339 --- /dev/null +++ b/src/Symfony/Component/Ldap/LdapClientInterface.php @@ -0,0 +1,49 @@ + + * @author Charles Sarrazin + */ +interface LdapClientInterface +{ + const LDAP_ESCAPE_FILTER = 0x01; + const LDAP_ESCAPE_DN = 0x02; + + /** + * Return a connection bound to the ldap. + * + * @param string $dn A LDAP dn + * @param string $password A password + * + * @throws ConnectionException If dn / password could not be bound. + */ + public function bind($dn = null, $password = null); + + /* + * Find a username into ldap connection. + * + * @param string $dn + * @param string $query + * @param mixed $filter + * + * @return array|null + */ + public function find($dn, $query, $filter = '*'); + + /** + * Escape a string for use in an LDAP filter or DN. + * + * @param string $subject + * @param string $ignore + * @param int $flags + * + * @return string + */ + public function escape($subject, $ignore = '', $flags = 0); +} diff --git a/src/Symfony/Component/Ldap/README.md b/src/Symfony/Component/Ldap/README.md new file mode 100644 index 0000000000..751ce6ad25 --- /dev/null +++ b/src/Symfony/Component/Ldap/README.md @@ -0,0 +1,23 @@ +Ldap Component +============= + +A Ldap client for PHP on top of PHP's ldap extension. + +This component also provides a stub for the missing +`ldap_escape` function in PHP versions lower than 5.6. + +Documentation +------------- + +The documentation for the component can be found [online] [0]. + +Resources +--------- + +You can run the unit tests with the following command: + + $ cd path/to/Symfony/Component/Ldap/ + $ composer install + $ phpunit + +[0]: https://symfony.com/doc/2.8/components/ldap.html diff --git a/src/Symfony/Component/Ldap/Tests/LdapClientTest.php b/src/Symfony/Component/Ldap/Tests/LdapClientTest.php new file mode 100644 index 0000000000..5b67ea8540 --- /dev/null +++ b/src/Symfony/Component/Ldap/Tests/LdapClientTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Authentication\Provider; + +use Symfony\Component\Ldap\LdapClient; + +class LdapClientTest extends \PHPUnit_Framework_TestCase +{ + protected function setUp() + { + if (!extension_loaded('ldap')) { + $this->markTestSkipped('The ldap extension is not available'); + } + } + + /** + * @dataProvider provideLdapEscapeValues + */ + public function testLdapEscape($subject, $ignore, $flags, $expected) + { + $ldap = new LdapClient(); + $this->assertSame($expected, $ldap->escape($subject, $ignore, $flags)); + } + + /** + * Provides values for the ldap_escape shim. These tests come from the official + * extension. + * + * @see https://github.com/php/php-src/blob/master/ext/ldap/tests/ldap_escape_dn.phpt + * @see https://github.com/php/php-src/blob/master/ext/ldap/tests/ldap_escape_all.phpt + * @see https://github.com/php/php-src/blob/master/ext/ldap/tests/ldap_escape_both.phpt + * @see https://github.com/php/php-src/blob/master/ext/ldap/tests/ldap_escape_filter.phpt + * @see https://github.com/php/php-src/blob/master/ext/ldap/tests/ldap_escape_ignore.phpt + * + * @return array + */ + public function provideLdapEscapeValues() + { + return array( + array('foo=bar(baz)*', null, LdapClient::LDAP_ESCAPE_DN, 'foo\3dbar(baz)*'), + array('foo=bar(baz)*', null, null, '\66\6f\6f\3d\62\61\72\28\62\61\7a\29\2a'), + array('foo=bar(baz)*', null, LdapClient::LDAP_ESCAPE_DN | LdapClient::LDAP_ESCAPE_FILTER, 'foo\3dbar\28baz\29\2a'), + array('foo=bar(baz)*', null, LdapClient::LDAP_ESCAPE_FILTER, 'foo=bar\28baz\29\2a'), + array('foo=bar(baz)*', 'ao', null, '\66oo\3d\62a\72\28\62a\7a\29\2a'), + ); + } +} diff --git a/src/Symfony/Component/Ldap/composer.json b/src/Symfony/Component/Ldap/composer.json new file mode 100644 index 0000000000..5fa6dda3d7 --- /dev/null +++ b/src/Symfony/Component/Ldap/composer.json @@ -0,0 +1,34 @@ +{ + "name": "symfony/ldap", + "type": "library", + "description": "An abstraction in front of PHP's LDAP functions, compatible with PHP 5.3.9 onwards.", + "keywords": ["ldap", "active directory"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Charles Sarrazin", + "email": "charles@sarraz.in" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.9", + "ext-ldap": "*" + }, + "require-dev": { + "symfony/phpunit-bridge": "~2.7|~3.0.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Ldap\\": "" } + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + } +} diff --git a/src/Symfony/Component/Ldap/phpunit.xml.dist b/src/Symfony/Component/Ldap/phpunit.xml.dist new file mode 100644 index 0000000000..82f3331146 --- /dev/null +++ b/src/Symfony/Component/Ldap/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Security/Core/Authentication/Provider/LdapBindAuthenticationProvider.php b/src/Symfony/Component/Security/Core/Authentication/Provider/LdapBindAuthenticationProvider.php new file mode 100644 index 0000000000..9ce3bd2428 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Authentication/Provider/LdapBindAuthenticationProvider.php @@ -0,0 +1,76 @@ + + */ +class LdapBindAuthenticationProvider extends UserAuthenticationProvider +{ + private $userProvider; + private $ldap; + private $dnString; + + /** + * Constructor. + * + * @param UserProviderInterface $userProvider A UserProvider + * @param UserCheckerInterface $userChecker A UserChecker + * @param string $providerKey The provider key + * @param LdapClientInterface $ldap An Ldap client + * @param string $dnString A string used to create the bind DN + * @param bool $hideUserNotFoundExceptions Whether to hide user not found exception or not + */ + public function __construct(UserProviderInterface $userProvider, UserCheckerInterface $userChecker, $providerKey, LdapClientInterface $ldap, $dnString = '{username}', $hideUserNotFoundExceptions = true) + { + parent::__construct($userChecker, $providerKey, $hideUserNotFoundExceptions); + + $this->userProvider = $userProvider; + $this->ldap = $ldap; + $this->dnString = $dnString; + } + + /** + * {@inheritdoc} + */ + protected function retrieveUser($username, UsernamePasswordToken $token) + { + if ('NONE_PROVIDED' === $username) { + throw new UsernameNotFoundException('Username can not be null'); + } + + return $this->userProvider->loadUserByUsername($username); + } + + /** + * {@inheritdoc} + */ + protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token) + { + $username = $token->getUsername(); + $password = $token->getCredentials(); + + try { + $username = $this->ldap->escape($username, '', LdapClientInterface::LDAP_ESCAPE_DN); + $dn = str_replace('{username}', $username, $this->dnString); + + $this->ldap->bind($dn, $password); + } catch (ConnectionException $e) { + throw new BadCredentialsException('The presented password is invalid.'); + } + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/LdapBindAuthenticationProviderTest.php b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/LdapBindAuthenticationProviderTest.php new file mode 100644 index 0000000000..f1b5c0355f --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/Authentication/Provider/LdapBindAuthenticationProviderTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Tests\Authentication\Provider; + +use Symfony\Component\Security\Core\Authentication\Provider\LdapBindAuthenticationProvider; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\User\User; +use Symfony\Component\Ldap\Exception\ConnectionException; + +class LdapBindAuthenticationProviderTest extends \PHPUnit_Framework_TestCase +{ + /** + * @expectedException \Symfony\Component\Security\Core\Exception\BadCredentialsException + * @expectedExceptionMessage The presented password is invalid. + */ + public function testBindFailureShouldThrowAnException() + { + $userProvider = $this->getMock('Symfony\Component\Security\Core\User\UserProviderInterface'); + $ldap = $this->getMock('Symfony\Component\Ldap\LdapClientInterface'); + $ldap + ->expects($this->once()) + ->method('bind') + ->will($this->throwException(new ConnectionException())) + ; + $userChecker = $this->getMock('Symfony\Component\Security\Core\User\UserCheckerInterface'); + + $provider = new LdapBindAuthenticationProvider($userProvider, $userChecker, 'key', $ldap); + $reflection = new \ReflectionMethod($provider, 'checkAuthentication'); + $reflection->setAccessible(true); + + $reflection->invoke($provider, new User('foo', null), new UsernamePasswordToken('foo', '', 'key')); + } + + public function testRetrieveUser() + { + $userProvider = $this->getMock('Symfony\Component\Security\Core\User\UserProviderInterface'); + $userProvider + ->expects($this->once()) + ->method('loadUserByUsername') + ->with('foo') + ; + $ldap = $this->getMock('Symfony\Component\Ldap\LdapClientInterface'); + + $userChecker = $this->getMock('Symfony\Component\Security\Core\User\UserCheckerInterface'); + + $provider = new LdapBindAuthenticationProvider($userProvider, $userChecker, 'key', $ldap); + $reflection = new \ReflectionMethod($provider, 'retrieveUser'); + $reflection->setAccessible(true); + + $reflection->invoke($provider, 'foo', new UsernamePasswordToken('foo', 'bar', 'key')); + } +} diff --git a/src/Symfony/Component/Security/Core/Tests/User/LdapUserProviderTest.php b/src/Symfony/Component/Security/Core/Tests/User/LdapUserProviderTest.php new file mode 100644 index 0000000000..b69987aa53 --- /dev/null +++ b/src/Symfony/Component/Security/Core/Tests/User/LdapUserProviderTest.php @@ -0,0 +1,93 @@ +getMock('Symfony\Component\Ldap\LdapClientInterface'); + $ldap + ->expects($this->once()) + ->method('bind') + ->will($this->throwException(new ConnectionException())) + ; + + $provider = new LdapUserProvider($ldap, 'ou=MyBusiness,dc=symfony,dc=com'); + $provider->loadUserByUsername('foo'); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\UsernameNotFoundException + */ + public function testLoadUserByUsernameFailsIfNoLdapEntries() + { + $ldap = $this->getMock('Symfony\Component\Ldap\LdapClientInterface'); + $ldap + ->expects($this->once()) + ->method('escape') + ->will($this->returnValue('foo')) + ; + + $provider = new LdapUserProvider($ldap, 'ou=MyBusiness,dc=symfony,dc=com'); + $provider->loadUserByUsername('foo'); + } + + /** + * @expectedException \Symfony\Component\Security\Core\Exception\UsernameNotFoundException + */ + public function testLoadUserByUsernameFailsIfMoreThanOneLdapEntry() + { + $ldap = $this->getMock('Symfony\Component\Ldap\LdapClientInterface'); + $ldap + ->expects($this->once()) + ->method('escape') + ->will($this->returnValue('foo')) + ; + $ldap + ->expects($this->once()) + ->method('find') + ->will($this->returnValue(array( + array(), + array(), + 'count' => 2, + ))) + ; + + $provider = new LdapUserProvider($ldap, 'ou=MyBusiness,dc=symfony,dc=com'); + $provider->loadUserByUsername('foo'); + } + + public function testSuccessfulLoadUserByUsername() + { + $ldap = $this->getMock('Symfony\Component\Ldap\LdapClientInterface'); + $ldap + ->expects($this->once()) + ->method('escape') + ->will($this->returnValue('foo')) + ; + $ldap + ->expects($this->once()) + ->method('find') + ->will($this->returnValue(array( + array( + 'sAMAccountName' => 'foo', + 'userpassword' => 'bar', + ), + 'count' => 1, + ))) + ; + + $provider = new LdapUserProvider($ldap, 'ou=MyBusiness,dc=symfony,dc=com'); + $this->assertInstanceOf( + 'Symfony\Component\Security\Core\User\User', + $provider->loadUserByUsername('foo') + ); + } +} diff --git a/src/Symfony/Component/Security/Core/User/LdapUserProvider.php b/src/Symfony/Component/Security/Core/User/LdapUserProvider.php new file mode 100644 index 0000000000..ec699fc022 --- /dev/null +++ b/src/Symfony/Component/Security/Core/User/LdapUserProvider.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\User; + +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; +use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; +use Symfony\Component\Ldap\Exception\ConnectionException; +use Symfony\Component\Ldap\LdapClientInterface; + +/** + * LdapUserProvider is a simple user provider on top of ldap. + * + * @author Grégoire Pineau + * @author Charles Sarrazin + */ +class LdapUserProvider implements UserProviderInterface +{ + private $ldap; + private $baseDn; + private $searchDn; + private $searchPassword; + private $defaultRoles; + private $defaultSearch; + + /** + * @param LdapClientInterface $ldap + * @param string $baseDn + * @param string $searchDn + * @param string $searchPassword + * @param array $defaultRoles + * @param string $uidKey + * @param string $filter + */ + public function __construct(LdapClientInterface $ldap, $baseDn, $searchDn = null, $searchPassword = null, array $defaultRoles = array(), $uidKey = 'sAMAccountName', $filter = '({uid_key}={username})') + { + $this->ldap = $ldap; + $this->baseDn = $baseDn; + $this->searchDn = $searchDn; + $this->searchPassword = $searchPassword; + $this->defaultRoles = $defaultRoles; + $this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter); + } + + /** + * {@inheritdoc} + */ + public function loadUserByUsername($username) + { + try { + $this->ldap->bind($this->searchDn, $this->searchPassword); + $username = $this->ldap->escape($username, '', LdapClientInterface::LDAP_ESCAPE_FILTER); + $query = str_replace('{username}', $username, $this->defaultSearch); + $search = $this->ldap->find($this->baseDn, $query); + } catch (ConnectionException $e) { + throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username), 0, $e); + } + + if (!$search) { + throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username)); + } + + if ($search['count'] > 1) { + throw new UsernameNotFoundException('More than one user found'); + } + + $user = $search[0]; + + return $this->loadUser($username, $user); + } + + public function loadUser($username, $user) + { + $password = isset($user['userpassword']) ? $user['userpassword'] : null; + + $roles = $this->defaultRoles; + + return new User($username, $password, $roles); + } + + /** + * {@inheritdoc} + */ + public function refreshUser(UserInterface $user) + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user))); + } + + return new User($user->getUsername(), null, $user->getRoles()); + } + + /** + * {@inheritdoc} + */ + public function supportsClass($class) + { + return $class === 'Symfony\Component\Security\Core\User\User'; + } + +} diff --git a/src/Symfony/Component/Security/Core/composer.json b/src/Symfony/Component/Security/Core/composer.json index 4d2405317c..6a1ac993c9 100644 --- a/src/Symfony/Component/Security/Core/composer.json +++ b/src/Symfony/Component/Security/Core/composer.json @@ -26,13 +26,15 @@ "symfony/translation": "~2.0,>=2.0.5|~3.0.0", "symfony/validator": "~2.5,>=2.5.5|~3.0.0", "psr/log": "~1.0", - "ircmaxell/password-compat": "1.0.*" + "ircmaxell/password-compat": "1.0.*", + "symfony/ldap": "~2.8|~3.0.0" }, "suggest": { "symfony/event-dispatcher": "", "symfony/http-foundation": "", "symfony/validator": "For using the user password constraint", "symfony/expression-language": "For using the expression voter", + "symfony/ldap": "For using LDAP integration", "ircmaxell/password-compat": "For using the BCrypt password encoder in PHP <5.5" }, "autoload": { diff --git a/src/Symfony/Component/Security/composer.json b/src/Symfony/Component/Security/composer.json index e4e9a8b701..87a47a53ad 100644 --- a/src/Symfony/Component/Security/composer.json +++ b/src/Symfony/Component/Security/composer.json @@ -38,7 +38,8 @@ "doctrine/dbal": "~2.2", "psr/log": "~1.0", "ircmaxell/password-compat": "~1.0", - "symfony/expression-language": "~2.6|~3.0.0" + "symfony/expression-language": "~2.6|~3.0.0", + "symfony/ldap": "~2.8|~3.0.0" }, "suggest": { "symfony/class-loader": "For using the ACL generateSql script", @@ -48,7 +49,8 @@ "symfony/routing": "For using the HttpUtils class to create sub-requests, redirect the user, and match URLs", "symfony/expression-language": "For using the expression voter", "ircmaxell/password-compat": "For using the BCrypt password encoder in PHP <5.5", - "paragonie/random_compat": "" + "paragonie/random_compat": "", + "symfony/ldap": "For using the LDAP user and authentication providers" }, "autoload": { "psr-4": { "Symfony\\Component\\Security\\": "" }