GeoNetAgent, LDAPWeb, server-audit, server-connection
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

349 lines
11 KiB

<?php
namespace App\Services;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
class LdapUserService
{
private const USER_FILTER = '(&(objectCategory=person)(objectClass=user))';
private const ATTRIBUTES = [
'samaccountname',
'displayname',
'mail',
'useraccountcontrol',
'lastlogontimestamp',
'memberof',
'distinguishedname',
];
private const UF_ACCOUNTDISABLE = 2;
public function __construct(
private readonly LdapConnection $ldap
) {
}
public function listUsers(string $sort, string $dir, string $search = '', string $groupFilter = ''): array
{
$connection = null;
try {
$connection = $this->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,
];
}
}