Updated ldap connections to be carried via their own class.
Extracted connection logic to its own class.
Having trouble making this new format testable.
namespace BookStack\Auth\Access\Guards;
-use BookStack\Auth\Access\LdapService;
+use BookStack\Auth\Access\Ldap\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
+++ /dev/null
-<?php
-
-namespace BookStack\Auth\Access;
-
-use ErrorException;
-
-/**
- * Class Ldap
- * An object-orientated thin abstraction wrapper for common PHP LDAP functions.
- * Allows the standard LDAP functions to be mocked for testing.
- */
-class Ldap
-{
- /**
- * Connect to an LDAP server.
- *
- * @return resource
- */
- public function connect(string $hostName, int $port)
- {
- return ldap_connect($hostName, $port);
- }
-
- /**
- * Set the value of a LDAP option for the given connection.
- *
- * @param resource $ldapConnection
- * @param mixed $value
- */
- public function setOption($ldapConnection, int $option, $value): bool
- {
- return ldap_set_option($ldapConnection, $option, $value);
- }
-
- /**
- * Start TLS on the given LDAP connection.
- *
- * @throws ErrorException
- */
- public function startTls($ldapConnection): bool
- {
- return ldap_start_tls($ldapConnection);
- }
-
- /**
- * Set the version number for the given ldap connection.
- *
- * @param resource $ldapConnection
- */
- public function setVersion($ldapConnection, int $version): bool
- {
- return $this->setOption($ldapConnection, LDAP_OPT_PROTOCOL_VERSION, $version);
- }
-
- /**
- * Search LDAP tree using the provided filter.
- *
- * @param resource $ldapConnection
- * @param string $baseDn
- * @param string $filter
- * @param array|null $attributes
- *
- * @return resource
- */
- public function search($ldapConnection, $baseDn, $filter, array $attributes = null)
- {
- return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
- }
-
- /**
- * Get entries from an ldap search result.
- *
- * @param resource $ldapConnection
- * @param resource $ldapSearchResult
- *
- * @return array
- */
- public function getEntries($ldapConnection, $ldapSearchResult)
- {
- return ldap_get_entries($ldapConnection, $ldapSearchResult);
- }
-
- /**
- * Search and get entries immediately.
- *
- * @param resource $ldapConnection
- * @param string $baseDn
- * @param string $filter
- * @param array|null $attributes
- *
- * @return resource
- */
- public function searchAndGetEntries($ldapConnection, $baseDn, $filter, array $attributes = null)
- {
- $search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
-
- return $this->getEntries($ldapConnection, $search);
- }
-
- /**
- * Bind to LDAP directory.
- *
- * @param resource $ldapConnection
- * @throws ErrorException
- */
- public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
- {
- return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
- }
-
- /**
- * Explode a LDAP dn string into an array of components.
- *
- * @param string $dn
- * @param int $withAttrib
- *
- * @return array
- */
- public function explodeDn(string $dn, int $withAttrib)
- {
- return ldap_explode_dn($dn, $withAttrib);
- }
-
- /**
- * Escape a string for use in an LDAP filter.
- *
- * @param string $value
- * @param string $ignore
- * @param int $flags
- *
- * @return string
- */
- public function escape(string $value, string $ignore = '', int $flags = 0)
- {
- return ldap_escape($value, $ignore, $flags);
- }
-}
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\Ldap;
+
+use ErrorException;
+
+/**
+ * An object-orientated wrapper for core ldap functions,
+ * holding an internal connection instance.
+ */
+class LdapConnection
+{
+ /**
+ * The core ldap connection resource.
+ * @var resource
+ */
+ protected $connection;
+
+ public function __construct(string $hostName, int $port)
+ {
+ $this->connection = $this->connect($hostName, $port);
+ }
+
+ /**
+ * Connect to an LDAP server.
+ *
+ * @return resource
+ */
+ protected function connect(string $hostName, int $port)
+ {
+ return ldap_connect($hostName, $port);
+ }
+
+ /**
+ * Set the value of a LDAP option for the current connection.
+ *
+ * @param mixed $value
+ */
+ public function setOption(int $option, $value): bool
+ {
+ return ldap_set_option($this->connection, $option, $value);
+ }
+
+ /**
+ * Start TLS for this LDAP connection.
+ */
+ public function startTls(): bool
+ {
+ return ldap_start_tls($this->connection);
+ }
+
+ /**
+ * Set the version number for this ldap connection.
+ */
+ public function setVersion(int $version): bool
+ {
+ return $this->setOption(LDAP_OPT_PROTOCOL_VERSION, $version);
+ }
+
+ /**
+ * Search LDAP tree using the provided filter.
+ *
+ * @return resource
+ */
+ public function search(string $baseDn, string $filter, array $attributes = null)
+ {
+ return ldap_search($this->connection, $baseDn, $filter, $attributes);
+ }
+
+ /**
+ * Get entries from an ldap search result.
+ *
+ * @param resource $ldapSearchResult
+ * @return array|false
+ */
+ public function getEntries($ldapSearchResult)
+ {
+ return ldap_get_entries($this->connection, $ldapSearchResult);
+ }
+
+ /**
+ * Search and get entries immediately.
+ *
+ * @return array|false
+ */
+ public function searchAndGetEntries(string $baseDn, string $filter, array $attributes = null)
+ {
+ $search = $this->search($baseDn, $filter, $attributes);
+
+ return $this->getEntries($search);
+ }
+
+ /**
+ * Bind to LDAP directory.
+ *
+ * @throws ErrorException
+ */
+ public function bind(string $bindRdn = null, string $bindPassword = null): bool
+ {
+ return ldap_bind($this->connection, $bindRdn, $bindPassword);
+ }
+
+ /**
+ * Explode a LDAP dn string into an array of components.
+ *
+ * @return array|false
+ */
+ public static function explodeDn(string $dn, int $withAttrib)
+ {
+ return ldap_explode_dn($dn, $withAttrib);
+ }
+
+ /**
+ * Escape a string for use in an LDAP filter.
+ */
+ public static function escape(string $value, string $ignore = '', int $flags = 0): string
+ {
+ return ldap_escape($value, $ignore, $flags);
+ }
+
+ /**
+ * Set a non-connection-specific LDAP option.
+ * @param mixed $value
+ */
+ public static function setGlobalOption(int $option, $value): bool
+ {
+ return ldap_set_option(null, $option, $value);
+ }
+}
--- /dev/null
+<?php
+
+namespace BookStack\Auth\Access\Ldap;
+
+use BookStack\Exceptions\LdapException;
+use BookStack\Exceptions\LdapFailedBindException;
+use ErrorException;
+use Illuminate\Support\Facades\Log;
+
+class LdapConnectionManager
+{
+ protected array $connectionCache = [];
+
+ /**
+ * Attempt to start and bind to a new LDAP connection as the configured LDAP system user.
+ */
+ public function startSystemBind(array $config): LdapConnection
+ {
+ // Incoming options are string|false
+ $dn = $config['dn'];
+ $pass = $config['pass'];
+
+ $isAnonymous = ($dn === false || $pass === false);
+
+ try {
+ return $this->startBind($dn ?: null, $pass ?: null, $config);
+ } catch (LdapFailedBindException $exception) {
+ $msg = ($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed'));
+ throw new LdapFailedBindException($msg);
+ }
+ }
+
+ /**
+ * Attempt to start and bind to a new LDAP connection.
+ * Will attempt against multiple defined fail-over hosts if set in the provided config.
+ *
+ * Throws a LdapFailedBindException error if the bind connected but failed.
+ * Otherwise, generic LdapException errors would be thrown.
+ *
+ * @throws LdapException
+ */
+ public function startBind(?string $dn, ?string $password, array $config): LdapConnection
+ {
+ // Check LDAP extension in installed
+ if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
+ throw new LdapException(trans('errors.ldap_extension_not_installed'));
+ }
+
+ // Disable certificate verification.
+ // This option works globally and must be set before a connection is created.
+ if ($config['tls_insecure']) {
+ LdapConnection::setGlobalOption(LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
+ }
+
+ $serverDetails = $this->parseMultiServerString($config['server']);
+ $lastException = null;
+
+ foreach ($serverDetails as $server) {
+ try {
+ $connection = $this->startServerConnection($server['host'], $server['port'], $config);
+ } catch (LdapException $exception) {
+ $lastException = $exception;
+ continue;
+ }
+
+ try {
+ $bound = $connection->bind($dn, $password);
+ if (!$bound) {
+ throw new LdapFailedBindException('Failed to perform LDAP bind');
+ }
+ } catch (ErrorException $exception) {
+ Log::error('LDAP bind error: ' . $exception->getMessage());
+ $lastException = new LdapException('Encountered error during LDAP bind');
+ continue;
+ }
+
+ $this->connectionCache[$server['host'] . ':' . $server['port']] = $connection;
+ return $connection;
+ }
+
+ throw $lastException;
+ }
+
+ /**
+ * Attempt to start a server connection from the provided details.
+ * @throws LdapException
+ */
+ protected function startServerConnection(string $host, int $port, array $config): LdapConnection
+ {
+ if (isset($this->connectionCache[$host . ':' . $port])) {
+ return $this->connectionCache[$host . ':' . $port];
+ }
+
+ $ldapConnection = new LdapConnection($host, $port);
+
+ if (!$ldapConnection) {
+ throw new LdapException(trans('errors.ldap_cannot_connect'));
+ }
+
+ // Set any required options
+ if ($config['version']) {
+ $ldapConnection->setVersion($config['version']);
+ }
+
+ // Start and verify TLS if it's enabled
+ if ($config['start_tls']) {
+ try {
+ $tlsStarted = $ldapConnection->startTls();
+ } catch (ErrorException $exception) {
+ $tlsStarted = false;
+ }
+
+ if (!$tlsStarted) {
+ throw new LdapException('Could not start TLS connection');
+ }
+ }
+
+ return $ldapConnection;
+ }
+
+ /**
+ * Parse a potentially multi-value LDAP server host string and return an array of host/port detail pairs.
+ * Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
+ *
+ * @return array<array{host: string, port: int}>
+ */
+ protected function parseMultiServerString(string $serversString): array
+ {
+ $serverStringList = explode(';', $serversString);
+
+ return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
+ }
+
+ /**
+ * Parse an LDAP server string and return the host and port for a connection.
+ * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
+ *
+ * @return array{host: string, port: int}
+ */
+ protected function parseSingleServerString(string $serverString): array
+ {
+ $serverNameParts = explode(':', $serverString);
+
+ // If we have a protocol just return the full string since PHP will ignore a separate port.
+ if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
+ return ['host' => $serverString, 'port' => 389];
+ }
+
+ // Otherwise, extract the port out
+ $hostName = $serverNameParts[0];
+ $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
+
+ return ['host' => $hostName, 'port' => $ldapPort];
+ }
+}
<?php
-namespace BookStack\Auth\Access;
+namespace BookStack\Auth\Access\Ldap;
+use BookStack\Auth\Access\GroupSyncService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LdapFailedBindException;
use BookStack\Uploads\UserAvatars;
-use ErrorException;
use Illuminate\Support\Facades\Log;
/**
*/
class LdapService
{
- protected Ldap $ldap;
+ protected LdapConnectionManager $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
protected array $config;
- protected bool $enabled;
- /**
- * LdapService constructor.
- */
- public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
+ public function __construct(LdapConnectionManager $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
{
$this->ldap = $ldap;
$this->userAvatars = $userAvatars;
$this->groupSyncService = $groupSyncService;
$this->config = config('services.ldap');
- $this->enabled = config('auth.method') === 'ldap';
- }
-
- /**
- * Check if groups should be synced.
- */
- public function shouldSyncGroups(): bool
- {
- return $this->enabled && $this->config['user_to_groups'] !== false;
}
/**
*/
protected function getUserWithAttributes(string $userName, array $attributes): ?array
{
- $ldapConnection = $this->bindConnection();
+ $connection = $this->ldap->startSystemBind($this->config);
// Clean attributes
foreach ($attributes as $index => $attribute) {
$baseDn = $this->config['base_dn'];
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
- $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
- $users = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $userFilter, $attributes);
+ $connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
+ $users = $connection->searchAndGetEntries($baseDn, $userFilter, $attributes);
if ($users['count'] === 0) {
return null;
}
}
try {
- $this->bindConnection($ldapUserDetails['dn'], $password);
+ $this->ldap->startBind($ldapUserDetails['dn'], $password, $this->config);
} catch (LdapFailedBindException $e) {
return false;
} catch (LdapException $e) {
return true;
}
-
- /**
- * Attempted to start and bind to a new LDAP connection.
- * Will attempt against multiple defined fail-over hosts if set.
- *
- * Throws a LdapFailedBindException error if the bind connected but failed.
- * Otherwise, generic LdapException errors would be thrown.
- *
- * @return resource
- * @throws LdapException
- */
- protected function bindConnection(string $dn = null, string $password = null)
- {
- $systemBind = ($dn === null && $password === null);
-
- // Check LDAP extension in installed
- if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
- throw new LdapException(trans('errors.ldap_extension_not_installed'));
- }
-
- // Disable certificate verification.
- // This option works globally and must be set before a connection is created.
- if ($this->config['tls_insecure']) {
- $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
- }
-
- $serverDetails = $this->parseMultiServerString($this->config['server']);
- $lastException = null;
-
- foreach ($serverDetails as $server) {
- try {
- $connection = $this->startServerConnection($server);
- } catch (LdapException $exception) {
- $lastException = $exception;
- continue;
- }
-
- try {
- if ($systemBind) {
- $this->bindSystemUser($connection);
- } else {
- $this->bindGivenUser($connection, $dn, $password);
- }
- } catch (LdapFailedBindException $exception) {
- // Rethrow simply to indicate the importance of handling this exception case
- // to indicate auth status. We skip past attempting fail-over hosts in this case since it's
- // likely the connection worked here but the bind was unauthorised.
- throw $exception;
- } catch (ErrorException $exception) {
- Log::error('LDAP bind error: ' . $exception->getMessage());
- $lastException = new LdapException('Encountered error during LDAP bind');
- continue;
- }
-
- return $connection;
- }
-
- throw $lastException;
- }
-
- /**
- * Bind to the given LDAP connection using the given credentials.
- * MUST throw an exception on failure.
- *
- * @param resource $connection
- *
- * @throws LdapFailedBindException
- */
- protected function bindGivenUser($connection, string $dn = null, string $password = null): void
- {
- $ldapBind = $this->ldap->bind($connection, $dn, $password);
-
- if (!$ldapBind) {
- throw new LdapFailedBindException('Failed to bind with given user details');
- }
- }
-
- /**
- * Bind the system user to the LDAP connection using the configured credentials otherwise anonymous
- * access is attempted. MUST throw an exception on failure.
- *
- * @param resource $connection
- *
- * @throws LdapFailedBindException
- */
- protected function bindSystemUser($connection): void
- {
- $ldapDn = $this->config['dn'];
- $ldapPass = $this->config['pass'];
-
- $isAnonymous = ($ldapDn === false || $ldapPass === false);
- if ($isAnonymous) {
- $ldapBind = $this->ldap->bind($connection);
- } else {
- $ldapBind = $this->ldap->bind($connection, $ldapDn, $ldapPass);
- }
-
- if (!$ldapBind) {
- throw new LdapFailedBindException(($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed')));
- }
- }
-
- /**
- * Attempt to start a server connection from the provided details.
- *
- * @param array{host: string, port: int} $serverDetail
- * @return resource
- * @throws LdapException
- */
- protected function startServerConnection(array $serverDetail)
- {
- $ldapConnection = $this->ldap->connect($serverDetail['host'], $serverDetail['port']);
-
- if (!$ldapConnection) {
- throw new LdapException(trans('errors.ldap_cannot_connect'));
- }
-
- // Set any required options
- if ($this->config['version']) {
- $this->ldap->setVersion($ldapConnection, $this->config['version']);
- }
-
- // Start and verify TLS if it's enabled
- if ($this->config['start_tls']) {
- try {
- $tlsStarted = $this->ldap->startTls($ldapConnection);
- } catch (ErrorException $exception) {
- $tlsStarted = false;
- }
-
- if (!$tlsStarted) {
- throw new LdapException('Could not start TLS connection');
- }
- }
-
- return $ldapConnection;
- }
-
- /**
- * Parse a potentially multi-value LDAP server host string and return an array of host/port detail pairs.
- * Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
- *
- * @return array<array{host: string, port: int}>
- */
- protected function parseMultiServerString(string $serversString): array
- {
- $serverStringList = explode(';', $serversString);
-
- return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
- }
-
- /**
- * Parse an LDAP server string and return the host and port for a connection.
- * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
- *
- * @return array{host: string, port: int}
- */
- protected function parseSingleServerString(string $serverString): array
- {
- $serverNameParts = explode(':', $serverString);
-
- // If we have a protocol just return the full string since PHP will ignore a separate port.
- if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
- return ['host' => $serverString, 'port' => 389];
- }
-
- // Otherwise, extract the port out
- $hostName = $serverNameParts[0];
- $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
-
- return ['host' => $hostName, 'port' => $ldapPort];
- }
-
/**
* Build a filter string by injecting common variables.
*/
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
- $newAttrs[$newKey] = $this->ldap->escape($attrText);
+ $newAttrs[$newKey] = LdapConnection::escape($attrText);
}
return strtr($filterString, $newAttrs);
*/
private function getGroupGroups(string $groupName): array
{
- $ldapConnection = $this->bindConnection();
+ $connection = $this->ldap->startSystemBind($this->config);
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
- $this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
+ $connection->setOption(LDAP_OPT_REFERRALS, $followReferrals);
$baseDn = $this->config['base_dn'];
$groupsAttr = strtolower($this->config['group_attribute']);
- $groupFilter = 'CN=' . $this->ldap->escape($groupName);
- $groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
+ $groupFilter = 'CN=' . $connection->escape($groupName);
+ $groups = $connection->searchAndGetEntries($baseDn, $groupFilter, [$groupsAttr]);
if ($groups['count'] === 0) {
return [];
}
}
for ($i = 0; $i < $count; $i++) {
- $dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
+ $dnComponents = LdapConnection::explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
if (!in_array($dnComponents[0], $ldapGroups)) {
$ldapGroups[] = $dnComponents[0];
}
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
}
+ /**
+ * Check if groups should be synced.
+ */
+ public function shouldSyncGroups(): bool
+ {
+ return $this->config['user_to_groups'] !== false;
+ }
+
/**
* Save and attach an avatar image, if found in the ldap details, and attach
* to the given user model.
*/
public function saveAndAttachAvatar(User $user, array $ldapUserDetails): void
{
- if (is_null(config('services.ldap.thumbnail_attribute')) || is_null($ldapUserDetails['avatar'])) {
+ if (is_null($this->config['thumbnail_attribute']) || is_null($ldapUserDetails['avatar'])) {
return;
}
use BookStack\Auth\Access\ExternalBaseUserProvider;
use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard;
use BookStack\Auth\Access\Guards\LdapSessionGuard;
-use BookStack\Auth\Access\LdapService;
+use BookStack\Auth\Access\Ldap\LdapService;
use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Support\Facades\Auth;
namespace Tests\Auth;
-use BookStack\Auth\Access\Ldap;
-use BookStack\Auth\Access\LdapService;
+use BookStack\Auth\Access\Ldap\LdapConnection;
+use BookStack\Auth\Access\Ldap\LdapConnectionManager;
+use BookStack\Auth\Access\Ldap\LdapService;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Testing\TestResponse;
protected function setUp(): void
{
parent::setUp();
+
if (!defined('LDAP_OPT_REFERRALS')) {
define('LDAP_OPT_REFERRALS', 1);
}
+
config()->set([
'auth.method' => 'ldap',
'auth.defaults.guard' => 'ldap',
'services.ldap.tls_insecure' => false,
'services.ldap.thumbnail_attribute' => null,
]);
- $this->mockLdap = \Mockery::mock(Ldap::class);
- $this->app[Ldap::class] = $this->mockLdap;
+
+ $lcm = $this->mock(LdapConnectionManager::class);
+ // TODO - Properly mock
+
+ $this->mockLdap = \Mockery::mock(LdapConnection::class);
+ $this->app[LdapConnection::class] = $this->mockLdap;
$this->mockUser = User::factory()->make();
}
*/
protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0)
{
- $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId);
$this->mockLdap->shouldReceive('setVersion')->times($versions);
$this->mockLdap->shouldReceive('setOption')->times($options);
$this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true);