first commit

This commit is contained in:
2025-07-18 16:20:14 +07:00
commit 98af45c018
16382 changed files with 3148096 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?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\Adapter;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
abstract class AbstractConnection implements ConnectionInterface
{
protected $config;
public function __construct(array $config = [])
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$this->config = $resolver->resolve($config);
}
/**
* @return void
*/
protected function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'host' => 'localhost',
'version' => 3,
'connection_string' => null,
'encryption' => 'none',
'options' => [],
]);
$resolver->setDefault('port', fn (Options $options) => 'ssl' === $options['encryption'] ? 636 : 389);
$resolver->setDefault('connection_string', fn (Options $options) => sprintf('ldap%s://%s:%s', 'ssl' === $options['encryption'] ? 's' : '', $options['host'], $options['port']));
$resolver->setAllowedTypes('host', 'string');
$resolver->setAllowedTypes('port', 'numeric');
$resolver->setAllowedTypes('connection_string', 'string');
$resolver->setAllowedTypes('version', 'numeric');
$resolver->setAllowedValues('encryption', ['none', 'ssl', 'tls']);
$resolver->setAllowedTypes('options', 'array');
}
}

View File

@@ -0,0 +1,50 @@
<?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\Adapter;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
abstract class AbstractQuery implements QueryInterface
{
protected $connection;
protected $dn;
protected $query;
protected $options;
public function __construct(ConnectionInterface $connection, string $dn, string $query, array $options = [])
{
$resolver = new OptionsResolver();
$resolver->setDefaults([
'filter' => '*',
'maxItems' => 0,
'sizeLimit' => 0,
'timeout' => 0,
'deref' => static::DEREF_NEVER,
'attrsOnly' => 0,
'scope' => static::SCOPE_SUB,
'pageSize' => 0,
]);
$resolver->setAllowedValues('deref', [static::DEREF_ALWAYS, static::DEREF_NEVER, static::DEREF_FINDING, static::DEREF_SEARCHING]);
$resolver->setAllowedValues('scope', [static::SCOPE_BASE, static::SCOPE_ONE, static::SCOPE_SUB]);
$resolver->setNormalizer('filter', fn (Options $options, $value) => \is_array($value) ? $value : [$value]);
$this->connection = $connection;
$this->dn = $dn;
$this->query = $query;
$this->options = $resolver->resolve($options);
}
}

View File

@@ -0,0 +1,38 @@
<?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\Adapter;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
interface AdapterInterface
{
/**
* Returns the current connection.
*/
public function getConnection(): ConnectionInterface;
/**
* Creates a new Query.
*/
public function createQuery(string $dn, string $query, array $options = []): QueryInterface;
/**
* Fetches the entry manager instance.
*/
public function getEntryManager(): EntryManagerInterface;
/**
* Escape a string for use in an LDAP filter or DN.
*/
public function escape(string $subject, string $ignore = '', int $flags = 0): string;
}

View File

@@ -0,0 +1,28 @@
<?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\Adapter;
use Symfony\Component\Ldap\Entry;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*
* @extends \ArrayAccess<int, Entry>
* @extends \IteratorAggregate<int, Entry>
*/
interface CollectionInterface extends \Countable, \IteratorAggregate, \ArrayAccess
{
/**
* @return list<Entry>
*/
public function toArray(): array;
}

View File

