diff --git a/config/registration.php b/config/registration.php index 00fe2247..51c05628 100644 --- a/config/registration.php +++ b/config/registration.php @@ -8,14 +8,24 @@ return [ 'ldif_export_dir' => 'data/registration-ldif', 'approver_groups' => ['administrators', 'appadmin'], 'approver_usernames' => [], + 'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '', 'ldap' => [ + 'enabled' => true, + 'host' => getenv('LDAP_HOST') ?: '127.0.0.1', + 'port' => (int) (getenv('LDAP_PORT') ?: 389), + 'bind_dn' => getenv('LDAP_BIND_DN') ?: '', + 'bind_password' => getenv('LDAP_BIND_PASSWORD') ?: '', + 'use_starttls' => filter_var(getenv('LDAP_USE_STARTTLS') ?: 'false', FILTER_VALIDATE_BOOL), 'users_dn' => 'cn=users,dc=nas,dc=kusche,dc=berlin', 'default_groups' => [ 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin', ], + 'group_assignment_mode' => getenv('LDAP_GROUP_ASSIGNMENT_MODE') ?: 'group_memberuid', + 'group_member_attribute' => getenv('LDAP_GROUP_MEMBER_ATTRIBUTE') ?: 'memberUid', 'default_login_shell' => '/bin/sh', 'home_directory_prefix' => '/home', 'shadow_expire' => 1, + 'active_shadow_expire' => -1, 'shadow_flag' => 0, 'shadow_inactive' => 0, 'shadow_max' => 99999, diff --git a/public/admin/ldap-test/index.php b/public/admin/ldap-test/index.php new file mode 100644 index 00000000..9ac3f143 --- /dev/null +++ b/public/admin/ldap-test/index.php @@ -0,0 +1,205 @@ +canManage()) { + http_response_code(403); + echo 'Kein Zugriff auf LDAP-Test.'; + exit; +} + +$ldap = new LdapProvisioner($config); +$csrfToken = (string) ($_SESSION['ldap_test_token'] ?? ''); + +if ($csrfToken === '') { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['ldap_test_token'] = $csrfToken; +} + +$serviceResult = $ldap->testServiceBind(); +$messages = []; +$errors = []; +$loginForm = ['username' => '', 'password' => '']; +$passwordForm = ['username' => '', 'current_password' => '', 'new_password' => '', 'new_password_confirm' => '']; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $postedToken = (string) ($_POST['csrf_token'] ?? ''); + + if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) { + $errors[] = 'Die Formularprüfung ist fehlgeschlagen.'; + } else { + $action = (string) ($_POST['action'] ?? ''); + + if ($action === 'test-login') { + $loginForm['username'] = trim((string) ($_POST['username'] ?? '')); + $loginForm['password'] = (string) ($_POST['password'] ?? ''); + + if ($loginForm['username'] === '' || $loginForm['password'] === '') { + $errors[] = 'Für den Login-Test sind Benutzername und Passwort erforderlich.'; + } else { + $result = $ldap->testUserCredentials($loginForm['username'], $loginForm['password']); + + if ($result['success'] ?? false) { + $messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? ''); + } else { + $errors[] = (string) ($result['message'] ?? 'LDAP-Login fehlgeschlagen.'); + } + } + } + + if ($action === 'change-password') { + $passwordForm['username'] = trim((string) ($_POST['username'] ?? '')); + $passwordForm['current_password'] = (string) ($_POST['current_password'] ?? ''); + $passwordForm['new_password'] = (string) ($_POST['new_password'] ?? ''); + $passwordForm['new_password_confirm'] = (string) ($_POST['new_password_confirm'] ?? ''); + + if ($passwordForm['username'] === '') { + $errors[] = 'Für die Passwortänderung ist ein Benutzername erforderlich.'; + } + + if ($passwordForm['current_password'] === '') { + $errors[] = 'Bitte das aktuelle Passwort angeben, damit der Login vorab geprüft werden kann.'; + } + + if (strlen($passwordForm['new_password']) < 12) { + $errors[] = 'Das neue Passwort muss mindestens 12 Zeichen lang sein.'; + } + + if ($passwordForm['new_password'] !== $passwordForm['new_password_confirm']) { + $errors[] = 'Die neuen Passwörter stimmen nicht überein.'; + } + + if ($errors === []) { + $loginCheck = $ldap->testUserCredentials($passwordForm['username'], $passwordForm['current_password']); + + if (!(bool) ($loginCheck['success'] ?? false)) { + $errors[] = 'Der Vorab-Login mit aktuellem Passwort ist fehlgeschlagen: ' . (string) ($loginCheck['message'] ?? ''); + } else { + $result = $ldap->updateUserPassword($passwordForm['username'], $passwordForm['new_password']); + + if ($result['success'] ?? false) { + $messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? ''); + $passwordForm = ['username' => '', 'current_password' => '', 'new_password' => '', 'new_password_confirm' => '']; + } else { + $errors[] = (string) ($result['message'] ?? 'Passwortänderung fehlgeschlagen.'); + } + } + } + } + } +} +?> + + + + + LDAP-Test + + + +
+
+ + + + + + + + + + + + +
+
+

