Skip to content

Commit c9fb14c

Browse files
committed
LDAP: Review, testing and update of LDAP TLS CA cert control
Review of #4913 Added testing to cover option. Updated option so it can be used for a CA directory, or a CA file. Updated option name to be somewhat abstracted from original underling PHP option. Tested against Jumpcloud. Testing took hours due to instability which was due to these settings sticking and being unstable on change until php process restart. Also due to little documentation for these options. X_TLS_CACERTDIR option needs cert files to be named via specific hashes which can be achieved via c_rehash utility. This also adds detail on STARTTLS failure, which took a long time to discover due to little detail out there for deeper PHP LDAP debugging.
1 parent 18269f2 commit c9fb14c

File tree

4 files changed

+74
-6
lines changed

4 files changed

+74
-6
lines changed

.env.example.complete

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ LDAP_USER_FILTER=false
219219
LDAP_VERSION=false
220220
LDAP_START_TLS=false
221221
LDAP_TLS_INSECURE=false
222-
LDAP_TLS_CACERTFILE=false
222+
LDAP_TLS_CA_CERT=false
223223
LDAP_ID_ATTRIBUTE=uid
224224
LDAP_EMAIL_ATTRIBUTE=mail
225225
LDAP_DISPLAY_NAME_ATTRIBUTE=cn

app/Access/LdapService.php

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,18 @@ protected function getConnection()
203203
throw new LdapException(trans('errors.ldap_extension_not_installed'));
204204
}
205205

206+
$this->ldap->setOption(null, LDAP_OPT_DEBUG_LEVEL, 7);
207+
206208
// Disable certificate verification.
207209
// This option works globally and must be set before a connection is created.
208210
if ($this->config['tls_insecure']) {
209211
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
210212
}
211213

212-
// Specify CA Cert file for LDAP.
214+
// Configure any user-provided CA cert files for LDAP.
213215
// This option works globally and must be set before a connection is created.
214-
if ($this->config['tls_cacertfile']) {
215-
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $this->config['tls_cacertfile']);
216+
if ($this->config['tls_ca_cert']) {
217+
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
216218
}
217219

218220
$ldapHost = $this->parseServerString($this->config['server']);
@@ -229,7 +231,14 @@ protected function getConnection()
229231

230232
// Start and verify TLS if it's enabled
231233
if ($this->config['start_tls']) {
232-
$started = $this->ldap->startTls($ldapConnection);
234+
try {
235+
$started = $this->ldap->startTls($ldapConnection);
236+
} catch (\Exception $exception) {
237+
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
238+
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
239+
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
240+
throw new LdapException('Could not start TLS connection. Further details in the application log.');
241+
}
233242
if (!$started) {
234243
throw new LdapException('Could not start TLS connection');
235244
}
@@ -240,6 +249,33 @@ protected function getConnection()
240249
return $this->ldapConnection;
241250
}
242251

252+
/**
253+
* Configure TLS CA certs globally for ldap use.
254+
* This will detect if the given path is a directory or file, and set the relevant
255+
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
256+
*
257+
* Note: When using a folder, certificates are expected to be correctly named by hash
258+
* which can be done via the c_rehash utility.
259+
*
260+
* @throws LdapException
261+
*/
262+
protected function configureTlsCaCerts(string $caCertPath): void
263+
{
264+
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
265+
$path = realpath($caCertPath);
266+
if ($path === false) {
267+
throw new LdapException($errMessage);
268+
}
269+
270+
if (is_dir($path)) {
271+
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
272+
} else if (is_file($path)) {
273+
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
274+
} else {
275+
throw new LdapException($errMessage);
276+
}
277+
}
278+
243279
/**
244280
* Parse an LDAP server string and return the host suitable for a connection.
245281
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.

app/Config/services.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
134134
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
135135
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
136-
'tls_cacertfile' => env('LDAP_TLS_CACERTFILE', false),
136+
'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
137137
'start_tls' => env('LDAP_START_TLS', false),
138138
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
139139
],

tests/Auth/LdapTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use BookStack\Access\Ldap;
66
use BookStack\Access\LdapService;
7+
use BookStack\Exceptions\LdapException;
78
use BookStack\Users\Models\Role;
89
use BookStack\Users\Models\User;
910
use Illuminate\Testing\TestResponse;
@@ -35,6 +36,7 @@ protected function setUp(): void
3536
'services.ldap.user_filter' => '(&(uid=${user}))',
3637
'services.ldap.follow_referrals' => false,
3738
'services.ldap.tls_insecure' => false,
39+
'services.ldap.tls_ca_cert' => false,
3840
'services.ldap.thumbnail_attribute' => null,
3941
]);
4042
$this->mockLdap = $this->mock(Ldap::class);
@@ -767,4 +769,34 @@ public function test_thumbnail_attribute_used_as_user_avatar_if_configured()
767769
$this->assertNotNull($user->avatar);
768770
$this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
769771
}
772+
773+
public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
774+
{
775+
$path = 'non_found_' . time();
776+
config()->set(['services.ldap.tls_ca_cert' => $path]);
777+
778+
$this->commonLdapMocks(0, 0, 0, 0, 0);
779+
780+
$this->assertThrows(function () {
781+
$this->withoutExceptionHandling()->mockUserLogin();
782+
}, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
783+
}
784+
785+
public function test_tls_ca_cert_option_used_if_set_to_a_folder()
786+
{
787+
$path = $this->files->testFilePath('');
788+
config()->set(['services.ldap.tls_ca_cert' => $path]);
789+
790+
$this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
791+
$this->runFailedAuthLogin();
792+
}
793+
794+
public function test_tls_ca_cert_option_used_if_set_to_a_file()
795+
{
796+
$path = $this->files->testFilePath('test-file.txt');
797+
config()->set(['services.ldap.tls_ca_cert' => $path]);
798+
799+
$this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
800+
$this->runFailedAuthLogin();
801+
}
770802
}

0 commit comments

Comments
 (0)