@@ -0,0 +1,38 @@
<?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\Adapter;
use Symfony\Component\Ldap\Exception\AlreadyExistsException;
use Symfony\Component\Ldap\Exception\ConnectionTimeoutException;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
interface ConnectionInterface
{
/**
* Checks whether the connection was already bound or not.
*/
public function isBound(): bool;
/**
* Binds the connection against a user's DN and password.
*
* @return void
*
* @throws AlreadyExistsException When the connection can't be created because of an LDAP_ALREADY_EXISTS error
* @throws ConnectionTimeoutException When the connection can't be created because of an LDAP_TIMEOUT error
* @throws InvalidCredentialsException When the connection can't be created because of an LDAP_INVALID_CREDENTIALS error
*/
public function bind(?string $dn = null, #[\SensitiveParameter] ?string $password = null);
}

View File

@@ -0,0 +1,74 @@
<?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\Adapter;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\LdapException;
use Symfony\Component\Ldap\Exception\NotBoundException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
* @author Bob van de Vijver <bobvandevijver@hotmail.com>
* @author Kevin Schuurmans <kevin.schuurmans@freshheads.com>
*/
interface EntryManagerInterface
{
/**
* Adds a new entry in the Ldap server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function add(Entry $entry);
/**
* Updates an entry from the Ldap server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function update(Entry $entry);
/**
* Moves an entry on the Ldap server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function move(Entry $entry, string $newParent);
/**
* Renames an entry on the Ldap server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function rename(Entry $entry, string $newRdn, bool $removeOldRdn = true);
/**
* Removes an entry from the Ldap server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function remove(Entry $entry);
}

View File

@@ -0,0 +1,70 @@
<?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\Adapter\ExtLdap;
use Symfony\Component\Ldap\Adapter\AdapterInterface;
use Symfony\Component\Ldap\Adapter\ConnectionInterface;
use Symfony\Component\Ldap\Adapter\EntryManagerInterface;
use Symfony\Component\Ldap\Adapter\QueryInterface;
use Symfony\Component\Ldap\Exception\LdapException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
class Adapter implements AdapterInterface
{
private array $config;
private ConnectionInterface $connection;
private EntryManagerInterface $entryManager;
public function __construct(array $config = [])
{
if (!\extension_loaded('ldap')) {
throw new LdapException('The LDAP PHP extension is not enabled.');
}
$this->config = $config;
}
public function getConnection(): ConnectionInterface
{
return $this->connection ??= new Connection($this->config);
}
public function getEntryManager(): EntryManagerInterface
{
return $this->entryManager ??= new EntryManager($this->getConnection());
}
public function createQuery(string $dn, string $query, array $options = []): QueryInterface
{
return new Query($this->getConnection(), $dn, $query, $options);
}
public function escape(string $subject, string $ignore = '', int $flags = 0): string
{
$value = ldap_escape($subject, $ignore, $flags);
// Per RFC 4514, leading/trailing spaces should be encoded in DNs, as well as carriage returns.
if ($flags & \LDAP_ESCAPE_DN) {
if (!empty($value) && ' ' === $value[0]) {
$value = '\\20'.substr($value, 1);
}
if (!empty($value) && ' ' === $value[\strlen($value) - 1]) {
$value = substr($value, 0, -1).'\\20';
}
$value = str_replace("\r", '\0d', $value);
}
return $value;
}
}

View File

@@ -0,0 +1,137 @@
<?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\Adapter\ExtLdap;
use Symfony\Component\Ldap\Adapter\CollectionInterface;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\LdapException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
class Collection implements CollectionInterface
{
private Connection $connection;
private Query $search;
/** @var list<Entry> */
private array $entries;
public function __construct(Connection $connection, Query $search)
{
$this->connection = $connection;
$this->search = $search;
}
public function toArray(): array
{
return $this->entries ??= iterator_to_array($this->getIterator(), false);
}
public function count(): int
{
$con = $this->connection->getResource();
$searches = $this->search->getResources();
$count = 0;
foreach ($searches as $search) {
$searchCount = ldap_count_entries($con, $search);
if (false === $searchCount) {
throw new LdapException('Error while retrieving entry count: '.ldap_error($con));
}
$count += $searchCount;
}
return $count;
}
public function getIterator(): \Traversable
{
if (0 === $this->count()) {
return;
}
$con = $this->connection->getResource();
$searches = $this->search->getResources();
foreach ($searches as $search) {
$current = ldap_first_entry($con, $search);
if (false === $current) {
throw new LdapException('Could not rewind entries array: '.ldap_error($con));
}
yield $this->getSingleEntry($con, $current);
while (false !== $current = ldap_next_entry($con, $current)) {
yield $this->getSingleEntry($con, $current);
}
}
}
public function offsetExists(mixed $offset): bool
{
$this->toArray();
return isset($this->entries[$offset]);
}
public function offsetGet(mixed $offset): ?Entry
{
$this->toArray();
return $this->entries[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->toArray();
$this->entries[$offset] = $value;
}
public function offsetUnset($offset): void
{
$this->toArray();
unset($this->entries[$offset]);
}
private function getSingleEntry($con, $current): Entry
{
$attributes = ldap_get_attributes($con, $current);
if (false === $attributes) {
throw new LdapException('Could not fetch attributes: '.ldap_error($con));
}
$attributes = $this->cleanupAttributes($attributes);
$dn = ldap_get_dn($con, $current);
if (false === $dn) {
throw new LdapException('Could not fetch DN: '.ldap_error($con));
}
return new Entry($dn, $attributes);
}
private function cleanupAttributes(array $entry): array
{
$attributes = array_diff_key($entry, array_flip(range(0, $entry['count'] - 1)) + [
'count' => null,
'dn' => null,
]);
array_walk($attributes, function (&$value) {
unset($value['count']);
});
return $attributes;
}
}

View File

@@ -0,0 +1,189 @@
<?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\Adapter\ExtLdap;
use LDAP\Connection as LDAPConnection;
use Symfony\Component\Ldap\Adapter\AbstractConnection;
use Symfony\Component\Ldap\Exception\AlreadyExistsException;
use Symfony\Component\Ldap\Exception\ConnectionException;
use Symfony\Component\Ldap\Exception\ConnectionTimeoutException;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\LdapException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
class Connection extends AbstractConnection
{
private const LDAP_INVALID_CREDENTIALS = 0x31;
private const LDAP_TIMEOUT = 0x55;
private const LDAP_ALREADY_EXISTS = 0x44;
private const PRECONNECT_OPTIONS = [
ConnectionOptions::DEBUG_LEVEL,
ConnectionOptions::X_TLS_CACERTDIR,
ConnectionOptions::X_TLS_CACERTFILE,
ConnectionOptions::X_TLS_REQUIRE_CERT,
];
private bool $bound = false;
private ?LDAPConnection $connection = null;
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
/**
* @return void
*/
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
$this->disconnect();
}
public function isBound(): bool
{
return $this->bound;
}
/**
* @param string $password WARNING: When the LDAP server allows unauthenticated binds, a blank $password will always be valid
*
* @return void
*/
public function bind(?string $dn = null, #[\SensitiveParameter] ?string $password = null)
{
if (!$this->connection) {
$this->connect();
}
if (false === @ldap_bind($this->connection, $dn, $password)) {
$error = ldap_error($this->connection);
switch (ldap_errno($this->connection)) {
case self::LDAP_INVALID_CREDENTIALS:
throw new InvalidCredentialsException($error);
case self::LDAP_TIMEOUT:
throw new ConnectionTimeoutException($error);
case self::LDAP_ALREADY_EXISTS:
throw new AlreadyExistsException($error);
}
throw new ConnectionException($error);
}
$this->bound = true;
}
/**
* @internal
*/
public function getResource(): ?LDAPConnection
{
return $this->connection;
}
/**
* @return void
*/
public function setOption(string $name, array|string|int|bool $value)
{
if (!@ldap_set_option($this->connection, ConnectionOptions::getOption($name), $value)) {
throw new LdapException(sprintf('Could not set value "%s" for option "%s".', $value, $name));
}
}
/**
* @return array|string|int|null
*/
public function getOption(string $name)
{
if (!@ldap_get_option($this->connection, ConnectionOptions::getOption($name), $ret)) {
throw new LdapException(sprintf('Could not retrieve value for option "%s".', $name));
}
return $ret;
}
/**
* @return void
*/
protected function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefault('debug', false);
$resolver->setAllowedTypes('debug', 'bool');
$resolver->setDefault('referrals', false);
$resolver->setAllowedTypes('referrals', 'bool');
$resolver->setDefault('options', function (OptionsResolver $options, Options $parent) {
$options->setDefined(array_map('strtolower', array_keys((new \ReflectionClass(ConnectionOptions::class))->getConstants())));
if (true === $parent['debug']) {
$options->setDefault('debug_level', 7);
}
if (!isset($parent['network_timeout'])) {
$options->setDefault('network_timeout', \ini_get('default_socket_timeout'));
}
$options->setDefaults([
'protocol_version' => $parent['version'],
'referrals' => $parent['referrals'],
]);
});
}
private function connect(): void
{
if ($this->connection) {
return;
}
foreach ($this->config['options'] as $name => $value) {
if (\in_array(ConnectionOptions::getOption($name), self::PRECONNECT_OPTIONS, true)) {
$this->setOption($name, $value);
}
}
if (false === $connection = ldap_connect($this->config['connection_string'])) {
throw new LdapException('Invalid connection string: '.$this->config['connection_string']);
} else {
$this->connection = $connection;
}
foreach ($this->config['options'] as $name => $value) {
if (!\in_array(ConnectionOptions::getOption($name), self::PRECONNECT_OPTIONS, true)) {
$this->setOption($name, $value);
}
}
if ('tls' === $this->config['encryption'] && false === @ldap_start_tls($this->connection)) {
throw new LdapException('Could not initiate TLS connection: '.ldap_error($this->connection));
}
}
private function disconnect(): void
{
if ($this->connection) {
ldap_unbind($this->connection);
}
$this->connection = null;
$this->bound = false;
}
}

View File

@@ -0,0 +1,93 @@
<?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\Adapter\ExtLdap;
use Symfony\Component\Ldap\Exception\LdapException;
/**
* A class representing the Ldap extension's options, which can be used with
* ldap_set_option or ldap_get_option.
*
* @author Charles Sarrazin <charles@sarraz.in>
*
* @internal
*/
final class ConnectionOptions
{
public const API_INFO = 0x00;
public const DEREF = 0x02;
public const SIZELIMIT = 0x03;
public const TIMELIMIT = 0x04;
public const REFERRALS = 0x08;
public const RESTART = 0x09;
public const PROTOCOL_VERSION = 0x11;
public const SERVER_CONTROLS = 0x12;
public const CLIENT_CONTROLS = 0x13;
public const API_FEATURE_INFO = 0x15;
public const HOST_NAME = 0x30;
public const ERROR_NUMBER = 0x31;
public const ERROR_STRING = 0x32;
public const MATCHED_DN = 0x33;
public const DEBUG_LEVEL = 0x5001;
public const TIMEOUT = 0x5002;
public const NETWORK_TIMEOUT = 0x5005;
public const X_TLS_CACERTFILE = 0x6002;
public const X_TLS_CACERTDIR = 0x6003;
public const X_TLS_CERTFILE = 0x6004;
public const X_TLS_CRL_ALL = 0x02;
public const X_TLS_CRL_NONE = 0x00;
public const X_TLS_CRL_PEER = 0x01;
public const X_TLS_KEYFILE = 0x6005;
public const X_TLS_REQUIRE_CERT = 0x6006;
public const X_TLS_PROTOCOL_MIN = 0x6007;
public const X_TLS_CIPHER_SUITE = 0x6008;
public const X_TLS_RANDOM_FILE = 0x6009;
public const X_TLS_CRLFILE = 0x6010;
public const X_TLS_PACKAGE = 0x6011;
public const X_TLS_CRLCHECK = 0x600B;
public const X_TLS_DHFILE = 0x600E;
public const X_SASL_MECH = 0x6100;
public const X_SASL_REALM = 0x6101;
public const X_SASL_AUTHCID = 0x6102;
public const X_SASL_AUTHZID = 0x6103;
public const X_KEEPALIVE_IDLE = 0x6300;
public const X_KEEPALIVE_PROBES = 0x6301;
public const X_KEEPALIVE_INTERVAL = 0x6302;
public static function getOptionName(string $name): string
{
return sprintf('%s::%s', self::class, strtoupper($name));
}
/**
* Fetches an option's corresponding constant value from an option name.
* The option name can either be in snake or camel case.
*
* @throws LdapException
*/
public static function getOption(string $name): int
{
// Convert
$constantName = self::getOptionName($name);
if (!\defined($constantName)) {
throw new LdapException(sprintf('Unknown option "%s".', $name));
}
return \constant($constantName);
}
public static function isOption(string $name): bool
{
return \defined(self::getOptionName($name));
}
}

View File

@@ -0,0 +1,189 @@
<?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\Adapter\ExtLdap;
use LDAP\Connection as LDAPConnection;
use Symfony\Component\Ldap\Adapter\EntryManagerInterface;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\LdapException;
use Symfony\Component\Ldap\Exception\NotBoundException;
use Symfony\Component\Ldap\Exception\UpdateOperationException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
* @author Bob van de Vijver <bobvandevijver@hotmail.com>
*/
class EntryManager implements EntryManagerInterface
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return $this
*/
public function add(Entry $entry)
{
$con = $this->getConnectionResource();
if (!@ldap_add($con, $entry->getDn(), $entry->getAttributes())) {
throw new LdapException(sprintf('Could not add entry "%s": ', $entry->getDn()).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* @return $this
*/
public function update(Entry $entry)
{
$con = $this->getConnectionResource();
if (!@ldap_modify($con, $entry->getDn(), $entry->getAttributes())) {
throw new LdapException(sprintf('Could not update entry "%s": ', $entry->getDn()).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* @return $this
*/
public function remove(Entry $entry)
{
$con = $this->getConnectionResource();
if (!@ldap_delete($con, $entry->getDn())) {
throw new LdapException(sprintf('Could not remove entry "%s": ', $entry->getDn()).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* Adds values to an entry's multi-valued attribute from the LDAP server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function addAttributeValues(Entry $entry, string $attribute, array $values)
{
$con = $this->getConnectionResource();
if (!@ldap_mod_add($con, $entry->getDn(), [$attribute => $values])) {
throw new LdapException(sprintf('Could not add values to entry "%s", attribute "%s": ', $entry->getDn(), $attribute).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* Removes values from an entry's multi-valued attribute from the LDAP server.
*
* @return $this
*
* @throws NotBoundException
* @throws LdapException
*/
public function removeAttributeValues(Entry $entry, string $attribute, array $values)
{
$con = $this->getConnectionResource();
if (!@ldap_mod_del($con, $entry->getDn(), [$attribute => $values])) {
throw new LdapException(sprintf('Could not remove values from entry "%s", attribute "%s": ', $entry->getDn(), $attribute).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* @return $this
*/
public function rename(Entry $entry, string $newRdn, bool $removeOldRdn = true)
{
$con = $this->getConnectionResource();
if (!@ldap_rename($con, $entry->getDn(), $newRdn, '', $removeOldRdn)) {
throw new LdapException(sprintf('Could not rename entry "%s" to "%s": ', $entry->getDn(), $newRdn).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* Moves an entry on the Ldap server.
*
* @return $this
*
* @throws NotBoundException if the connection has not been previously bound
* @throws LdapException if an error is thrown during the rename operation
*/
public function move(Entry $entry, string $newParent)
{
$rdn = $this->parseRdnFromEntry($entry);
$con = $this->getConnectionResource();
// deleteOldRdn does not matter here, since the Rdn will not be changing in the move.
if (!@ldap_rename($con, $entry->getDn(), $rdn, $newParent, true)) {
throw new LdapException(sprintf('Could not move entry "%s" to "%s": ', $entry->getDn(), $newParent).ldap_error($con), ldap_errno($con));
}
return $this;
}
/**
* Get the connection resource, but first check if the connection is bound.
*/
private function getConnectionResource(): LDAPConnection
{
// If the connection is not bound, throw an exception. Users should use an explicit bind call first.
if (!$this->connection->isBound()) {
throw new NotBoundException('Query execution is not possible without binding the connection first.');
}
return $this->connection->getResource();
}
/**
* @param iterable<int, UpdateOperation> $operations An array or iterable of UpdateOperation instances
*
* @return $this
*
* @throws UpdateOperationException in case of an error
*/
public function applyOperations(string $dn, iterable $operations)
{
$operationsMapped = [];
foreach ($operations as $modification) {
$operationsMapped[] = $modification->toArray();
}
$con = $this->getConnectionResource();
if (!@ldap_modify_batch($con, $dn, $operationsMapped)) {
throw new UpdateOperationException(sprintf('Error executing UpdateOperation on "%s": ', $dn).ldap_error($con), ldap_errno($con));
}
return $this;
}
private function parseRdnFromEntry(Entry $entry): string
{
if (!preg_match('/(^[^,\\\\]*(?:\\\\.[^,\\\\]*)*),/', $entry->getDn(), $matches)) {
throw new LdapException(sprintf('Entry "%s" malformed, could not parse RDN.', $entry->getDn()));
}
return $matches[1];
}
}

View File

@@ -0,0 +1,209 @@
<?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\Adapter\ExtLdap;
use LDAP\Result;
use Symfony\Component\Ldap\Adapter\AbstractQuery;
use Symfony\Component\Ldap\Adapter\CollectionInterface;
use Symfony\Component\Ldap\Exception\LdapException;
use Symfony\Component\Ldap\Exception\NotBoundException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
* @author Bob van de Vijver <bobvandevijver@hotmail.com>
*/
class Query extends AbstractQuery
{
public const PAGINATION_OID = \LDAP_CONTROL_PAGEDRESULTS;
/** @var Result[] */
private array $results;
private array $serverctrls = [];
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
/**
* @return void
*/
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
$con = $this->connection->getResource();
if (!isset($this->results)) {
return;
}
foreach ($this->results as $result) {
if (false === $result || null === $result) {
continue;
}
if (!ldap_free_result($result)) {
throw new LdapException('Could not free results: '.ldap_error($con));
}
}
}
public function execute(): CollectionInterface
{
if (!isset($this->results)) {
// If the connection is not bound, throw an exception. Users should use an explicit bind call first.
if (!$this->connection->isBound()) {
throw new NotBoundException('Query execution is not possible without binding the connection first.');
}
$this->results = [];
$con = $this->connection->getResource();
$func = match ($this->options['scope']) {
static::SCOPE_BASE => 'ldap_read',
static::SCOPE_ONE => 'ldap_list',
static::SCOPE_SUB => 'ldap_search',
default => throw new LdapException(sprintf('Could not search in scope "%s".', $this->options['scope'])),
};
$itemsLeft = $maxItems = $this->options['maxItems'];
$pageSize = $this->options['pageSize'];
// Deal with the logic to handle maxItems properly. If we can satisfy it in
// one request based on pageSize, we don't need to bother sending page control
// to the server so that it can determine what we already know.
if (0 !== $maxItems && $pageSize > $maxItems) {
$pageSize = 0;
} elseif (0 !== $maxItems) {
$pageSize = min($maxItems, $pageSize);
}
$pageControl = $this->options['scope'] != static::SCOPE_BASE && $pageSize > 0;
$cookie = '';
do {
if ($pageControl) {
$this->controlPagedResult($pageSize, true, $cookie);
}
$sizeLimit = $itemsLeft;
if ($pageSize > 0 && $sizeLimit >= $pageSize) {
$sizeLimit = 0;
}
$search = @$func($con, $this->dn, $this->query, $this->options['filter'], $this->options['attrsOnly'], $sizeLimit, $this->options['timeout'], $this->options['deref'], $this->serverctrls);
if (false === $search) {
$ldapError = '';
if ($errno = ldap_errno($con)) {
$ldapError = sprintf(' LDAP error was [%d] %s', $errno, ldap_error($con));
}
if ($pageControl) {
$this->resetPagination();
}
throw new LdapException(sprintf('Could not complete search with dn "%s", query "%s" and filters "%s".%s.', $this->dn, $this->query, implode(',', $this->options['filter']), $ldapError), $errno);
}
$this->results[] = $search;
$itemsLeft -= min($itemsLeft, $pageSize);
if (0 !== $maxItems && 0 === $itemsLeft) {
break;
}
if ($pageControl) {
ldap_parse_result($con, $search, $errcode, $matcheddn, $errmsg, $referrals, $controls);
$cookie = $controls[\LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'] ?? '';
}
} while (null !== $cookie && '' !== $cookie);
if ($pageControl) {
$this->resetPagination();
}
}
return new Collection($this->connection, $this);
}
/**
* Returns an LDAP search resource. If this query resulted in multiple searches, only the first
* page will be returned.
*
* @internal
*/
public function getResource(int $idx = 0): ?Result
{
return $this->results[$idx] ?? null;
}
/**
* Returns all LDAP search resources.
*
* @return Result[]
*
* @internal
*/
public function getResources(): array
{
return $this->results;
}
/**
* Resets pagination on the current connection.
*/
private function resetPagination(): void
{
$con = $this->connection->getResource();
$this->controlPagedResult(0, false, '');
$this->serverctrls = [];
// This is a workaround for a bit of a bug in the above invocation
// of ldap_control_paged_result. Instead of indicating to extldap that
// we no longer wish to page queries on this link, this invocation sets
// the LDAP_CONTROL_PAGEDRESULTS OID with a page size of 0. This isn't
// well defined by RFC 2696 if there is no cookie present, so some servers
// will interpret it differently and do the wrong thing. Forcefully remove
// the OID for now until a fix can make its way through the versions of PHP
// the we support.
//
// This is not supported in PHP < 7.2, so these versions will remain broken.
$ctl = [];
ldap_get_option($con, \LDAP_OPT_SERVER_CONTROLS, $ctl);
if (!empty($ctl)) {
foreach ($ctl as $idx => $info) {
if (static::PAGINATION_OID == $info['oid']) {
unset($ctl[$idx]);
}
}
ldap_set_option($con, \LDAP_OPT_SERVER_CONTROLS, $ctl);
}
}
/**
* Sets LDAP pagination controls.
*/
private function controlPagedResult(int $pageSize, bool $critical, string $cookie): bool
{
$this->serverctrls = [
[
'oid' => \LDAP_CONTROL_PAGEDRESULTS,
'isCritical' => $critical,
'value' => [
'size' => $pageSize,
'cookie' => $cookie,
],
],
];
return true;
}
}

View File

@@ -0,0 +1,62 @@
<?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\Adapter\ExtLdap;
use Symfony\Component\Ldap\Exception\UpdateOperationException;
class UpdateOperation
{
private int $operationType;
private ?array $values;
private string $attribute;
private const VALID_OPERATION_TYPES = [
\LDAP_MODIFY_BATCH_ADD,
\LDAP_MODIFY_BATCH_REMOVE,
\LDAP_MODIFY_BATCH_REMOVE_ALL,
\LDAP_MODIFY_BATCH_REPLACE,
];
/**
* @param int $operationType An LDAP_MODIFY_BATCH_* constant
* @param string $attribute The attribute to batch modify on
*
* @throws UpdateOperationException on consistency errors during construction
*/
public function __construct(int $operationType, string $attribute, ?array $values)
{
if (!\in_array($operationType, self::VALID_OPERATION_TYPES, true)) {
throw new UpdateOperationException(sprintf('"%s" is not a valid modification type.', $operationType));
}
if (\LDAP_MODIFY_BATCH_REMOVE_ALL === $operationType && null !== $values) {
throw new UpdateOperationException(sprintf('$values must be null for LDAP_MODIFY_BATCH_REMOVE_ALL operation, "%s" given.', get_debug_type($values)));
}
$this->operationType = $operationType;
$this->attribute = $attribute;
$this->values = $values;
}
public function toArray(): array
{
$op = [
'attrib' => $this->attribute,
'modtype' => $this->operationType,
];
if (\LDAP_MODIFY_BATCH_REMOVE_ALL !== $this->operationType) {
$op['values'] = $this->values;
}
return $op;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Ldap\Adapter;
use Symfony\Component\Ldap\Exception\LdapException;
use Symfony\Component\Ldap\Exception\NotBoundException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
* @author Bob van de Vijver <bobvandevijver@hotmail.com>
*/
interface QueryInterface
{
public const DEREF_NEVER = 0x00;
public const DEREF_SEARCHING = 0x01;
public const DEREF_FINDING = 0x02;
public const DEREF_ALWAYS = 0x03;
public const SCOPE_BASE = 'base';
public const SCOPE_ONE = 'one';
public const SCOPE_SUB = 'sub';
/**
* Executes a query and returns the list of Ldap entries.
*
* @throws NotBoundException
* @throws LdapException
*/
public function execute(): CollectionInterface;
}

View File

@@ -0,0 +1,75 @@
CHANGELOG
=========
6.2
---
* Deprecate `{username}` parameter use in favour of `{user_identifier}`
6.1
---
* Return a 500 Internal Server Error if LDAP server in unavailable during user enumeration / authentication
* Introduce `InvalidSearchCredentialsException` to differentiate between cases where user-provided credentials are invalid and cases where the configured search credentials are invalid
6.0
---
* Removed `LdapUser::getUsername()` method, use `getUserIdentifier()` instead
* Removed `LdapUserProvider::loadUserByUsername()` method, use `loadUserByIdentifier()` instead
5.3
---
* The authenticator system is no longer experimental
* Added caseSensitive option for attribute keys in the Entry class.
5.1.0
-----
* Added `Security\LdapBadge`, `Security\LdapAuthenticator` and `Security\CheckLdapCredentialsListener` to integrate with the authenticator Security system
5.0.0
-----
* Added method `move() to `EntryManagerInterface`
* Added pagination support to the ExtLdap adapter with the pageSize query option
4.4.0
-----
* Added the "extra_fields" option, an array of custom fields to pull from the LDAP server
4.3.0
-----
* Added `EntryManager::move`, not implementing it is deprecated
* Added pagination support to the ExtLdap adapter with the pageSize query option
4.2.0
-----
* Added `EntryManager::applyOperations`
* Added timeout option to `ConnectionOptions`
4.1.0
-----
* Added support for adding values to multi-valued attributes
* Added support for removing values from multi-valued attributes
4.0.0
-----
* Removed the `LdapClient` class and the `LdapClientInterface`
* Removed the `RenameEntryInterface` interface and merged with `EntryManagerInterface`
3.3.0
-----
* The `RenameEntryInterface` inferface is deprecated, and will be merged with `EntryManagerInterface` in 4.0.
3.1.0
-----
* The `LdapClient` class is deprecated. Use the `Ldap` class instead.

View File

@@ -0,0 +1,127 @@
<?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;
/**
* @author Charles Sarrazin <charles@sarraz.in>
* @author Karl Shea <karl@karlshea.com>
*/
class Entry
{
private string $dn;
/**
* @var array<string, array>
*/
private array $attributes = [];
/**
* @var array<string, string>
*/
private array $lowerMap = [];
/**
* @param array<string, array> $attributes
*/
public function __construct(string $dn, array $attributes = [])
{
$this->dn = $dn;
foreach ($attributes as $key => $attribute) {
$this->setAttribute($key, $attribute);
}
}
/**
* Returns the entry's DN.
*/
public function getDn(): string
{
return $this->dn;
}
/**
* Returns whether an attribute exists.
*
* @param string $name The name of the attribute
* @param bool $caseSensitive Whether the check should be case-sensitive
*/
public function hasAttribute(string $name, bool $caseSensitive = true): bool
{
$attributeKey = $this->getAttributeKey($name, $caseSensitive);
if (null === $attributeKey) {
return false;
}
return isset($this->attributes[$attributeKey]);
}
/**
* Returns a specific attribute's value.
*
* As LDAP can return multiple values for a single attribute,
* this value is returned as an array.
*
* @param string $name The name of the attribute
* @param bool $caseSensitive Whether the attribute name is case-sensitive
*/
public function getAttribute(string $name, bool $caseSensitive = true): ?array
{
$attributeKey = $this->getAttributeKey($name, $caseSensitive);
if (null === $attributeKey) {
return null;
}
return $this->attributes[$attributeKey] ?? null;
}
/**
* Returns the complete list of attributes.
*/
public function getAttributes(): array
{
return $this->attributes;
}
/**
* Sets a value for the given attribute.
*
* @return void
*/
public function setAttribute(string $name, array $value)
{
$this->attributes[$name] = $value;
$this->lowerMap[strtolower($name)] = $name;
}
/**
* Removes a given attribute.
*
* @return void
*/
public function removeAttribute(string $name)
{
unset($this->attributes[$name]);
unset($this->lowerMap[strtolower($name)]);
}
private function getAttributeKey(string $name, bool $caseSensitive = true): ?string
{
if ($caseSensitive) {
return $name;
}
return $this->lowerMap[strtolower($name)] ?? null;
}
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* AlreadyExistsException is thrown if the element already exists.
*
* @author Hamza Amrouche <hamza.simperfit@gmail.com>
*/
class AlreadyExistsException extends ConnectionException
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* ConnectionException is thrown if binding to ldap cannot be established.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class ConnectionException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* ConnectionTimeoutException is thrown if binding to ldap time out.
*
* @author Hamza Amrouche <hamza.simperfit@gmail.com>
*/
class ConnectionTimeoutException extends ConnectionException
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* LdapException is thrown if php ldap module is not loaded.
*
* @author Charles Sarrazin <charles@sarraz.in>
*/
class DriverNotFoundException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* Base ExceptionInterface for the Ldap component.
*
* @author Charles Sarrazin <charles@sarraz.in>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* InvalidCredentialsException is thrown if binding to ldap has been done with invalid credentials.
*
* @author Hamza Amrouche <hamza.simperfit@gmail.com>
*/
class InvalidCredentialsException extends ConnectionException
{
}

View File

@@ -0,0 +1,22 @@
<?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\Exception;
/**
* InvalidSearchCredentialsException is thrown if binding to ldap fails when
* using the configured search_dn and search_password.
*
* @author Jeroen de Boer <info@jayfrown.nl>
*/
class InvalidSearchCredentialsException extends InvalidCredentialsException
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* LdapException is thrown if php ldap module is not loaded.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class LdapException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
/**
* NotBoundException is thrown if the connection with the LDAP server is not yet bound.
*
* @author Bob van de Vijver <bobvandevijver@hotmail.com>
*/
class NotBoundException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,16 @@
<?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\Exception;
class UpdateOperationException extends LdapException
{
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2004-present 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.

View File

@@ -0,0 +1,66 @@
<?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;
use Symfony\Component\Ldap\Adapter\AdapterInterface;
use Symfony\Component\Ldap\Adapter\EntryManagerInterface;
use Symfony\Component\Ldap\Adapter\ExtLdap\Adapter;
use Symfony\Component\Ldap\Adapter\QueryInterface;
use Symfony\Component\Ldap\Exception\DriverNotFoundException;
/**
* @author Charles Sarrazin <charles@sarraz.in>
*/
final class Ldap implements LdapInterface
{
private AdapterInterface $adapter;
public function __construct(AdapterInterface $adapter)
{
$this->adapter = $adapter;
}
public function bind(?string $dn = null, #[\SensitiveParameter] ?string $password = null): void
{
$this->adapter->getConnection()->bind($dn, $password);
}
public function query(string $dn, string $query, array $options = []): QueryInterface
{
return $this->adapter->createQuery($dn, $query, $options);
}
public function getEntryManager(): EntryManagerInterface
{
return $this->adapter->getEntryManager();
}
public function escape(string $subject, string $ignore = '', int $flags = 0): string
{
return $this->adapter->escape($subject, $ignore, $flags);
}
/**
* Creates a new Ldap instance.
*
* @param string $adapter The adapter name
* @param array $config The adapter's configuration
*/
public static function create(string $adapter, array $config = []): static
{
if ('ext_ldap' !== $adapter) {
throw new DriverNotFoundException(sprintf('Adapter "%s" not found. Only "ext_ldap" is supported at the moment.', $adapter));
}
return new self(new Adapter($config));
}
}

View File

@@ -0,0 +1,48 @@
<?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;
use Symfony\Component\Ldap\Adapter\EntryManagerInterface;
use Symfony\Component\Ldap\Adapter\QueryInterface;
use Symfony\Component\Ldap\Exception\ConnectionException;
/**
* Ldap interface.
*
* @author Charles Sarrazin <charles@sarraz.in>
*/
interface LdapInterface
{
public const ESCAPE_FILTER = 0x01;
public const ESCAPE_DN = 0x02;
/**
* Return a connection bound to the ldap.
*
* @return void
*
* @throws ConnectionException if dn / password could not be bound
*/
public function bind(?string $dn = null, #[\SensitiveParameter] ?string $password = null);
/**
* Queries a ldap server for entries matching the given criteria.
*/
public function query(string $dn, string $query, array $options = []): QueryInterface;
public function getEntryManager(): EntryManagerInterface;
/**
* Escape a string for use in an LDAP filter or DN.
*/
public function escape(string $subject, string $ignore = '', int $flags = 0): string;
}

View File

@@ -0,0 +1,22 @@
Ldap Component
==============
The LDAP component provides a LDAP client for PHP on top of PHP's ldap
extension.
Disclaimer
----------
This component is only stable since Symfony 3.1. Earlier versions
have been marked as internal as they still needed some work.
Breaking changes were introduced in Symfony 3.1, so code relying on
previous version of the component will break with this version.
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/ldap)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@@ -0,0 +1,115 @@
<?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\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException;
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\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 ContainerInterface $ldapLocator;
public function __construct(ContainerInterface $ldapLocator)
{
$this->ldapLocator = $ldapLocator;
}
/**
* @return void
*/
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->hasBadge(PasswordCredentials::class)) {
throw new \LogicException(sprintf('LDAP authentication requires a passport containing password credentials, authenticator "%s" does not fulfill these requirements.', $event->getAuthenticator()::class));
}
/** @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.');
}
$user = $passport->getUser();
/** @var LdapInterface $ldap */
$ldap = $this->ldapLocator->get($ldapBadge->getLdapServiceId());
try {
if ($ldapBadge->getQueryString()) {
if ('' !== $ldapBadge->getSearchDn() && '' !== $ldapBadge->getSearchPassword()) {
try {
$ldap->bind($ldapBadge->getSearchDn(), $ldapBadge->getSearchPassword());
} catch (InvalidCredentialsException) {
throw new InvalidSearchCredentialsException();
}
} else {
throw new LogicException('Using the "query_string" config without using a "search_dn" and a "search_password" is not supported.');
}
$identifier = $ldap->escape($user->getUserIdentifier(), '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{user_identifier}', $identifier, $ldapBadge->getQueryString());
$result = $ldap->query($ldapBadge->getDnString(), $query)->execute();
if (1 !== $result->count()) {
throw new BadCredentialsException('The presented user identifier is invalid.');
}
$dn = $result[0]->getDn();
} else {
$identifier = $ldap->escape($user->getUserIdentifier(), '', LdapInterface::ESCAPE_DN);
$dn = str_replace('{user_identifier}', $identifier, $ldapBadge->getDnString());
}
$ldap->bind($dn, $presentedPassword);
} catch (InvalidCredentialsException) {
throw new BadCredentialsException('The presented password is invalid.');
}
$passwordCredentials->markResolved();
$ldapBadge->markResolved();
}
public static function getSubscribedEvents(): array
{
return [CheckPassportEvent::class => ['onCheckPassport', 144]];
}
}

View File

@@ -0,0 +1,108 @@
<?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\InteractiveAuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Security\Http\EntryPoint\Exception\NotAnEntryPointException;
/**
* 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
*/
class LdapAuthenticator implements AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface
{
private AuthenticatorInterface $authenticator;
private string $ldapServiceId;
private string $dnString;
private string $searchDn;
private string $searchPassword;
private string $queryString;
public function __construct(AuthenticatorInterface $authenticator, string $ldapServiceId, string $dnString = '{user_identifier}', 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): Passport
{
$passport = $this->authenticator->authenticate($request);
$passport->addBadge(new LdapBadge($this->ldapServiceId, $this->dnString, $this->searchDn, $this->searchPassword, $this->queryString));
return $passport;
}
/**
* @internal
*/
public function createAuthenticatedToken(PassportInterface $passport, string $firewallName): TokenInterface
{
throw new \BadMethodCallException(sprintf('The "%s()" method cannot be called.', __METHOD__));
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
return $this->authenticator->createToken($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);
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
if (!$this->authenticator instanceof AuthenticationEntryPointInterface) {
throw new NotAnEntryPointException(sprintf('Decorated authenticator "%s" does not implement interface "%s".', get_debug_type($this->authenticator), AuthenticationEntryPointInterface::class));
}
return $this->authenticator->start($request, $authException);
}
public function isInteractive(): bool
{
if ($this->authenticator instanceof InteractiveAuthenticatorInterface) {
return $this->authenticator->isInteractive();
}
return false;
}
}

View File

@@ -0,0 +1,85 @@
<?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
*/
class LdapBadge implements BadgeInterface
{
private bool $resolved = false;
private string $ldapServiceId;
private string $dnString;
private string $searchDn;
private string $searchPassword;
private ?string $queryString;
public function __construct(string $ldapServiceId, string $dnString = '{user_identifier}', string $searchDn = '', string $searchPassword = '', ?string $queryString = null)
{
$this->ldapServiceId = $ldapServiceId;
$dnString = str_replace('{username}', '{user_identifier}', $dnString, $replaceCount);
if ($replaceCount > 0) {
trigger_deprecation('symfony/ldap', '6.2', 'Using "{username}" parameter in LDAP configuration is deprecated, consider using "{user_identifier}" instead.');
}
$this->dnString = $dnString;
$this->searchDn = $searchDn;
$this->searchPassword = $searchPassword;
$queryString = str_replace('{username}', '{user_identifier}', $queryString ?? '', $replaceCount);
if ($replaceCount > 0) {
trigger_deprecation('symfony/ldap', '6.2', 'Using "{username}" parameter in LDAP configuration is deprecated, consider using "{user_identifier}" instead.');
}
$this->queryString = $queryString;
}
public function getLdapServiceId(): string
{
return $this->ldapServiceId;
}
public function getDnString(): string
{
return $this->dnString;
}
public function getSearchDn(): string
{
return $this->searchDn;
}
public function getSearchPassword(): string
{
return $this->searchPassword;
}
public function getQueryString(): ?string
{
return $this->queryString;
}
public function markResolved(): void
{
$this->resolved = true;
}
public function isResolved(): bool
{
return $this->resolved;
}
}

View File

@@ -0,0 +1,113 @@
<?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\Ldap\Entry;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @final
*/
class LdapUser implements UserInterface, PasswordAuthenticatedUserInterface, EquatableInterface
{
private Entry $entry;
private string $identifier;
private ?string $password;
private array $roles;
private array $extraFields;
public function __construct(Entry $entry, string $identifier, #[\SensitiveParameter] ?string $password, array $roles = [], array $extraFields = [])
{
if (!$identifier) {
throw new \InvalidArgumentException('The username cannot be empty.');
}
$this->entry = $entry;
$this->identifier = $identifier;
$this->password = $password;
$this->roles = $roles;
$this->extraFields = $extraFields;
}
public function getEntry(): Entry
{
return $this->entry;
}
public function getRoles(): array
{
return $this->roles;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getSalt(): ?string
{
return null;
}
/**
* @internal for compatibility with Symfony 5.4
*/
public function getUsername(): string
{
return $this->getUserIdentifier();
}
public function getUserIdentifier(): string
{
return $this->identifier;
}
public function eraseCredentials(): void
{
$this->password = null;
}
public function getExtraFields(): array
{
return $this->extraFields;
}
public function setPassword(#[\SensitiveParameter] string $password): void
{
$this->password = $password;
}
public function isEqualTo(UserInterface $user): bool
{
if (!$user instanceof self) {
return false;
}
if ($this->getPassword() !== $user->getPassword()) {
return false;
}
if ($this->getSalt() !== $user->getSalt()) {
return false;
}
if ($this->getUserIdentifier() !== $user->getUserIdentifier()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,189 @@
<?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\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ExceptionInterface;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* LdapUserProvider is a simple user provider on top of LDAP.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Charles Sarrazin <charles@sarraz.in>
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @template-implements UserProviderInterface<LdapUser>
*/
class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private LdapInterface $ldap;
private string $baseDn;
private ?string $searchDn;
private ?string $searchPassword;
private array $defaultRoles;
private ?string $uidKey;
private string $defaultSearch;
private ?string $passwordAttribute;
private array $extraFields;
public function __construct(LdapInterface $ldap, string $baseDn, ?string $searchDn = null, #[\SensitiveParameter] ?string $searchPassword = null, array $defaultRoles = [], ?string $uidKey = null, ?string $filter = null, ?string $passwordAttribute = null, array $extraFields = [])
{
$uidKey ??= 'sAMAccountName';
$filter ??= '({uid_key}={user_identifier})';
$this->ldap = $ldap;
$this->baseDn = $baseDn;
$this->searchDn = $searchDn;
$this->searchPassword = $searchPassword;
$this->defaultRoles = $defaultRoles;
$this->uidKey = $uidKey;
$this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter);
$this->passwordAttribute = $passwordAttribute;
$this->extraFields = $extraFields;
}
/**
* @internal for compatibility with Symfony 5.4
*/
public function loadUserByUsername(string $username): UserInterface
{
return $this->loadUserByIdentifier($username);
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
try {
$this->ldap->bind($this->searchDn, $this->searchPassword);
} catch (InvalidCredentialsException) {
throw new InvalidSearchCredentialsException();
}
$identifier = $this->ldap->escape($identifier, '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{username}', '{user_identifier}', $this->defaultSearch, $replaceCount);
if ($replaceCount > 0) {
trigger_deprecation('symfony/ldap', '6.2', 'Using "{username}" parameter in LDAP configuration is deprecated, consider using "{user_identifier}" instead.');
}
$query = str_replace('{user_identifier}', $identifier, $query);
$search = $this->ldap->query($this->baseDn, $query, ['filter' => 0 == \count($this->extraFields) ? '*' : $this->extraFields]);
$entries = $search->execute();
$count = \count($entries);
if (!$count) {
$e = new UserNotFoundException(sprintf('User "%s" not found.', $identifier));
$e->setUserIdentifier($identifier);
throw $e;
}
if ($count > 1) {
$e = new UserNotFoundException('More than one user found.');
$e->setUserIdentifier($identifier);
throw $e;
}
$entry = $entries[0];
try {
if (null !== $this->uidKey) {
$identifier = $this->getAttributeValue($entry, $this->uidKey);
}
} catch (InvalidArgumentException) {
}
return $this->loadUser($identifier, $entry);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
return new LdapUser($user->getEntry(), $user->getUserIdentifier(), $user->getPassword(), $user->getRoles(), $user->getExtraFields());
}
/**
* @final
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
if (null === $this->passwordAttribute) {
return;
}
try {
$user->getEntry()->setAttribute($this->passwordAttribute, [$newHashedPassword]);
$this->ldap->getEntryManager()->update($user->getEntry());
$user->setPassword($newHashedPassword);
} catch (ExceptionInterface) {
// ignore failed password upgrades
}
}
public function supportsClass(string $class): bool
{
return LdapUser::class === $class;
}
/**
* Loads a user from an LDAP entry.
*/
protected function loadUser(string $identifier, Entry $entry): UserInterface
{
$password = null;
$extraFields = [];
if (null !== $this->passwordAttribute) {
$password = $this->getAttributeValue($entry, $this->passwordAttribute);
}
foreach ($this->extraFields as $field) {
$extraFields[$field] = $this->getAttributeValue($entry, $field);
}
return new LdapUser($entry, $identifier, $password, $this->defaultRoles, $extraFields);
}
private function getAttributeValue(Entry $entry, string $attribute): mixed
{
if (!$entry->hasAttribute($attribute)) {
throw new InvalidArgumentException(sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
}
$values = $entry->getAttribute($attribute);
if (!\in_array($attribute, [$this->uidKey, $this->passwordAttribute])) {
return $values;
}
if (1 !== \count($values)) {
throw new InvalidArgumentException(sprintf('Attribute "%s" has multiple values.', $attribute));
}
return $values[0];
}
}

View File

@@ -0,0 +1,39 @@
{
"name": "symfony/ldap",
"type": "library",
"description": "Provides a LDAP client for PHP on top of PHP's ldap extension",
"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": ">=8.1",
"ext-ldap": "*",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/options-resolver": "^5.4|^6.0|^7.0"
},
"require-dev": {
"symfony/security-core": "^5.4|^6.0|^7.0",
"symfony/security-http": "^5.4|^6.0|^7.0"
},
"conflict": {
"symfony/options-resolver": "<5.4",
"symfony/security-core": "<5.4"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Ldap\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}