3 namespace BookStack\Auth\Access\Ldap;
5 use BookStack\Exceptions\LdapException;
6 use BookStack\Exceptions\LdapFailedBindException;
8 use Illuminate\Support\Facades\Log;
10 class LdapConnectionManager
12 protected array $connectionCache = [];
15 * Attempt to start and bind to a new LDAP connection as the configured LDAP system user.
17 public function startSystemBind(array $config): LdapConnection
19 // Incoming options are string|false
21 $pass = $config['pass'];
23 $isAnonymous = ($dn === false || $pass === false);
26 return $this->startBind($dn ?: null, $pass ?: null, $config);
27 } catch (LdapFailedBindException $exception) {
28 $msg = ($isAnonymous ? trans('errors.ldap_fail_anonymous') : trans('errors.ldap_fail_authed'));
29 throw new LdapFailedBindException($msg);
34 * Attempt to start and bind to a new LDAP connection.
35 * Will attempt against multiple defined fail-over hosts if set in the provided config.
37 * Throws a LdapFailedBindException error if the bind connected but failed.
38 * Otherwise, generic LdapException errors would be thrown.
40 * @throws LdapException
42 public function startBind(?string $dn, ?string $password, array $config): LdapConnection
44 // Check LDAP extension in installed
45 if (!function_exists('ldap_connect') && config('app.env') !== 'testing') {
46 throw new LdapException(trans('errors.ldap_extension_not_installed'));
49 // Disable certificate verification.
50 // This option works globally and must be set before a connection is created.
51 if ($config['tls_insecure']) {
52 LdapConnection::setGlobalOption(LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
55 $serverDetails = $this->parseMultiServerString($config['server']);
56 $lastException = null;
58 foreach ($serverDetails as $server) {
60 $connection = $this->startServerConnection($server['host'], $server['port'], $config);
61 } catch (LdapException $exception) {
62 $lastException = $exception;
67 $bound = $connection->bind($dn, $password);
69 throw new LdapFailedBindException('Failed to perform LDAP bind');
71 } catch (ErrorException $exception) {
72 Log::error('LDAP bind error: ' . $exception->getMessage());
73 $lastException = new LdapException('Encountered error during LDAP bind');
77 $this->connectionCache[$server['host'] . ':' . $server['port']] = $connection;
85 * Attempt to start a server connection from the provided details.
86 * @throws LdapException
88 protected function startServerConnection(string $host, int $port, array $config): LdapConnection
90 if (isset($this->connectionCache[$host . ':' . $port])) {
91 return $this->connectionCache[$host . ':' . $port];
94 $ldapConnection = new LdapConnection($host, $port);
96 if (!$ldapConnection) {
97 throw new LdapException(trans('errors.ldap_cannot_connect'));
100 // Set any required options
101 if ($config['version']) {
102 $ldapConnection->setVersion($config['version']);
105 // Start and verify TLS if it's enabled
106 if ($config['start_tls']) {
108 $tlsStarted = $ldapConnection->startTls();
109 } catch (ErrorException $exception) {
114 throw new LdapException('Could not start TLS connection');
118 return $ldapConnection;
122 * Parse a potentially multi-value LDAP server host string and return an array of host/port detail pairs.
123 * Multiple hosts are separated with a semicolon, for example: 'ldap.example.com:8069;ldaps://ldap.example.com'
125 * @return array<array{host: string, port: int}>
127 protected function parseMultiServerString(string $serversString): array
129 $serverStringList = explode(';', $serversString);
131 return array_map(fn ($serverStr) => $this->parseSingleServerString($serverStr), $serverStringList);
135 * Parse an LDAP server string and return the host and port for a connection.
136 * Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
138 * @return array{host: string, port: int}
140 protected function parseSingleServerString(string $serverString): array
142 $serverNameParts = explode(':', $serverString);
144 // If we have a protocol just return the full string since PHP will ignore a separate port.
145 if ($serverNameParts[0] === 'ldaps' || $serverNameParts[0] === 'ldap') {
146 return ['host' => $serverString, 'port' => 389];
149 // Otherwise, extract the port out
150 $hostName = $serverNameParts[0];
151 $ldapPort = (count($serverNameParts) > 1) ? intval($serverNameParts[1]) : 389;
153 return ['host' => $hostName, 'port' => $ldapPort];