ldap->bind(); $result = @ldap_search( $connection, config('ldap.base_dn'), self::USER_FILTER, self::ATTRIBUTES ); if ($result === false) { return $this->errorResult('LDAP search failed.'); } $entries = ldap_get_entries($connection, $result); $rows = []; for ($i = 0; $i < ($entries['count'] ?? 0); $i++) { $row = $this->mapUserEntry($entries[$i]); if ($row !== null) { $rows[] = $row; } } $allGroups = $this->collectGroupNames($rows); $rows = $this->filterAndSort($rows, $sort, $dir, $search, $groupFilter); return [ 'rows' => $rows, 'meta' => array_merge($this->meta(), [ 'total' => count($rows), 'all_groups' => $allGroups, ]), 'error' => null, ]; } catch (Throwable $e) { return $this->errorResult($e->getMessage() === 'LDAP bind failed.' ? 'LDAP bind failed. Check bind credentials.' : 'Unable to load LDAP users. Please check LDAP connectivity.'); } finally { if ($connection !== null) { @ldap_unbind($connection); } } } public function getUser(string $username): array { $connection = null; try { $username = $this->ldap->validateUsername($username); $connection = $this->ldap->bind(); $filter = '(sAMAccountName=' . ldap_escape($username, '', LDAP_ESCAPE_FILTER) . ')'; $result = @ldap_search($connection, config('ldap.base_dn'), $filter, self::ATTRIBUTES); if ($result === false) { throw new InvalidArgumentException('LDAP search failed.'); } $entries = ldap_get_entries($connection, $result); if (($entries['count'] ?? 0) < 1) { throw new InvalidArgumentException('User not found.'); } $user = $this->mapUserEntry($entries[0]); if ($user === null) { throw new InvalidArgumentException('User not found.'); } $user['protected'] = $this->isProtectedUser($username); return $user; } finally { if ($connection !== null) { @ldap_unbind($connection); } } } public function updateProfile(string $username, string $displayName, ?string $email): void { $this->ldap->assertUserWritable($username); $connection = null; try { $connection = $this->ldap->bind(); $dn = $this->ldap->findUserDn($connection, $username); if (! @ldap_mod_replace($connection, $dn, ['displayName' => $displayName])) { throw new RuntimeException('Failed to update display name.'); } if ($email !== null && $email !== '') { if (! @ldap_mod_replace($connection, $dn, ['mail' => $email])) { throw new RuntimeException('Failed to update email.'); } } else { @ldap_mod_del($connection, $dn, ['mail' => []]); } } finally { if ($connection !== null) { @ldap_unbind($connection); } } } public function setEnabled(string $username, bool $enabled): void { $this->ldap->assertUserWritable($username); $connection = null; try { $connection = $this->ldap->bind(); $dn = $this->ldap->findUserDn($connection, $username); $result = @ldap_read($connection, $dn, '(objectClass=user)', ['useraccountcontrol']); if ($result === false) { throw new RuntimeException('Failed to read user status.'); } $entries = ldap_get_entries($connection, $result); $uac = (int) ($this->ldap->attributeValue($entries[0] ?? [], 'useraccountcontrol') ?? 0); if ($enabled) { $uac &= ~self::UF_ACCOUNTDISABLE; } else { $uac |= self::UF_ACCOUNTDISABLE; } if (! @ldap_mod_replace($connection, $dn, ['userAccountControl' => (string) $uac])) { throw new RuntimeException('Failed to update account status.'); } } finally { if ($connection !== null) { @ldap_unbind($connection); } } } public function resetPassword(string $username, string $password): void { $this->ldap->assertUserWritable($username); $connection = null; try { $connection = $this->ldap->bind(ssl: true); $dn = $this->ldap->findUserDn($connection, $username); $encoded = iconv('UTF-8', 'UTF-16LE', '"' . $password . '"'); if ($encoded === false) { throw new InvalidArgumentException('Invalid password encoding.'); } if (! @ldap_mod_replace($connection, $dn, ['unicodePwd' => $encoded])) { throw new RuntimeException('Password reset failed. Ensure LDAPS (port 636) is available and password meets AD policy.'); } } finally { if ($connection !== null) { @ldap_unbind($connection); } } } public function isProtectedUser(string $username): bool { try { $this->ldap->assertUserWritable($username); return false; } catch (InvalidArgumentException) { return true; } } private function mapUserEntry(array $entry): ?array { $username = $this->ldap->attributeValue($entry, 'samaccountname'); if ($username === null) { return null; } $uac = (int) ($this->ldap->attributeValue($entry, 'useraccountcontrol') ?? 0); $groups = $this->groupsList($entry); return [ 'username' => $username, 'display_name' => $this->ldap->attributeValue($entry, 'displayname') ?? '—', 'email' => $this->ldap->attributeValue($entry, 'mail') ?? '—', 'enabled' => ! ($uac & self::UF_ACCOUNTDISABLE), 'last_logon' => $this->filetimeToDate($this->ldap->attributeValue($entry, 'lastlogontimestamp')), 'groups' => $groups, 'groups_count' => count($groups), 'protected' => $this->isProtectedUser($username), ]; } private function groupsList(array $entry): array { if (! isset($entry['memberof']) || ! is_array($entry['memberof'])) { return []; } $groups = []; for ($i = 0; $i < ($entry['memberof']['count'] ?? 0); $i++) { $dn = $entry['memberof'][$i] ?? ''; if (preg_match('/^CN=([^,]+)/i', $dn, $matches)) { $groups[] = $matches[1]; } } sort($groups); return $groups; } private function collectGroupNames(array $rows): array { $groups = []; foreach ($rows as $row) { foreach ($row['groups'] as $group) { $groups[$group] = true; } } $names = array_keys($groups); sort($names, SORT_NATURAL | SORT_FLAG_CASE); return $names; } private function filterAndSort(array $rows, string $sort, string $dir, string $search, string $groupFilter = ''): array { if ($groupFilter !== '') { $rows = array_values(array_filter( $rows, fn (array $row) => in_array($groupFilter, $row['groups'], true) )); } if ($search !== '') { $needle = mb_strtolower($search); $rows = array_values(array_filter($rows, function (array $row) use ($needle) { if (str_contains(mb_strtolower($row['username']), $needle) || str_contains(mb_strtolower($row['display_name']), $needle) || str_contains(mb_strtolower($row['email']), $needle)) { return true; } foreach ($row['groups'] as $group) { if (str_contains(mb_strtolower($group), $needle)) { return true; } } return false; })); } usort($rows, function (array $a, array $b) use ($sort, $dir) { if ($sort === 'enabled') { $cmp = ($a['enabled'] ? 1 : 0) <=> ($b['enabled'] ? 1 : 0); } elseif ($sort === 'groups_count') { $cmp = $a['groups_count'] <=> $b['groups_count']; } elseif ($sort === 'groups') { $cmp = implode(', ', $a['groups']) <=> implode(', ', $b['groups']); } else { $cmp = ($a[$sort] ?? '') <=> ($b[$sort] ?? ''); } return $dir === 'desc' ? -$cmp : $cmp; }); return $rows; } private function filetimeToDate(?string $filetime): ?string { if ($filetime === null || $filetime === '' || $filetime === '0') { return null; } $unix = ((int) $filetime / 10000000) - 11644473600; if ($unix <= 0) { return null; } return gmdate('Y-m-d H:i:s', $unix); } private function meta(): array { return [ 'total' => 0, 'server' => config('ldap.host'), 'domain' => $this->ldap->domainFromBaseDn(), ]; } private function errorResult(string $message): array { return [ 'rows' => [], 'meta' => array_merge($this->meta(), ['total' => 0, 'all_groups' => []]), 'error' => $message, ]; } }