Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions docs/source/LDAP-Authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ options with detailed comments explaining each setting. You can also view and
modify these settings through the web admin interface at **Application
Management > Configuration**. Key settings include:

- **host**: LDAP server URL(s)
- **uri**: LDAP URI string. For multiple servers, use a space-separated list of URIs.
- **binddn/bindpw**: Service account credentials for directory searches
- **basedn**: Base DN where users are located
- **user.id.attribute**: LDAP attribute for username lookup (typically ``uid``)
Expand All @@ -62,14 +62,46 @@ Management > Configuration**. Key settings include:

.. important::

The ``host`` value must include the LDAP scheme, for example
The ``uri`` value must include the LDAP scheme, for example
``ldap://ldap.example.com`` or ``ldaps://ldap.example.com``.
A bare hostname such as ``ldap.example.com`` is not enough.

URI examples:

.. code-block:: php

// single LDAP server (unencrypted LDAP, explicit port)
'uri' => 'ldap://ldap1.example.com:389',

// single LDAP server (unencrypted LDAP, default port 389)
'uri' => 'ldap://ldap1.example.com',

// single LDAP server over LDAPS (TLS, explicit port)
'uri' => 'ldaps://ldap1.example.com:636',

// single LDAP server over LDAPS (TLS, default port 636)
'uri' => 'ldaps://ldap1.example.com',

// multiple LDAP servers (space-separated URIs in one string)
'uri' => 'ldap://ldap1.example.com:389 ldap://ldap2.example.com:389',

// multiple LDAPS servers
'uri' => 'ldaps://ldap1.example.com:636 ldaps://ldap2.example.com:636',

Port defaults:

- ``ldap://`` uses port ``389`` by default when no port is specified.
- ``ldaps://`` uses port ``636`` by default when no port is specified.

Breaking change:

- ``host`` and ``port`` are no longer supported.
- Configure LDAP endpoints only through ``uri``.

Alternatively, configure the plugin through the web admin interface at
**Application Configuration** (``/Web/admin/manage_configuration.php``) and
select **Authentification-Ldap**.
Refer to ``/plugins/Authentication/Ldap/Ldap.config.dist.php`` for complete
select **Authentication-Ldap**. Refer to
``/plugins/Authentication/Ldap/Ldap.config.dist.php`` for complete
documentation of all options.

Troubleshooting
Expand All @@ -89,7 +121,7 @@ Common Issues
~~~~~~~~~~~~~

**Connection failures**
- Verify server hostname and port accessibility
- Verify LDAP URI hostname and port accessibility
- Check firewall rules
- Test with ``telnet ldap.example.com 389``

