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
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, |
|
]; |
|
} |
|
}
|
|
|