ldaü
All checks were successful
Deploy / deploy-staging (push) Successful in 58s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-15 01:03:55 +02:00
parent cd5525ff2e
commit 344dbb9b79
9 changed files with 854 additions and 18 deletions

404
src/App/LdapProvisioner.php Normal file
View File

@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace App;
use RuntimeException;
final class LdapProvisioner
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
public function isEnabled(): bool
{
return (bool) ($this->config['ldap']['enabled'] ?? false);
}
public function canUseLdap(): bool
{
return function_exists('ldap_connect')
&& function_exists('ldap_bind')
&& function_exists('ldap_add');
}
/**
* @return array{success: bool, message: string}
*/
public function testServiceBind(): array
{
try {
$link = $this->connectAsService();
return [
'success' => true,
'message' => 'Service-Bind erfolgreich.',
];
} catch (\Throwable $exception) {
return [
'success' => false,
'message' => $exception->getMessage(),
];
}
}
/**
* @return array{success: bool, message: string, dn?: string}
*/
public function testUserCredentials(string $username, string $password): array
{
try {
if (!$this->canUseLdap()) {
throw new RuntimeException('Die PHP-LDAP-Erweiterung ist auf diesem Server nicht verfügbar.');
}
$link = $this->connect();
$userDn = $this->findUserDnByUid($link, $username);
if ($userDn === null) {
throw new RuntimeException('Benutzer wurde im LDAP nicht gefunden.');
}
if (!@ldap_bind($link, $userDn, $password)) {
throw new RuntimeException('LDAP-Login fehlgeschlagen: ' . $this->lastError($link));
}
return [
'success' => true,
'message' => 'LDAP-Login erfolgreich.',
'dn' => $userDn,
];
} catch (\Throwable $exception) {
return [
'success' => false,
'message' => $exception->getMessage(),
];
}
}
/**
* @return array{success: bool, message: string, dn?: string}
*/
public function updateUserPassword(string $username, string $newPassword): array
{
try {
$link = $this->connectAsService();
$userDn = $this->findUserDnByUid($link, $username);
if ($userDn === null) {
throw new RuntimeException('Benutzer wurde im LDAP nicht gefunden.');
}
$now = time();
$shadowLastChange = (string) floor($now / 86400);
$ntHash = strtoupper(hash('md4', iconv('UTF-8', 'UTF-16LE', $newPassword)));
$unixHash = $this->cryptSha512($newPassword);
$modifications = [
'userPassword' => $unixHash,
'sambaNTPassword' => $ntHash,
'sambaLMPassword' => 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
'sambaPwdLastSet' => (string) $now,
'shadowLastChange' => $shadowLastChange,
];
if (!$this->isInactiveShadowExpiry()) {
$modifications['shadowExpire'] = (string) ($this->config['ldap']['active_shadow_expire'] ?? -1);
}
if (!@ldap_modify($link, $userDn, $modifications)) {
throw new RuntimeException('Passwortänderung im LDAP fehlgeschlagen: ' . $this->lastError($link));
}
return [
'success' => true,
'message' => 'Passwort wurde im LDAP und für Samba aktualisiert.',
'dn' => $userDn,
];
} catch (\Throwable $exception) {
return [
'success' => false,
'message' => $exception->getMessage(),
];
}
}
/**
* @param array<string, mixed> $request
* @param array<string, mixed> $provisioning
* @return array{dn: string, group_updates: array<int, string>}
*/
public function createInactiveUser(array $request, array $provisioning, string $plainPassword): array
{
if (!$this->isEnabled()) {
throw new RuntimeException('LDAP-Provisionierung ist deaktiviert.');
}
if (!$this->canUseLdap()) {
throw new RuntimeException('Die PHP-LDAP-Erweiterung ist auf diesem Server nicht verfügbar.');
}
$userDn = 'uid=' . (string) ($request['username'] ?? '') . ',' . (string) ($this->config['ldap']['users_dn'] ?? '');
$link = $this->connectAsService();
$entry = $this->buildEntry($request, $provisioning, $plainPassword);
if (!@ldap_add($link, $userDn, $entry)) {
throw new RuntimeException('LDAP-Benutzer konnte nicht angelegt werden: ' . $this->lastError($link));
}
$groupUpdates = $this->assignGroups($link, (string) ($request['username'] ?? ''), (string) $userDn, $provisioning);
return [
'dn' => $userDn,
'group_updates' => $groupUpdates,
];
}
/**
* @param array<string, mixed> $request
* @param array<string, mixed> $provisioning
* @return array<string, mixed>
*/
private function buildEntry(array $request, array $provisioning, string $plainPassword): array
{
$username = (string) ($request['username'] ?? '');
$givenName = trim((string) ($request['given_name'] ?? ''));
$familyName = trim((string) ($request['family_name'] ?? ''));
$displayName = trim((string) ($provisioning['display_name'] ?? $request['display_name'] ?? ''));
$cn = trim((string) ($provisioning['cn'] ?? ($displayName !== '' ? $displayName : $username)));
$gecos = trim((string) ($provisioning['gecos'] ?? ($displayName !== '' ? $displayName : trim($givenName . ' ' . $familyName))));
$uidNumber = trim((string) ($provisioning['uid_number'] ?? ''));
$gidNumber = trim((string) ($provisioning['gid_number'] ?? ''));
$homeDirectory = trim((string) ($provisioning['home_directory'] ?? ''));
$loginShell = trim((string) ($provisioning['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')));
$mail = trim((string) ($request['email'] ?? ''));
$appleGeneratedUid = strtoupper(trim((string) ($provisioning['apple_generateduid'] ?? $this->generateUuidV4())));
$sambaSid = trim((string) ($provisioning['samba_sid'] ?? ''));
$ntHash = strtoupper(hash('md4', iconv('UTF-8', 'UTF-16LE', $plainPassword)));
$unixHash = $this->cryptSha512($plainPassword);
$now = time();
$shadowLastChange = (string) floor($now / 86400);
return [
'objectClass' => [
'apple-user',
'extensibleObject',
'inetOrgPerson',
'organizationalPerson',
'person',
'posixAccount',
'sambaIdmapEntry',
'sambaSamAccount',
'shadowAccount',
'top',
],
'cn' => $cn,
'gidNumber' => $gidNumber,
'homeDirectory' => $homeDirectory,
'sambaSID' => $sambaSid,
'sn' => $familyName !== '' ? $familyName : $username,
'uid' => $username,
'uidNumber' => $uidNumber,
'apple-generateduid' => $appleGeneratedUid,
'authAuthority' => ';basic;',
'displayName' => $displayName !== '' ? $displayName : $username,
'gecos' => $gecos,
'givenName' => $givenName,
'loginShell' => $loginShell,
'mail' => $mail,
'sambaAcctFlags' => '[U ]',
'sambaLMPassword' => 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
'sambaNTPassword' => $ntHash,
'sambaPasswordHistory' => '0000000000000000000000000000000000000000000000000000000000000000',
'sambaPwdLastSet' => (string) $now,
'shadowExpire' => (string) ($this->config['ldap']['shadow_expire'] ?? 1),
'shadowFlag' => (string) ($this->config['ldap']['shadow_flag'] ?? 0),
'shadowInactive' => (string) ($this->config['ldap']['shadow_inactive'] ?? 0),
'shadowLastChange' => $shadowLastChange,
'shadowMax' => (string) ($this->config['ldap']['shadow_max'] ?? 99999),
'shadowMin' => (string) ($this->config['ldap']['shadow_min'] ?? 0),
'shadowWarning' => (string) ($this->config['ldap']['shadow_warning'] ?? 7),
'userPassword' => $unixHash,
];
}
/**
* @param resource|\LDAP\Connection $link
* @param array<string, mixed> $provisioning
* @return array<int, string>
*/
private function assignGroups($link, string $username, string $userDn, array $provisioning): array
{
$list = trim((string) ($provisioning['member_of_list'] ?? ''));
$groupDns = $list !== ''
? $this->parseGroupDns($list)
: array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
$mode = (string) ($this->config['ldap']['group_assignment_mode'] ?? 'group_memberuid');
$updated = [];
if ($groupDns === [] || $mode === 'none') {
return [];
}
if ($mode === 'user_memberof') {
return [];
}
$attribute = (string) ($this->config['ldap']['group_member_attribute'] ?? 'memberUid');
$value = $attribute === 'member' ? $userDn : $username;
foreach ($groupDns as $groupDn) {
$result = @ldap_mod_add($link, $groupDn, [$attribute => [$value]]);
if ($result === false) {
$errorCode = @ldap_errno($link);
if ($errorCode === 20 || $errorCode === 68) {
$updated[] = $groupDn . ' (bereits vorhanden)';
continue;
}
throw new RuntimeException('LDAP-Gruppenaktualisierung fehlgeschlagen für ' . $groupDn . ': ' . $this->lastError($link));
}
$updated[] = $groupDn;
}
return $updated;
}
/**
* @return resource|\LDAP\Connection
*/
private function connect()
{
$host = trim((string) ($this->config['ldap']['host'] ?? ''));
$port = (int) ($this->config['ldap']['port'] ?? 389);
if ($host === '') {
throw new RuntimeException('LDAP_HOST ist nicht gesetzt.');
}
$link = ldap_connect($host, $port);
if ($link === false) {
throw new RuntimeException('LDAP-Verbindung konnte nicht aufgebaut werden.');
}
ldap_set_option($link, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($link, LDAP_OPT_REFERRALS, 0);
if ((bool) ($this->config['ldap']['use_starttls'] ?? false) && !@ldap_start_tls($link)) {
throw new RuntimeException('LDAP StartTLS konnte nicht aktiviert werden: ' . $this->lastError($link));
}
return $link;
}
/**
* @return resource|\LDAP\Connection
*/
private function connectAsService()
{
$bindDn = trim((string) ($this->config['ldap']['bind_dn'] ?? ''));
$bindPassword = (string) ($this->config['ldap']['bind_password'] ?? '');
if ($bindDn === '' || $bindPassword === '') {
throw new RuntimeException('LDAP-Verbindungsdaten sind unvollständig.');
}
$link = $this->connect();
if (!@ldap_bind($link, $bindDn, $bindPassword)) {
throw new RuntimeException('LDAP-Bind fehlgeschlagen: ' . $this->lastError($link));
}
return $link;
}
/**
* @param resource|\LDAP\Connection $link
*/
private function findUserDnByUid($link, string $username): ?string
{
$baseDn = (string) ($this->config['ldap']['users_dn'] ?? '');
if ($baseDn === '') {
throw new RuntimeException('LDAP users_dn ist nicht gesetzt.');
}
$filter = sprintf('(uid=%s)', $this->ldapEscape($username, '', LDAP_ESCAPE_FILTER));
$search = @ldap_search($link, $baseDn, $filter, ['dn']);
if ($search === false) {
throw new RuntimeException('LDAP-Suche fehlgeschlagen: ' . $this->lastError($link));
}
$entries = ldap_get_entries($link, $search);
if (!is_array($entries) || (int) ($entries['count'] ?? 0) < 1) {
return null;
}
return (string) ($entries[0]['dn'] ?? '');
}
private function isInactiveShadowExpiry(): bool
{
return (string) ($this->config['ldap']['shadow_expire'] ?? 1) !== (string) ($this->config['ldap']['active_shadow_expire'] ?? -1);
}
private function ldapEscape(string $value, string $ignore = '', int $flags = 0): string
{
if (function_exists('ldap_escape')) {
return ldap_escape($value, $ignore, $flags);
}
$search = ['\\', '*', '(', ')', "\x00"];
$replace = ['\\5c', '\\2a', '\\28', '\\29', '\\00'];
return str_replace($search, $replace, $value);
}
/**
* @return array<int, string>
*/
private function parseGroupDns(string $value): array
{
$lines = preg_split('/\r\n|\r|\n/', $value) ?: [];
return array_values(array_filter(array_map('trim', $lines)));
}
/**
* @param resource|\LDAP\Connection $link
*/
private function lastError($link): string
{
$error = @ldap_error($link);
return is_string($error) && $error !== '' ? $error : 'Unbekannter LDAP-Fehler';
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function cryptSha512(string $password): string
{
$salt = '$6$' . bin2hex(random_bytes(8)) . '$';
$hash = crypt($password, $salt);
return str_starts_with($hash, '$6$') ? '{CRYPT}' . $hash : $hash;
}
}

View File

@@ -26,7 +26,7 @@ final class RegistrationLdifBuilder
$gidNumber = trim((string) ($provisioning['gid_number'] ?? ''));
$homeDirectory = trim((string) ($provisioning['home_directory'] ?? ''));
$loginShell = trim((string) ($provisioning['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')));
$memberOf = array_values(array_filter(array_map('trim', explode(',', (string) ($provisioning['member_of_csv'] ?? '')))));
$memberOf = $this->parseGroupDns((string) ($provisioning['member_of_list'] ?? ''));
$displayName = trim((string) ($provisioning['display_name'] ?? $request['display_name'] ?? ''));
$givenName = trim((string) ($request['given_name'] ?? ''));
$familyName = trim((string) ($request['family_name'] ?? ''));
@@ -137,6 +137,16 @@ final class RegistrationLdifBuilder
return implode(PHP_EOL, $lines) . PHP_EOL;
}
/**
* @return array<int, string>
*/
private function parseGroupDns(string $value): array
{
$lines = preg_split('/\r\n|\r|\n/', $value) ?: [];
return array_values(array_filter(array_map('trim', $lines)));
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App;
use RuntimeException;
final class RegistrationSecretBox
{
private const CIPHER = 'aes-256-gcm';
public function __construct(
private readonly string $keyMaterial,
) {
}
public function isConfigured(): bool
{
return trim($this->keyMaterial) !== '';
}
public function encrypt(string $plaintext): string
{
$key = $this->normalizedKey();
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt($plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($ciphertext === false) {
throw new RuntimeException('Das Passwort konnte nicht verschlüsselt werden.');
}
return base64_encode($iv . $tag . $ciphertext);
}
public function decrypt(string $encoded): string
{
$raw = base64_decode($encoded, true);
if ($raw === false || strlen($raw) < 28) {
throw new RuntimeException('Das gespeicherte Passwort ist beschädigt.');
}
$key = $this->normalizedKey();
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$ciphertext = substr($raw, 28);
$plaintext = openssl_decrypt($ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($plaintext === false) {
throw new RuntimeException('Das gespeicherte Passwort konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function normalizedKey(): string
{
if (!$this->isConfigured()) {
throw new RuntimeException('REGISTRATION_PASSWORD_KEY ist nicht gesetzt.');
}
$trimmed = trim($this->keyMaterial);
$decoded = base64_decode($trimmed, true);
if ($decoded !== false && strlen($decoded) >= 32) {
return substr($decoded, 0, 32);
}
return hash('sha256', $trimmed, true);
}
}

View File

@@ -8,6 +8,8 @@ final class RegistrationService
{
private RegistrationStore $store;
private RegistrationLdifBuilder $ldifBuilder;
private RegistrationSecretBox $secretBox;
private LdapProvisioner $ldapProvisioner;
/**
* @param array<string, mixed> $config
@@ -19,6 +21,8 @@ final class RegistrationService
$storagePath = $this->resolveProjectPath((string) ($this->config['storage_path'] ?? 'data/registration-requests.json'));
$this->store = new RegistrationStore($storagePath);
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
$this->secretBox = new RegistrationSecretBox((string) ($this->config['password_crypto_key'] ?? ''));
$this->ldapProvisioner = new LdapProvisioner($this->config);
}
public function isEnabled(): bool
@@ -53,6 +57,8 @@ final class RegistrationService
$familyName = trim((string) ($input['family_name'] ?? ''));
$email = strtolower(trim((string) ($input['email'] ?? '')));
$note = trim((string) ($input['note'] ?? ''));
$password = (string) ($input['password'] ?? '');
$passwordConfirm = (string) ($input['password_confirm'] ?? '');
$displayName = trim($givenName . ' ' . $familyName);
$errors = [];
@@ -72,6 +78,18 @@ final class RegistrationService
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse an.';
}
if (strlen($password) < 12) {
$errors[] = 'Das Passwort muss mindestens 12 Zeichen lang sein.';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Die Passwörter stimmen nicht überein.';
}
if (!$this->secretBox->isConfigured()) {
$errors[] = 'Die Registrierungsverschlüsselung ist noch nicht konfiguriert.';
}
if ($note !== '' && mb_strlen($note) > 2000) {
$errors[] = 'Die Zusatzinfo ist zu lang.';
}
@@ -100,6 +118,7 @@ final class RegistrationService
'display_name' => $displayName,
'email' => $email,
'note' => $note,
'password_secret' => $this->secretBox->encrypt($password),
'origin' => [
'ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
@@ -131,7 +150,7 @@ final class RegistrationService
$gidNumber = trim((string) ($input['gid_number'] ?? ''));
$homeDirectory = trim((string) ($input['home_directory'] ?? ''));
$sambaSid = trim((string) ($input['samba_sid'] ?? ''));
$memberOfCsv = trim((string) ($input['member_of_csv'] ?? ''));
$memberOfList = trim((string) ($input['member_of_list'] ?? ''));
$adminNote = trim((string) ($input['admin_note'] ?? ''));
$errors = [];
@@ -156,7 +175,7 @@ final class RegistrationService
'gid_number' => $gidNumber,
'home_directory' => $homeDirectory,
'login_shell' => trim((string) ($input['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh'))),
'member_of_csv' => $memberOfCsv,
'member_of_list' => $memberOfList,
'samba_sid' => $sambaSid,
'apple_generateduid' => trim((string) ($input['apple_generateduid'] ?? '')),
'display_name' => trim((string) ($input['display_name'] ?? (string) ($request['display_name'] ?? ''))),
@@ -190,16 +209,50 @@ final class RegistrationService
$ldif = $this->ldifBuilder->build($request, $provisioning);
$ldifPath = $this->writeLdifDraft((string) ($request['id'] ?? ''), (string) ($request['username'] ?? ''), $ldif);
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath): array {
$item['updated_at'] = gmdate('c');
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'ldif_generated';
try {
$passwordSecret = (string) ($request['password_secret'] ?? '');
return $item;
if ($passwordSecret === '') {
throw new \RuntimeException('Für diese Registrierung ist kein Passwort mehr hinterlegt.');
}
);
$plainPassword = $this->secretBox->decrypt($passwordSecret);
$ldapResult = $this->ldapProvisioner->createInactiveUser($request, $provisioning, $plainPassword);
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath, $ldapResult): array {
$item['status'] = 'ldap_created_inactive';
$item['updated_at'] = gmdate('c');
$item['password_secret'] = null;
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'ldap_created_inactive';
$item['provisioning']['ldap_dn'] = $ldapResult['dn'];
$item['provisioning']['group_updates'] = $ldapResult['group_updates'];
return $item;
}
);
} catch (\Throwable $exception) {
$request = $this->store->update(
$id,
static function (array $item) use ($ldifPath, $exception): array {
$item['status'] = 'provisioning_failed';
$item['updated_at'] = gmdate('c');
$item['provisioning']['ldif_path'] = $ldifPath;
$item['provisioning']['status'] = 'provisioning_failed';
$item['provisioning']['error'] = $exception->getMessage();
return $item;
}
);
return [
'success' => false,
'errors' => ['LDAP-Provisionierung fehlgeschlagen: ' . $exception->getMessage()],
'request' => $request,
];
}
return [
'success' => true,