Login testen

+ +
+ +
+

Passwort ändern

+ +
+
+
+
+ + diff --git a/public/admin/registrations/index.php b/public/admin/registrations/index.php index 64bf5d8c..e1d71595 100644 --- a/public/admin/registrations/index.php +++ b/public/admin/registrations/index.php @@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (($result['success'] ?? false) !== true) { $errors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); } else { - $notice = 'Registrierung freigegeben. Ein LDIF-Entwurf für den inaktiven Benutzer wurde erzeugt.'; + $notice = 'Registrierung freigegeben und als inaktiver LDAP-Benutzer angelegt.'; } } elseif ($action === 'reject') { $result = $registration->reject($selectedId, (string) ($_POST['reject_reason'] ?? ''), $reviewer); @@ -79,7 +79,7 @@ $provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisi

Kusche.Berlin

Registrierungsfreigaben

-

Anfragen werden erst nach Prüfung für LDAP und NAS vorbereitet. Die Freigabe erzeugt aktuell einen LDIF-Entwurf für einen inaktiven Benutzer.

+

Anfragen werden erst nach Prüfung direkt ins LDAP geschrieben. Dabei werden auch Unix- und Samba-Felder gesetzt, der Benutzer bleibt aber zunächst inaktiv.

@@ -185,8 +185,8 @@ $provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisi @@ -196,7 +196,7 @@ $provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisi
- +
@@ -206,9 +206,26 @@ $provisioning = is_array($selected['provisioning'] ?? null) ? $selected['provisi + +
+ LDAP-Anlage +

DN:

+ +

Gruppen:

+ +
+ + + +
+ Provisionierungsfehler +

+
+ +
- LDIF-Entwurf + LDIF-Fallback

diff --git a/public/auth/register/index.php b/public/auth/register/index.php index 7990cf54..d909de34 100644 --- a/public/auth/register/index.php +++ b/public/auth/register/index.php @@ -34,6 +34,8 @@ $form = [ 'family_name' => '', 'email' => '', 'note' => '', + 'password' => '', + 'password_confirm' => '', ]; if ($_SERVER['REQUEST_METHOD'] === 'POST') { @@ -43,6 +45,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'family_name' => trim((string) ($_POST['family_name'] ?? '')), 'email' => trim((string) ($_POST['email'] ?? '')), 'note' => trim((string) ($_POST['note'] ?? '')), + 'password' => (string) ($_POST['password'] ?? ''), + 'password_confirm' => (string) ($_POST['password_confirm'] ?? ''), ]; $postedToken = (string) ($_POST['csrf_token'] ?? ''); @@ -65,6 +69,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'family_name' => '', 'email' => '', 'note' => '', + 'password' => '', + 'password_confirm' => '', ]; } } @@ -137,9 +143,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { +
+ + + +
+
Wichtig -

Das Passwort wird erst nach Freigabe gesetzt. Neue Registrierungen erhalten zunächst keinen aktiven Desktop-Zugang.

+

Das Passwort wird verschlüsselt zwischengespeichert und bei Freigabe direkt für LDAP und SMB gesetzt. Neue Registrierungen erhalten zunächst trotzdem keinen aktiven Desktop-Zugang.

diff --git a/src/App/LdapProvisioner.php b/src/App/LdapProvisioner.php new file mode 100644 index 00000000..1ca05ece --- /dev/null +++ b/src/App/LdapProvisioner.php @@ -0,0 +1,404 @@ + $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 $request + * @param array $provisioning + * @return array{dn: string, group_updates: array} + */ + 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 $request + * @param array $provisioning + * @return array + */ + 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 $provisioning + * @return array + */ + 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 + */ + 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; + } +} diff --git a/src/App/RegistrationLdifBuilder.php b/src/App/RegistrationLdifBuilder.php index 6e517405..e0dcd6de 100644 --- a/src/App/RegistrationLdifBuilder.php +++ b/src/App/RegistrationLdifBuilder.php @@ -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 + */ + 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); diff --git a/src/App/RegistrationSecretBox.php b/src/App/RegistrationSecretBox.php new file mode 100644 index 00000000..0a0b2aaf --- /dev/null +++ b/src/App/RegistrationSecretBox.php @@ -0,0 +1,73 @@ +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); + } +} diff --git a/src/App/RegistrationService.php b/src/App/RegistrationService.php index 09d1dbea..e2008233 100644 --- a/src/App/RegistrationService.php +++ b/src/App/RegistrationService.php @@ -8,6 +8,8 @@ final class RegistrationService { private RegistrationStore $store; private RegistrationLdifBuilder $ldifBuilder; + private RegistrationSecretBox $secretBox; + private LdapProvisioner $ldapProvisioner; /** * @param array $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, diff --git a/temp/LDAP_DIREKT_SETUP.md b/temp/LDAP_DIREKT_SETUP.md new file mode 100644 index 00000000..fd4a9b8d --- /dev/null +++ b/temp/LDAP_DIREKT_SETUP.md @@ -0,0 +1,46 @@ +# LDAP Direkt-Setup + +Damit die Freigabe direkt ins LDAP schreiben kann, muss auf dem Zielsystem Folgendes vorhanden sein: + +## PHP-Erweiterung + +- `php-ldap` + +Lokal in meiner aktuellen Umgebung ist diese Erweiterung **nicht** geladen. Deshalb ist der Verbindungsweg hier nicht live getestet, nur die Syntax. + +## Erforderliche Umgebungsvariablen + +- `REGISTRATION_PASSWORD_KEY` +- `LDAP_HOST` +- `LDAP_PORT` +- `LDAP_BIND_DN` +- `LDAP_BIND_PASSWORD` +- `LDAP_USE_STARTTLS` +- optional `LDAP_GROUP_ASSIGNMENT_MODE` +- optional `LDAP_GROUP_MEMBER_ATTRIBUTE` + +## Beispiel + +```env +REGISTRATION_PASSWORD_KEY=bitte-einen-langen-zufallswert-oder-base64-key-verwenden +LDAP_HOST=ldap.example.internal +LDAP_PORT=389 +LDAP_BIND_DN=uid=serviceaccount,cn=users,dc=nas,dc=kusche,dc=berlin +LDAP_BIND_PASSWORD=supersecret +LDAP_USE_STARTTLS=true +LDAP_GROUP_ASSIGNMENT_MODE=group_memberuid +LDAP_GROUP_MEMBER_ATTRIBUTE=memberUid +``` + +## Aktuelles Verhalten + +- Registrierung speichert das gewünschte Passwort verschlüsselt zwischen +- Admin-Freigabe schreibt den Benutzer direkt ins LDAP +- dabei werden `userPassword` und `sambaNTPassword` gesetzt +- der Benutzer wird zunächst mit inaktivem `shadowExpire` angelegt +- zusätzlich wird weiter ein LDIF-Fallback erzeugt + +## Wichtige offene Punkte + +- Ob `shadowExpire=1` für Keycloak-Logins wirklich blockierend wirkt, hängt vom LDAP-/NAS-Verhalten ab. +- Falls Keycloak trotzdem Login zulässt, brauchen wir als Nächstes noch einen expliziten Aktivierungsstatus oder eine Gruppen-/Attributprüfung im Loginflow.