diff --git a/roles/web-app-joomla/files/ldapautocreate.php b/roles/web-app-joomla/files/ldapautocreate.php index 9fafe0dc..2049d68d 100644 --- a/roles/web-app-joomla/files/ldapautocreate.php +++ b/roles/web-app-joomla/files/ldapautocreate.php @@ -1,8 +1,7 @@ logEnabled = (bool) filter_var(getenv('JOOMLA_LDAP_AUTOCREATE_LOG') ?: '0', FILTER_VALIDATE_BOOL); + + if ($this->logEnabled) { + // Register a dedicated channel and file + Log::addLogger( + ['text_file' => 'ldapauth.log', 'extension' => 'plg_system_ldapautocreate'], + Log::ALL, + ['ldapautocreate'] + ); + $this->log('logger-initialized', ['version' => '1.0.0']); + } + } + + private function log(string $event, array $ctx = []): void + { + if (!$this->logEnabled) { + return; + } + $payload = json_encode(['event' => $event, 'ctx' => $ctx], JSON_UNESCAPED_SLASHES); + Log::add($payload, Log::INFO, 'ldapautocreate'); + } /** - * Runs after authentication handlers; fires for both frontend and backend. - * @param array $options Contains 'username' and more after auth - * @return void + * Fires after authentication handlers; frontend and backend. + * @param array $options + * @param object $response ->status, ->type, ->error_message, ->username, etc. */ public function onUserAfterAuthenticate($options, $response) { - // Only proceed on success - if (($response->status ?? null) !== Authentication::STATUS_SUCCESS) { + // Defensive: normalize shape + $status = $response->status ?? null; + $type = $response->type ?? '(unknown)'; + $user = $response->username ?? ($options['username'] ?? null); + + $this->log('after-auth-enter', [ + 'username' => $user, + 'status' => $status, + 'type' => $type, + 'error' => $response->error_message ?? null, + ]); + + // Only proceed when LDAP (or any plugin) actually succeeded + if ($status !== Authentication::STATUS_SUCCESS) { + $this->log('skip-non-success', ['reason' => 'status!=' . Authentication::STATUS_SUCCESS]); return; } - $username = $response->username ?? $options['username'] ?? null; - if (!$username) { + if (!$user) { + $this->log('skip-missing-username'); return; } - /** @var DatabaseDriver $dbo */ + // If user exists locally, nothing to do $dbo = Factory::getDbo(); - - // If user already exists locally, nothing to do - $exists = (int) $dbo->setQuery( + $count = (int) $dbo->setQuery( $dbo->getQuery(true) ->select('COUNT(*)') ->from($dbo->quoteName('#__users')) - ->where($dbo->quoteName('username') . ' = ' . $dbo->quote($username)) + ->where($dbo->quoteName('username') . ' = ' . $dbo->quote($user)) )->loadResult(); - if ($exists) { + if ($count > 0) { + $this->log('user-exists', ['username' => $user]); return; } - // Read LDAP Auth plugin params to connect (the ones we configured via cli-ldap.php) + // Read LDAP plugin params (host/port/base_dn/attrs) and fetch cn/mail $ldapExt = $dbo->setQuery( $dbo->getQuery(true) ->select('*') ->from($dbo->quoteName('#__extensions')) - ->where($dbo->quoteName('type') . " = 'plugin'") - ->where($dbo->quoteName('folder') . " = 'authentication'") - ->where($dbo->quoteName('element') . " = 'ldap'") + ->where("type='plugin' AND folder='authentication' AND element='ldap'") )->loadObject(); if (!$ldapExt) { - return; // LDAP plugin not found; bail out silently + $this->log('ldap-plugin-missing'); + return; } $p = json_decode($ldapExt->params ?: "{}", true) ?: []; - $host = $p['host'] ?? 'openldap'; - $port = (int) ($p['port'] ?? 389); - $baseDn = $p['base_dn'] ?? ''; - $bindDn = $p['username'] ?? ''; - $bindPw = $p['password'] ?? ''; - $attrUid = $p['ldap_uid'] ?? 'uid'; + $host = $p['host'] ?? 'openldap'; + $port = (int) ($p['port'] ?? 389); + $baseDn = $p['base_dn'] ?? ''; + $bindDn = $p['username'] ?? ''; + $bindPw = $p['password'] ?? ''; + $attrUid = $p['ldap_uid'] ?? 'uid'; $attrMail = $p['ldap_email'] ?? 'mail'; $attrName = $p['ldap_fullname'] ?? 'cn'; - // Look up user in LDAP to fetch name/email + $this->log('ldap-params', [ + 'host' => $host, 'port' => $port, 'base_dn' => $baseDn, + 'attrUid' => $attrUid, 'attrMail' => $attrMail, 'attrName' => $attrName, + ]); + $ds = @ldap_connect($host, $port); - if (!$ds) { return; } + if (!$ds) { $this->log('ldap-connect-failed'); return; } ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3); @ldap_bind($ds, $bindDn, $bindPw); - $filter = sprintf('(%s=%s)', $attrUid, ldap_escape($username, '', LDAP_ESCAPE_FILTER)); + $filter = sprintf('(%s=%s)', $attrUid, ldap_escape($user, '', LDAP_ESCAPE_FILTER)); $sr = @ldap_search($ds, $baseDn, $filter, [$attrName, $attrMail]); $entry = $sr ? @ldap_first_entry($ds, $sr) : null; - $name = $entry ? (@ldap_get_values($ds, $entry, $attrName)[0] ?? $username) : $username; - $email = $entry ? (@ldap_get_values($ds, $entry, $attrMail)[0] ?? ($username.'@example.invalid')) : ($username.'@example.invalid'); + $name = $entry ? (@ldap_get_values($ds, $entry, $attrName)[0] ?? $user) : $user; + $email = $entry ? (@ldap_get_values($ds, $entry, $attrMail)[0] ?? ($user.'@example.invalid')) : ($user.'@example.invalid'); if ($ds) { @ldap_unbind($ds); } - // Create Joomla user (Registered group id=2) + $this->log('creating-user', ['username' => $user, 'name' => $name, 'email' => $email]); + + // Create Joomla user in Registered (id=2) $data = [ 'name' => $name, - 'username' => $username, + 'username' => $user, 'email' => $email, - // Password is irrelevant for LDAP; set a random one 'password' => bin2hex(random_bytes(12)), 'block' => 0, 'groups' => [2], ]; - $user = new User; - if (!$user->bind($data)) { + $joomUser = new User; + if (!$joomUser->bind($data)) { + $this->log('user-bind-failed', ['error' => 'bind() returned false']); return; } - $user->save(); + + if (!$joomUser->save()) { + $this->log('user-save-failed', ['error' => 'save() returned false']); + return; + } + + $this->log('user-created', ['id' => $joomUser->id, 'username' => $user]); } } diff --git a/roles/web-app-joomla/tasks/01_ldap_files.yml b/roles/web-app-joomla/tasks/01_ldap_files.yml index 065b4e8f..9a3b6845 100644 --- a/roles/web-app-joomla/tasks/01_ldap_files.yml +++ b/roles/web-app-joomla/tasks/01_ldap_files.yml @@ -1,6 +1,6 @@ - name: "Render LDAP CLI helper" template: - src: cli-ldap.php.j2 + src: ldap/cli.php.j2 dest: "{{ JOOMLA_LDAP_CONF_FILE }}" mode: "0644" when: JOOMLA_LDAP_ENABLED | bool @@ -23,3 +23,24 @@ - php - xml when: JOOMLA_LDAP_AUTO_CREATE_ENABLED | bool + +- name: "Deploy LDAP diagnose CLI" + template: + src: ldap/diagnose.php.j2 + dest: "{{ docker_compose.directories.volumes }}/cli-ldap-diagnose.php" + mode: "0644" + when: MODE_DEBUG | bool + +- name: "Deploy Joomla plugin inspector CLI (list state)" + template: + src: ldap/plugins.php.j2 + dest: "{{ docker_compose.directories.volumes }}/cli-plugins.php" + mode: "0644" + when: MODE_DEBUG | bool + +- name: "Deploy Joomla auth trace CLI" + template: + src: ldap/auth-trace.php.j2 + dest: "{{ docker_compose.directories.volumes }}/cli-ldap-auth-trace.php" + mode: "0644" + when: MODE_DEBUG | bool diff --git a/roles/web-app-joomla/tasks/05_ldap.yml b/roles/web-app-joomla/tasks/05_ldap.yml index 72812d39..22790f97 100644 --- a/roles/web-app-joomla/tasks/05_ldap.yml +++ b/roles/web-app-joomla/tasks/05_ldap.yml @@ -6,7 +6,6 @@ changed_when: "'configured' in ldap_conf.stdout | lower" async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - when: JOOMLA_LDAP_ENABLED | bool - name: "Register & enable ldapautocreate Joomla system plugin" command: > diff --git a/roles/web-app-joomla/tasks/06_assert.yml b/roles/web-app-joomla/tasks/06_assert.yml index bae42dd8..1a14605e 100644 --- a/roles/web-app-joomla/tasks/06_assert.yml +++ b/roles/web-app-joomla/tasks/06_assert.yml @@ -2,4 +2,3 @@ command: argv: [ docker, exec, "{{ JOOMLA_CONTAINER }}", php, "-l", "{{ JOOMLA_CONFIG_FILE }}" ] changed_when: false - when: MODE_ASSERT | bool diff --git a/roles/web-app-joomla/tasks/07_diagnose.yml b/roles/web-app-joomla/tasks/07_diagnose.yml new file mode 100644 index 00000000..c5597ec6 --- /dev/null +++ b/roles/web-app-joomla/tasks/07_diagnose.yml @@ -0,0 +1,14 @@ +- name: "Run LDAP diagnose" + command: + argv: + - docker + - exec + - "{{ JOOMLA_CONTAINER }}" + - php + - /var/www/html/cli/ldap-diagnose.php + - "--username={{ users.administrator.username }}" + register: ldap_diag + changed_when: false + +- debug: + var: ldap_diag.stdout_lines diff --git a/roles/web-app-joomla/tasks/main.yml b/roles/web-app-joomla/tasks/main.yml index b01a584a..8579d2a4 100644 --- a/roles/web-app-joomla/tasks/main.yml +++ b/roles/web-app-joomla/tasks/main.yml @@ -26,5 +26,15 @@ - 02_install.yml - 03_debug.yml - 04_patch.yml - - 05_ldap.yml - - 06_assert.yml + +- name: Include LDAP routines + include_tasks: "05_ldap.yml" + when: JOOMLA_LDAP_ENABLED | bool + +- name: Include assert routines + include_tasks: "06_assert.yml" + when: MODE_ASSERT | bool + +- name: Include LDAP diagnose routines + include_tasks: "07_diagnose.yml" + when: MODE_DEBUG | bool and JOOMLA_LDAP_ENABLED | bool \ No newline at end of file diff --git a/roles/web-app-joomla/templates/docker-compose.yml.j2 b/roles/web-app-joomla/templates/docker-compose.yml.j2 index 6e504162..33ac66af 100644 --- a/roles/web-app-joomla/templates/docker-compose.yml.j2 +++ b/roles/web-app-joomla/templates/docker-compose.yml.j2 @@ -14,6 +14,11 @@ {% if JOOMLA_LDAP_AUTO_CREATE_ENABLED | bool %} - {{ JOOMLA_LDAP_AUT_CRT_HOST_DIR }}:{{ JOOMLA_LDAP_AUT_CRT_DOCK_DIR }}:ro {% endif %} +{% if MODE_DEBUG | bool %} + - {{ JOOMLA_LDAP_DIAG_HOST_FILE }}:{{ JOOMLA_LDAP_DIAG_DOCK_FILE }}:ro + - {{ docker_compose.directories.volumes }}/cli-plugins.php:/var/www/html/cli/cli-plugins.php:ro + - {{ docker_compose.directories.volumes }}/cli-ldap-auth-trace.php:/var/www/html/cli/ldap-auth-trace.php:ro +{% endif %} {% endif %} ports: - "127.0.0.1:{{ ports.localhost.http[application_id] }}:{{ container_port }}" diff --git a/roles/web-app-joomla/templates/env.j2 b/roles/web-app-joomla/templates/env.j2 index 2a52ea46..53a51909 100644 --- a/roles/web-app-joomla/templates/env.j2 +++ b/roles/web-app-joomla/templates/env.j2 @@ -15,6 +15,7 @@ JOOMLA_DB_TYPE={{ JOOMLA_DB_CONNECTOR }} {% if JOOMLA_LDAP_ENABLED %} # LDAP +JOOMLA_LDAP_AUTOCREATE_LOG={{ MODE_DEBUG | ternary('1','') }} JOOMLA_LDAP_HOST={{ JOOMLA_LDAP_HOST }} JOOMLA_LDAP_PORT={{ JOOMLA_LDAP_PORT }} JOOMLA_LDAP_BASE_DN={{ JOOMLA_LDAP_BASE_DN }} diff --git a/roles/web-app-joomla/templates/ldap/auth-trace.php.j2 b/roles/web-app-joomla/templates/ldap/auth-trace.php.j2 new file mode 100644 index 00000000..70d19cfe --- /dev/null +++ b/roles/web-app-joomla/templates/ldap/auth-trace.php.j2 @@ -0,0 +1,242 @@ + --password '
' [--json] + * + * Exit codes: + * 0 = authentication SUCCESS + * 1 = authentication FAILED (see messages) + * 2 = usage error + * 3 = LDAP plugin row not found / misconfigured + * 4 = PHP ldap extension missing + * + * Notes: + * - This bypasses Joomla plugin boot issues in CLI and gives precise LDAP errors. + * - Honors LDAP plugin params + ENV overrides (same names as your role uses). + */ + +define('_JEXEC', 1); +define('JPATH_BASE', __DIR__ . '/..'); + +// Bootstrap minimal Joomla DB access (no need to boot Site/CMS app) +require JPATH_BASE . '/includes/defines.php'; +require JPATH_BASE . '/includes/framework.php'; + +ini_set('display_errors', '1'); +error_reporting(E_ALL); + +use Joomla\CMS\Factory; + +function getenv_clean(string $key, $default = null) { + $v = getenv($key); + if ($v === false || $v === '') return $default; + return preg_replace('/^(["\'])(.*)\1$/', '$2', $v); +} + +function outln($msg) { echo $msg . "\n"; } + +function result($ok, $label, $detail = null) { + $prefix = $ok ? '[OK] ' : '[ERR] '; + echo $prefix . $label . ($detail !== null ? " — $detail" : '') . "\n"; +} + +/** + * Attempt a full LDAP auth: + * - connect (host:port) + * - optional StartTLS (negotiate_tls) + * - service bind (admin) + * - lookup DN (search or users_dn template) + * - user bind with provided password + */ +function ldap_auth_flow(array $cfg, string $username, string $password, bool $asJson): int +{ + $report = [ + 'connect' => null, + 'starttls' => null, + 'serviceBind' => null, + 'userSearch' => null, + 'userBind' => null, + 'dn' => null, + 'messages' => [], + ]; + + if (!extension_loaded('ldap')) { + $msg = 'PHP LDAP extension not loaded'; + if ($asJson) echo json_encode(['error' => $msg], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + else result(false, $msg); + return 4; + } + + // Connect + $ds = @ldap_connect($cfg['host'], $cfg['port']); + if (!$ds) { + $report['connect'] = false; + $report['messages'][] = "ldap_connect failed to {$cfg['host']}:{$cfg['port']}"; + return emit($report, $asJson); + } + ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, 5); + $report['connect'] = true; + + // StartTLS if configured + if ($cfg['use_tls']) { + if (@ldap_start_tls($ds)) { + $report['starttls'] = true; + } else { + $report['starttls'] = false; + $report['messages'][] = 'StartTLS failed: ' . ldap_error($ds); + return emit($report, $asJson, $ds); + } + } else { + $report['starttls'] = null; // not requested + } + + // Service/admin bind (may be empty for anonymous) + $serviceBound = @ldap_bind($ds, $cfg['bind_dn'], $cfg['bind_pw']); + if (!$serviceBound) { + $report['serviceBind'] = false; + $report['messages'][] = 'Service bind failed: ' . ldap_error($ds) . sprintf(" (bind_dn=%s)", $cfg['bind_dn'] ?: '(anonymous)'); + return emit($report, $asJson, $ds); + } + $report['serviceBind'] = true; + + // Resolve user DN + $userDn = null; + + if (strtolower($cfg['auth_method']) === 'bind') { + // Direct template substitution (no search) + $userDn = str_replace(['[username]', '[USER]', '[uid]'], $username, $cfg['users_dn']); + $report['userSearch'] = 'skipped (bind mode)'; + } else { + // search mode: find DN first + $filter = sprintf('(%s=%s)', $cfg['uid_attr'], ldap_escape($username, '', LDAP_ESCAPE_FILTER)); + $sr = @ldap_search($ds, $cfg['base_dn'], $filter, [$cfg['name_attr'], $cfg['mail_attr']]); + if (!$sr) { + $report['userSearch'] = false; + $report['messages'][] = 'Search failed: ' . ldap_error($ds) . " (base_dn={$cfg['base_dn']}, filter={$filter})"; + return emit($report, $asJson, $ds); + } + $entries = @ldap_get_entries($ds, $sr); + $count = (int)($entries['count'] ?? 0); + if ($count < 1) { + $report['userSearch'] = false; + $report['messages'][] = "User not found under base_dn={$cfg['base_dn']} with {$cfg['uid_attr']}={$username}"; + return emit($report, $asJson, $ds); + } + $userDn = $entries[0]['dn'] ?? null; + $report['userSearch'] = true; + } + + if (!$userDn) { + $report['messages'][] = 'No user DN resolved.'; + return emit($report, $asJson, $ds); + } + $report['dn'] = $userDn; + + // Attempt user bind with provided password + $ok = @ldap_bind($ds, $userDn, $password); + if ($ok) { + $report['userBind'] = true; + return emit($report, $asJson, $ds, /*success*/true); + } else { + $report['userBind'] = false; + $report['messages'][] = 'User bind failed: ' . ldap_error($ds); + return emit($report, $asJson, $ds); + } +} + +function emit(array $report, bool $asJson, $ds = null, bool $success = false): int +{ + if ($asJson) { + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + } else { + if ($report['connect'] !== null) result((bool)$report['connect'], 'Connected'); + if ($report['starttls'] !== null) result((bool)$report['starttls'], 'StartTLS'); + if ($report['serviceBind'] !== null) result((bool)$report['serviceBind'], 'Service bind'); + if ($report['userSearch'] !== null && $report['userSearch'] !== 'skipped (bind mode)') { + result((bool)$report['userSearch'], 'User search'); + } elseif ($report['userSearch'] === 'skipped (bind mode)') { + outln('Info User search: skipped (bind mode)'); + } + if ($report['dn']) outln('Info User DN: ' . $report['dn']); + if ($report['userBind'] !== null) result((bool)$report['userBind'], 'User bind'); + + foreach ($report['messages'] as $m) { + outln('Note ' . $m); + } + + outln($success ? 'Overall: SUCCESS' : 'Overall: FAIL'); + } + + if ($ds) { @ldap_unbind($ds); } + return $success ? 0 : 1; +} + +// ------ Parse CLI args ------ +$args = getopt('', ['username:', 'password:', 'json']); +$username = $args['username'] ?? null; +$password = $args['password'] ?? null; +$asJson = array_key_exists('json', $args); + +if (!$username || $password === null) { + fwrite(STDERR, "Usage: --username --password '
' [--json]\n");
+ exit(2);
+}
+
+// ------ Load LDAP plugin params from DB ------
+$dbo = Factory::getDbo();
+$q = $dbo->getQuery(true)
+ ->select('*')
+ ->from($dbo->quoteName('#__extensions'))
+ ->where($dbo->quoteName('type') . ' = ' . $dbo->quote('plugin'))
+ ->where($dbo->quoteName('folder') . ' = ' . $dbo->quote('authentication'))
+ ->where($dbo->quoteName('element') . ' = ' . $dbo->quote('ldap'));
+$dbo->setQuery($q);
+$ext = $dbo->loadObject();
+
+if (!$ext) {
+ if ($asJson) echo json_encode(['error' => 'LDAP plugin row not found in #__extensions'], JSON_PRETTY_PRINT) . "\n";
+ else result(false, 'LDAP plugin not found in #__extensions (authentication/ldap)');
+ exit(3);
+}
+
+$params = json_decode($ext->params ?: "{}", true) ?: [];
+
+// Effective config with ENV overrides (matches your role variables)
+$cfg = [
+ 'host' => getenv_clean('JOOMLA_LDAP_HOST', $params['host'] ?? 'openldap'),
+ 'port' => (int) getenv_clean('JOOMLA_LDAP_PORT', (string)($params['port'] ?? 389)),
+ 'base_dn' => getenv_clean('JOOMLA_LDAP_BASE_DN', $params['base_dn'] ?? ''),
+ 'users_dn' => getenv_clean('JOOMLA_LDAP_USER_TREE_DN', $params['users_dn'] ?? ''),
+ 'bind_dn' => getenv_clean('JOOMLA_LDAP_BIND_DN', $params['username'] ?? ''),
+ 'bind_pw' => getenv_clean('JOOMLA_LDAP_BIND_PASSWORD', $params['password'] ?? ''),
+ 'use_tls' => filter_var(getenv_clean('JOOMLA_LDAP_USE_STARTTLS', $params['negotiate_tls'] ?? false), FILTER_VALIDATE_BOOL),
+ 'auth_method' => getenv_clean('JOOMLA_LDAP_AUTH_METHOD', $params['auth_method'] ?? 'search'),
+ 'search_string' => getenv_clean('JOOMLA_LDAP_USER_SEARCH_STRING', $params['search_string'] ?? 'uid=[username]'),
+ 'uid_attr' => getenv_clean('JOOMLA_LDAP_UID_ATTR', $params['ldap_uid'] ?? 'uid'),
+ 'mail_attr' => getenv_clean('JOOMLA_LDAP_EMAIL_ATTR', $params['ldap_email'] ?? 'mail'),
+ 'name_attr' => getenv_clean('JOOMLA_LDAP_NAME_ATTR', $params['ldap_fullname'] ?? 'cn'),
+];
+
+// Print effective config (non-JSON mode) with masked password
+if (!$asJson) {
+ outln('Effective LDAP configuration:');
+ outln(' host: ' . $cfg['host']);
+ outln(' port: ' . $cfg['port']);
+ outln(' base_dn: ' . $cfg['base_dn']);
+ outln(' users_dn: ' . $cfg['users_dn']);
+ outln(' bind_dn: ' . ($cfg['bind_dn'] ?: '(anonymous)'));
+ outln(' bind_pw: ' . ($cfg['bind_pw'] !== '' ? '***' : ''));
+ outln(' use_tls: ' . ($cfg['use_tls'] ? '1' : ''));
+ outln(' auth_method: ' . $cfg['auth_method']);
+ outln(' uid_attr: ' . $cfg['uid_attr']);
+}
+
+// Run the flow
+exit( ldap_auth_flow($cfg, $username, $password, $asJson) );
diff --git a/roles/web-app-joomla/templates/cli-ldap.php.j2 b/roles/web-app-joomla/templates/ldap/cli.php.j2
similarity index 100%
rename from roles/web-app-joomla/templates/cli-ldap.php.j2
rename to roles/web-app-joomla/templates/ldap/cli.php.j2
diff --git a/roles/web-app-joomla/templates/ldap/diagnose.php.j2 b/roles/web-app-joomla/templates/ldap/diagnose.php.j2
new file mode 100644
index 00000000..69ce6271
--- /dev/null
+++ b/roles/web-app-joomla/templates/ldap/diagnose.php.j2
@@ -0,0 +1,194 @@
+ [--verbose]
+ *
+ * Behavior:
+ * - Loads the "Authentication - LDAP" plugin parameters from #__extensions.
+ * - Allows ENV overrides for any LDAP setting (see list below).
+ * - Checks step-by-step: PHP-LDAP → connect → (optional) StartTLS → bind → search → read attributes.
+ * - Optionally previews the candidate DN for user-bind when auth_method=bind (no password tested).
+ *
+ * Supported ENV overrides (optional):
+ * JOOMLA_LDAP_HOST
+ * JOOMLA_LDAP_PORT
+ * JOOMLA_LDAP_BASE_DN
+ * JOOMLA_LDAP_USER_TREE_DN
+ * JOOMLA_LDAP_BIND_DN
+ * JOOMLA_LDAP_BIND_PASSWORD
+ * JOOMLA_LDAP_USE_STARTTLS (true/false)
+ * JOOMLA_LDAP_AUTH_METHOD ("search" or "bind")
+ * JOOMLA_LDAP_USER_SEARCH_STRING (e.g., "uid=[username]")
+ * JOOMLA_LDAP_UID_ATTR (e.g., "uid")
+ * JOOMLA_LDAP_EMAIL_ATTR (e.g., "mail")
+ * JOOMLA_LDAP_NAME_ATTR (e.g., "cn")
+ * TEST_USERNAME (fallback if --username is not passed)
+ */
+
+define('_JEXEC', 1);
+define('JPATH_BASE', __DIR__ . '/..');
+
+require JPATH_BASE . '/includes/defines.php';
+require JPATH_BASE . '/includes/framework.php';
+
+use Joomla\CMS\Factory;
+
+function println($msg, ?bool $ok = null, int $indent = 0): void {
+ $pad = str_repeat(' ', $indent);
+ if ($ok === true) {
+ echo $pad . "[OK] $msg\n";
+ } elseif ($ok === false) {
+ echo $pad . "[ERR] $msg\n";
+ } else {
+ echo $pad . "$msg\n";
+ }
+}
+
+function getenv_clean(string $key, $default = null) {
+ $v = getenv($key);
+ if ($v === false || $v === '') return $default;
+ // Strip surrounding single/double quotes if present
+ return preg_replace('/^(["\'])(.*)\1$/', '$2', $v);
+}
+
+function env_bool($key, $default = false): bool {
+ $v = getenv_clean($key, null);
+ if ($v === null) return (bool)$default;
+ return filter_var($v, FILTER_VALIDATE_BOOL);
+}
+
+// ---- CLI args
+$args = getopt('', ['username:', 'verbose']);
+$username = $args['username'] ?? getenv_clean('TEST_USERNAME', null);
+$verbose = array_key_exists('verbose', $args);
+
+// ---- Preflight: php-ldap
+if (!extension_loaded('ldap')) {
+ println('PHP LDAP extension not loaded (php-ldap missing in the image).', false);
+ exit(1);
+}
+println('PHP LDAP extension: loaded', true);
+
+// ---- Load LDAP plugin params from DB
+$dbo = Factory::getDbo();
+$q = $dbo->getQuery(true)
+ ->select('*')
+ ->from($dbo->quoteName('#__extensions'))
+ ->where($dbo->quoteName('type') . ' = ' . $dbo->quote('plugin'))
+ ->where($dbo->quoteName('folder') . ' = ' . $dbo->quote('authentication'))
+ ->where($dbo->quoteName('element') . ' = ' . $dbo->quote('ldap'));
+$dbo->setQuery($q);
+$ext = $dbo->loadObject();
+if (!$ext) {
+ println('LDAP plugin row not found in #__extensions (authentication/ldap).', false);
+ exit(2);
+}
+$params = json_decode($ext->params ?: "{}", true) ?: [];
+
+// ---- Effective config (DB params with ENV overrides)
+$cfg = [
+ 'host' => getenv_clean('JOOMLA_LDAP_HOST', $params['host'] ?? 'openldap'),
+ 'port' => (int) getenv_clean('JOOMLA_LDAP_PORT', (string)($params['port'] ?? 389)),
+ 'base_dn' => getenv_clean('JOOMLA_LDAP_BASE_DN', $params['base_dn'] ?? ''),
+ 'users_dn' => getenv_clean('JOOMLA_LDAP_USER_TREE_DN', $params['users_dn'] ?? ''),
+ 'bind_dn' => getenv_clean('JOOMLA_LDAP_BIND_DN', $params['username'] ?? ''),
+ 'bind_pw' => getenv_clean('JOOMLA_LDAP_BIND_PASSWORD', $params['password'] ?? ''),
+ 'use_tls' => env_bool('JOOMLA_LDAP_USE_STARTTLS', $params['negotiate_tls'] ?? false),
+ 'auth_method' => getenv_clean('JOOMLA_LDAP_AUTH_METHOD', $params['auth_method'] ?? 'search'),
+ 'search_string' => getenv_clean('JOOMLA_LDAP_USER_SEARCH_STRING', $params['search_string'] ?? 'uid=[username]'),
+ 'uid_attr' => getenv_clean('JOOMLA_LDAP_UID_ATTR', $params['ldap_uid'] ?? 'uid'),
+ 'mail_attr' => getenv_clean('JOOMLA_LDAP_EMAIL_ATTR', $params['ldap_email'] ?? 'mail'),
+ 'name_attr' => getenv_clean('JOOMLA_LDAP_NAME_ATTR', $params['ldap_fullname'] ?? 'cn'),
+];
+
+if (!$username) {
+ println('Missing username. Provide --username