LDAP_BASE_DN=false
LDAP_DN=false
LDAP_PASS=false
-LDAP_USER_FILTER=false
+LDAP_USER_FILTER="(&(uid={user}))"
LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false
+ LDAP_TLS_CA_CERT=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
+OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
+# Export PDF Command
+# Set a command which can be used to convert a HTML file into a PDF file.
+# When false this will not be used.
+# String values represent the command to be called for conversion.
+# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
+# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
+EXPORT_PDF_COMMAND=false
+
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
+ // Configure any user-provided CA cert files for LDAP.
+ // This option works globally and must be set before a connection is created.
+ if ($this->config['tls_ca_cert']) {
+ $this->configureTlsCaCerts($this->config['tls_ca_cert']);
+ }
+
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
- $started = $this->ldap->startTls($ldapConnection);
+ try {
+ $started = $this->ldap->startTls($ldapConnection);
+ } catch (\Exception $exception) {
+ $error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
+ ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
+ Log::info("LDAP STARTTLS failure: {$error} {$detail}");
+ throw new LdapException('Could not start TLS connection. Further details in the application log.');
+ }
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
return $this->ldapConnection;
}
+ /**
+ * Configure TLS CA certs globally for ldap use.
+ * This will detect if the given path is a directory or file, and set the relevant
+ * LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
+ *
+ * Note: When using a folder, certificates are expected to be correctly named by hash
+ * which can be done via the c_rehash utility.
+ *
+ * @throws LdapException
+ */
+ protected function configureTlsCaCerts(string $caCertPath): void
+ {
+ $errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
+ $path = realpath($caCertPath);
+ if ($path === false) {
+ throw new LdapException($errMessage);
+ }
+
+ if (is_dir($path)) {
+ $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
+ } else if (is_file($path)) {
+ $this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
+ } else {
+ throw new LdapException($errMessage);
+ }
+ }
+
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
/**
* Build a filter string by injecting common variables.
+ * Both "${var}" and "{var}" style placeholders are supported.
+ * Dollar based are old format but supported for compatibility.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
- $newKey = '${' . $key . '}';
- $newAttrs[$newKey] = $this->ldap->escape($attrText);
+ $escapedText = $this->ldap->escape($attrText);
+ $oldVarKey = '${' . $key . '}';
+ $newVarKey = '{' . $key . '}';
+ $newAttrs[$oldVarKey] = $escapedText;
+ $newAttrs[$newVarKey] = $escapedText;
}
return strtr($filterString, $newAttrs);
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),
- 'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
+ 'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
+ 'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
'start_tls' => env('LDAP_START_TLS', false),
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
],
use BookStack\Access\Ldap;
use BookStack\Access\LdapService;
+ use BookStack\Exceptions\LdapException;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Illuminate\Testing\TestResponse;
'services.ldap.id_attribute' => 'uid',
'services.ldap.user_to_groups' => false,
'services.ldap.version' => '3',
- 'services.ldap.user_filter' => '(&(uid=${user}))',
+ 'services.ldap.user_filter' => '(&(uid={user}))',
'services.ldap.follow_referrals' => false,
'services.ldap.tls_insecure' => false,
+ 'services.ldap.tls_ca_cert' => false,
'services.ldap.thumbnail_attribute' => null,
]);
$this->mockLdap = $this->mock(Ldap::class);
$this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
}
+ public function test_user_filter_default_placeholder_format()
+ {
+ config()->set('services.ldap.user_filter', '(&(uid={user}))');
+ $this->mockUser->name = 'barryldapuser';
+ $expectedFilter = '(&(uid=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
+
+ $this->commonLdapMocks(1, 1, 1, 1, 1);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')
+ ->once()
+ ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
+ ->andReturn(['count' => 0, 0 => []]);
+
+ $resp = $this->mockUserLogin();
+ $resp->assertRedirect('/login');
+ }
+
+ public function test_user_filter_old_placeholder_format()
+ {
+ config()->set('services.ldap.user_filter', '(&(username=${user}))');
+ $this->mockUser->name = 'barryldapuser';
+ $expectedFilter = '(&(username=\62\61\72\72\79\6c\64\61\70\75\73\65\72))';
+
+ $this->commonLdapMocks(1, 1, 1, 1, 1);
+ $this->mockLdap->shouldReceive('searchAndGetEntries')
+ ->once()
+ ->with($this->resourceId, config('services.ldap.base_dn'), $expectedFilter, \Mockery::type('array'))
+ ->andReturn(['count' => 0, 0 => []]);
+
+ $resp = $this->mockUserLogin();
+ $resp->assertRedirect('/login');
+ }
+
public function test_initial_incorrect_credentials()
{
$this->commonLdapMocks(1, 1, 1, 0, 1);
$this->assertNotNull($user->avatar);
$this->assertEquals('8c90748342f19b195b9c6b4eff742ded', md5_file(public_path($user->avatar->path)));
}
+
+ public function test_tls_ca_cert_option_throws_if_set_to_invalid_location()
+ {
+ $path = 'non_found_' . time();
+ config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+ $this->commonLdapMocks(0, 0, 0, 0, 0);
+
+ $this->assertThrows(function () {
+ $this->withoutExceptionHandling()->mockUserLogin();
+ }, LdapException::class, "Provided path [{$path}] for LDAP TLS CA certs could not be resolved to an existing location");
+ }
+
+ public function test_tls_ca_cert_option_used_if_set_to_a_folder()
+ {
+ $path = $this->files->testFilePath('');
+ config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+ $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTDIR, rtrim($path, '/'))->andReturn(true);
+ $this->runFailedAuthLogin();
+ }
+
+ public function test_tls_ca_cert_option_used_if_set_to_a_file()
+ {
+ $path = $this->files->testFilePath('test-file.txt');
+ config()->set(['services.ldap.tls_ca_cert' => $path]);
+
+ $this->mockLdap->shouldReceive('setOption')->once()->with(null, LDAP_OPT_X_TLS_CACERTFILE, $path)->andReturn(true);
+ $this->runFailedAuthLogin();
+ }
}