Expand Down
15 changes: 10 additions & 5 deletions plugins/Authentication/Ldap/Ldap.config.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
return [
'settings' => [
'ldap' => [
// comma separated list of ldap servers such as ldap://mydomain1,ldap://localhost
'host' => 'ldap://localhost',

// default ldap port 389 or 636 for ssl.
'port' => 389,
// LDAP URI(s). For multiple servers, separate each URI with spaces.
// If no port is specified, defaults are: ldap:// = 389, ldaps:// = 636.
// examples:
// 'ldap://ldap1.example.com'
// 'ldap://ldap1.example.com:389'
// 'ldaps://ldap1.example.com'
// 'ldaps://ldap1.example.com:636'
// 'ldap://ldap1.example.com:389 ldap://ldap2.example.com:389'
// 'ldaps://ldap1.example.com:636 ldaps://ldap2.example.com:636'
'uri' => 'ldap://localhost:389',

// LDAP protocol version
'version' => 3,
Expand Down
8 changes: 7 additions & 1 deletion plugins/Authentication/Ldap/Ldap.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ public function Validate($username, $password)
$this->password = $password;

$username = $this->CleanUsername($username);
$connected = $this->ldap->Connect();

try {
$connected = $this->ldap->Connect();
} catch (RuntimeException $e) {
Log::Error('LDAP configuration error: %s', $e->getMessage());
throw $e;
Comment thread
JohnVillalovos marked this conversation as resolved.
}

if (!$connected) {
throw new Exception('Could not connect to LDAP server. Please check your LDAP configuration settings');
Expand Down
17 changes: 4 additions & 13 deletions plugins/Authentication/Ldap/LdapConfigKeys.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,12 @@ class LdapConfigKeys extends PluginConfigKeys
{
public const CONFIG_ID = 'ldap';

public const HOST = [
'key' => 'host',
public const URI = [
'key' => 'uri',
'type' => 'string',
'default' => '',
'label' => 'LDAP Host',
'description' => 'Hostname or IP address of LDAP server. Should start with ldap:// or ldaps://',
'section' => 'ldap'
];

public const PORT = [
'key' => 'port',
'type' => 'integer',
'default' => 389,
'label' => 'LDAP Port',
'description' => 'Port of LDAP server (usually 389 or 636 for SSL)',
'label' => 'LDAP server URI(s)',
'description' => 'LDAP server URI(s). Use ldap:// or ldaps://. For multiple servers, separate URIs with spaces.',
'section' => 'ldap'
];

Expand Down
44 changes: 34 additions & 10 deletions plugins/Authentication/Ldap/LdapOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
class LdapOptions
{
private $_options = [];
/**
* @var array<string, mixed>
*/
private $rawLdapSettings = [];

public function __construct()
{
Expand All @@ -13,22 +17,22 @@ public function __construct()
$configPath = dirname(__FILE__) . '/Ldap.config.dist.php';
}

require_once($configPath);

Configuration::Instance()->Register(
$configPath,
'',
LdapConfigKeys::CONFIG_ID,
false,
LdapConfigKeys::class
);

$allValues = Configuration::Instance()->File(LdapConfigKeys::CONFIG_ID)->GetValues();
$this->rawLdapSettings = $allValues['ldap'] ?? [];
}

public function Ldap2Config()
{
$hosts = $this->GetHosts();
$hosts = $this->GetUris();
$this->SetOption('host', $hosts);
$this->SetOption('port', $this->GetConfig(LdapConfigKeys::PORT, new IntConverter()));
$this->SetOption('starttls', $this->GetConfig(LdapConfigKeys::STARTTLS, new BooleanConverter()));
$this->SetOption('version', $this->GetConfig(LdapConfigKeys::VERSION, new IntConverter()));
$this->SetOption('binddn', $this->GetConfig(LdapConfigKeys::BINDDN));
Expand All @@ -47,7 +51,7 @@ public function RetryAgainstDatabase()

public function Controllers()
{
return $this->GetHosts();
return $this->GetUris();
}

private function SetOption($key, $value)
Expand All @@ -64,15 +68,35 @@ private function GetConfig($configDef, $converter = null)
return Configuration::Instance()->File(LdapConfigKeys::CONFIG_ID)->GetKey($configDef, $converter);
}

private function GetHosts()
private function GetUris()
{
$hosts = explode(',', $this->GetConfig(LdapConfigKeys::HOST));
$this->AssertLegacyHostPortNotConfigured();

$uriConfig = trim($this->GetConfig(LdapConfigKeys::URI));
if (empty($uriConfig)) {
throw new RuntimeException("LDAP setting 'uri' is required and must contain at least one ldap:// or ldaps:// URI.");
}

for ($i = 0; $i < count($hosts); $i++) {
$hosts[$i] = trim($hosts[$i]);
$uris = preg_split('/\s+/', $uriConfig) ?: [];
foreach ($uris as $uri) {
$scheme = parse_url($uri, PHP_URL_SCHEME);
$host = parse_url($uri, PHP_URL_HOST);

if (!in_array($scheme, ['ldap', 'ldaps'], true) || empty($host)) {
throw new RuntimeException(
sprintf("Invalid LDAP URI '%s'. Use ldap:// or ldaps:// with a hostname. For multiple servers, separate URIs with spaces.", $uri)
);
}
Comment thread
JohnVillalovos marked this conversation as resolved.
}

return $hosts;
return $uris;
}

private function AssertLegacyHostPortNotConfigured()
{
if (array_key_exists('host', $this->rawLdapSettings) || array_key_exists('port', $this->rawLdapSettings)) {
throw new RuntimeException("LDAP settings 'host' and 'port' have been removed. Use only the 'uri' setting.");
}
}

public function BaseDn()
Expand Down
73 changes: 64 additions & 9 deletions tests/Plugins/Authentication/Ldap/LdapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ public function testNotValidIfCannotFindUser()
$this->assertTrue($this->fakeLdap->_AuthenticateCalled);
}

public function testValidateRethrowsRuntimeExceptionFromConnect()
{
$exception = new RuntimeException("LDAP settings 'host' and 'port' have been removed. Use only the 'uri' setting.");
$this->fakeLdap->_ConnectException = $exception;

$auth = new Ldap($this->fakeAuth, $this->fakeLdap, $this->fakeLdapOptions);

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("LDAP settings 'host' and 'port' have been removed");

$auth->Validate($this->username, $this->password);
}

public function testDoesNotTryToLoadUserDetailsIfNotValid()
{
$this->fakeLdap->_ExpectedAuthenticate = false;
Expand Down Expand Up @@ -186,17 +199,15 @@ public function testDoesNotSyncIfUserWasNotFoundInLdap()

public function testConstructsOptionsCorrectly()
{
$hosts = 'localhost, localhost.2';
$port = '389';
$uris = 'ldap://localhost:389 ldap://localhost.2:389';
$binddn = 'cn=admin,ou=users,dc=example,dc=org';
$password = 'pw';
$base = 'dc=example,dc=org';
$starttls = 'false';
$version = '3';

$configFile = new FakeConfigFile();
$configFile->SetKey(LdapConfigKeys::HOST, $hosts);
$configFile->SetKey(LdapConfigKeys::PORT, $port);
$configFile->SetKey(LdapConfigKeys::URI, $uris);
$configFile->SetKey(LdapConfigKeys::BINDDN, $binddn);
$configFile->SetKey(LdapConfigKeys::BINDPW, $password);
$configFile->SetKey(LdapConfigKeys::BASEDN, $base);
Expand All @@ -209,8 +220,7 @@ public function testConstructsOptionsCorrectly()
$options = $ldapOptions->Ldap2Config();

$this->assertNotNull($this->fakeConfig->_RegisteredFiles[LdapConfigKeys::CONFIG_ID]);
$this->assertEquals('localhost', $options['host'][0], 'domain_controllers must be an array');
$this->assertEquals(intval($port), $options['port'], 'port should be int');
$this->assertEquals('ldap://localhost:389', $options['host'][0], 'controllers must be an array of URIs');
$this->assertEquals($binddn, $options['binddn']);
$this->assertEquals($password, $options['bindpw']);
$this->assertEquals($base, $options['basedn']);
Expand All @@ -220,15 +230,51 @@ public function testConstructsOptionsCorrectly()

public function testGetAllHosts()
{
$controllers = 'localhost, localhost.2';
$controllers = 'ldap://localhost:389 ldap://localhost.2:389';

$configFile = new FakeConfigFile();
$configFile->SetKey(LdapConfigKeys::HOST, $controllers);
$configFile->SetKey(LdapConfigKeys::URI, $controllers);
$this->fakeConfig->SetFile(LdapConfigKeys::CONFIG_ID, $configFile);

$options = new LdapOptions();

$this->assertEquals(['localhost', 'localhost.2'], $options->Controllers(), 'comma separated values should become array');
$this->assertEquals(['ldap://localhost:389', 'ldap://localhost.2:389'], $options->Controllers(), 'space separated uris should become array');
}

public function testThrowsIfLegacyHostIsConfigured()
{
$configFile = new FakeConfigFile();
$configFile->SetKey([
'key' => 'host',
'section' => 'ldap',
'type' => 'string',
], 'ldap://localhost');
$this->fakeConfig->SetFile(LdapConfigKeys::CONFIG_ID, $configFile);

$options = new LdapOptions();

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("LDAP settings 'host' and 'port' have been removed");

$options->Ldap2Config();
}

public function testThrowsIfLegacyPortIsConfigured()
{
$configFile = new FakeConfigFile();
$configFile->SetKey([
'key' => 'port',
'section' => 'ldap',
'type' => 'string',
], '389');
$this->fakeConfig->SetFile(LdapConfigKeys::CONFIG_ID, $configFile);

$options = new LdapOptions();

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("LDAP settings 'host' and 'port' have been removed");

$options->Ldap2Config();
}

public function testUserHandlesArraysAsAttribute()
Expand Down Expand Up @@ -345,6 +391,10 @@ public function testAuthRealLdap()

class FakeLdapOptions extends LdapOptions
{
public function __construct()
{
}

public $_RetryAgainstDatabase = false;

public function RetryAgainstDatabase()
Expand Down Expand Up @@ -391,9 +441,14 @@ public function __construct()

public $_ExpectedLdapUser;

public ?\RuntimeException $_ConnectException = null;

public function Connect()
{
$this->_ConnectCalled = true;
if ($this->_ConnectException !== null) {
throw $this->_ConnectException;
}
return $this->_ExpectedConnect;
}

Expand Down
5 changes: 5 additions & 0 deletions tests/fakes/FakeConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ public function SetKey($configDef, $value)
}
}

public function GetValues(): array
{
return $this->_values;
}

public function EnableSubscription()
{
}
Expand Down