From 9325b93404d9e21b109d25ba20000719acc9cab3 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 15 Jun 2026 02:05:36 +0200 Subject: [PATCH] asdasd --- .../theme/kusche-desktop/login/login.ftl | 6 +- .../resources/js/desktop-register-link.js | 13 +- config/apps.php | 15 ++ config/registration.php | 23 ++ partials/auth/login_screen.php | 5 + public/admin/ldap-test/index.php | 178 +------------ public/admin/users/index.php | 234 ++++++++++++++++++ public/auth/forgot-password/index.php | 66 +++++ public/auth/login/index.php | 1 + public/auth/reset-password/index.php | 79 ++++++ src/App/App.php | 34 ++- src/App/JsonStore.php | 51 ++++ src/App/LdapProvisioner.php | 166 ++++++++++++- src/App/Mailer.php | 71 ++++++ src/App/PasswordResetService.php | 106 ++++++++ src/App/PasswordResetTokenStore.php | 75 ++++++ src/App/RegistrationIdentityAllocator.php | 42 ++++ src/App/RegistrationService.php | 205 +++++++++------ 18 files changed, 1102 insertions(+), 268 deletions(-) create mode 100644 public/admin/users/index.php create mode 100644 public/auth/forgot-password/index.php create mode 100644 public/auth/reset-password/index.php create mode 100644 src/App/JsonStore.php create mode 100644 src/App/Mailer.php create mode 100644 src/App/PasswordResetService.php create mode 100644 src/App/PasswordResetTokenStore.php create mode 100644 src/App/RegistrationIdentityAllocator.php diff --git a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl index a1048852..58da9fd1 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl +++ b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl @@ -77,9 +77,7 @@ @@ -90,7 +88,7 @@
diff --git a/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js index cb660056..f4421535 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js +++ b/CopyToKeycloak/theme/kusche-desktop/login/resources/js/desktop-register-link.js @@ -1,7 +1,8 @@ (function () { const registerLink = document.getElementById('kb-register-button'); + const forgotPasswordLink = document.getElementById('kb-forgot-password-link'); - if (!registerLink) { + if (!registerLink && !forgotPasswordLink) { return; } @@ -14,8 +15,14 @@ try { const target = new URL(redirectUri); - registerLink.href = target.origin + '/auth/register'; - registerLink.hidden = false; + if (registerLink) { + registerLink.href = target.origin + '/auth/register/'; + registerLink.hidden = false; + } + if (forgotPasswordLink) { + forgotPasswordLink.href = target.origin + '/auth/forgot-password/'; + forgotPasswordLink.hidden = false; + } } catch (error) { console.warn('Registrierungslink konnte nicht vorbereitet werden.', error); } diff --git a/config/apps.php b/config/apps.php index 31f9e9d7..0683ab59 100644 --- a/config/apps.php +++ b/config/apps.php @@ -55,6 +55,21 @@ return [ 'module_name' => 'admin', 'summary' => 'Platzhalter fuer globale Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen.', ], + [ + 'app_id' => 'user-management', + 'title' => 'User Management', + 'icon' => 'UM', + 'entry_route' => '/admin/users/', + 'window_mode' => 'window', + 'content_mode' => 'iframe', + 'default_width' => 1180, + 'default_height' => 760, + 'supports_widget' => false, + 'supports_tray' => true, + 'module_name' => 'admin', + 'required_groups' => ['administrators'], + 'summary' => 'Benutzerverwaltung fuer Freischaltungen, Aktivierung, Deaktivierung und Passwort-Reset.', + ], [ 'app_id' => 'desktop-login', 'title' => 'Desktop Login', diff --git a/config/registration.php b/config/registration.php index 51c05628..a7178a5a 100644 --- a/config/registration.php +++ b/config/registration.php @@ -6,9 +6,18 @@ return [ 'enabled' => true, 'storage_path' => 'data/registration-requests.json', 'ldif_export_dir' => 'data/registration-ldif', + 'identity_counter_path' => 'data/registration-identity-counter.json', + 'reset_token_path' => 'data/password-reset-tokens.json', 'approver_groups' => ['administrators', 'appadmin'], 'approver_usernames' => [], 'password_crypto_key' => getenv('REGISTRATION_PASSWORD_KEY') ?: '', + 'reset_link_ttl_hours' => 8, + 'mail' => [ + 'enabled' => true, + 'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@desktop.kusche.berlin', + 'from_name' => getenv('MAIL_FROM_NAME') ?: 'Kusche.Berlin Desktop', + 'log_path' => 'data/mail.log', + ], 'ldap' => [ 'enabled' => true, 'host' => getenv('LDAP_HOST') ?: '127.0.0.1', @@ -20,10 +29,24 @@ return [ 'default_groups' => [ 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin', ], + 'pending_group_dn' => getenv('LDAP_PENDING_GROUP_DN') ?: 'cn=users-pending,cn=groups,dc=nas,dc=kusche,dc=berlin', + 'available_groups' => [ + ['dn' => 'cn=administrators,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Administrators'], + ['dn' => 'cn=appadmin,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'App Admin'], + ['dn' => 'cn=dockers,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Dockers'], + ['dn' => 'cn=family,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Family'], + ['dn' => 'cn=generaluser,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'General User'], + ['dn' => 'cn=internalfamily,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Internal Family'], + ['dn' => 'cn=users,cn=groups,dc=nas,dc=kusche,dc=berlin', 'label' => 'Users'], + ], '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', + 'uid_number_start' => (int) (getenv('LDAP_UID_NUMBER_START') ?: 1000010), + 'gid_number_default' => (int) (getenv('LDAP_GID_NUMBER_DEFAULT') ?: 1000012), + 'samba_domain_sid' => getenv('LDAP_SAMBA_DOMAIN_SID') ?: 'S-1-5-21-2039032496-2228230393-4197958046', + 'samba_rid_start' => (int) (getenv('LDAP_SAMBA_RID_START') ?: 1025), 'shadow_expire' => 1, 'active_shadow_expire' => -1, 'shadow_flag' => 0, diff --git a/partials/auth/login_screen.php b/partials/auth/login_screen.php index bebd5f45..bf3ad12c 100644 --- a/partials/auth/login_screen.php +++ b/partials/auth/login_screen.php @@ -7,6 +7,7 @@ $headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login'); $subheadline = (string) ($loginScreen['branding']['subheadline'] ?? ''); $loginUrl = $loginScreen['login_url'] ?? null; $registerUrl = $loginScreen['register_url'] ?? null; +$forgotPasswordUrl = $loginScreen['forgot_password_url'] ?? null; $previewUrl = $loginScreen['preview_url'] ?? null; $keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false); $callbackUrl = (string) ($loginScreen['callback_url'] ?? ''); @@ -41,6 +42,10 @@ $callbackUrl = (string) ($loginScreen['callback_url'] ?? ''); Registrierung anfragen + + + Passwort vergessen +
diff --git a/public/admin/ldap-test/index.php b/public/admin/ldap-test/index.php index ac1f8782..9e696b77 100644 --- a/public/admin/ldap-test/index.php +++ b/public/admin/ldap-test/index.php @@ -1,177 +1,3 @@ diagnostics(); - -$serviceResult = $ldap->testServiceBind(); -$messages = []; -$errors = []; -$form = ['username' => '', 'password' => '', 'new_password' => '']; -$attempt = null; -$currentUserDn = null; - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = (string) ($_POST['action'] ?? ''); - $form['username'] = trim((string) ($_POST['username'] ?? '')); - $form['password'] = (string) ($_POST['password'] ?? ''); - $form['new_password'] = (string) ($_POST['new_password'] ?? ''); - - if ($form['username'] === '') { - $errors[] = 'Benutzername ist erforderlich.'; - } - - if ($form['password'] === '') { - $errors[] = 'Passwort ist erforderlich.'; - } - - if ($action === 'change-password' && strlen($form['new_password']) < 12) { - $errors[] = 'Das neue Passwort muss mindestens 12 Zeichen lang sein.'; - } - - if ($errors === []) { - $currentUserDn = $ldap->userDnForUsername($form['username']); - - if ($action === 'test-login') { - $attempt = [ - 'action' => 'Login-Test', - 'username' => $form['username'], - 'user_dn' => $currentUserDn, - ]; - $result = $ldap->testUserCredentials($form['username'], $form['password']); - - if ($result['success'] ?? false) { - $messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? ''); - } else { - $errors[] = (string) ($result['message'] ?? 'LDAP-Login fehlgeschlagen.'); - } - } elseif ($action === 'change-password') { - $attempt = [ - 'action' => 'Passwortänderung', - 'username' => $form['username'], - 'user_dn' => $currentUserDn, - 'new_password_length' => (string) strlen($form['new_password']), - ]; - $loginCheck = $ldap->testUserCredentials($form['username'], $form['password']); - - if (!(bool) ($loginCheck['success'] ?? false)) { - $errors[] = 'Der Login mit aktuellem Passwort ist fehlgeschlagen: ' . (string) ($loginCheck['message'] ?? ''); - } else { - $result = $ldap->updateUserPassword($form['username'], $form['new_password']); - - if ($result['success'] ?? false) { - $messages[] = $result['message'] . ' DN: ' . (string) ($result['dn'] ?? ''); - $form['password'] = ''; - $form['new_password'] = ''; - } else { - $errors[] = (string) ($result['message'] ?? 'Passwortänderung fehlgeschlagen.'); - } - } - } - } -} -?> - - - - - LDAP-Test - - - -
- -
- - +http_response_code(404); +echo 'LDAP-Test ist deaktiviert.'; diff --git a/public/admin/users/index.php b/public/admin/users/index.php new file mode 100644 index 00000000..03ccc974 --- /dev/null +++ b/public/admin/users/index.php @@ -0,0 +1,234 @@ + strtolower(trim($group, '/')), array_values(array_map('strval', (array) ($currentUser['groups'] ?? [])))); + +if (!in_array('administrators', $currentGroups, true)) { + http_response_code(403); + echo 'Kein Zugriff auf User Management.'; + exit; +} + +$csrfToken = (string) ($_SESSION['user_management_token'] ?? ''); + +if ($csrfToken === '') { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['user_management_token'] = $csrfToken; +} + +$messages = []; +$errors = []; +$selectedId = trim((string) ($_GET['id'] ?? $_POST['selected_id'] ?? '')); +$manualUsername = trim((string) ($_POST['manual_username'] ?? '')); +$manualResetUsername = trim((string) ($_POST['reset_username'] ?? '')); +$manualResetPassword = (string) ($_POST['reset_password'] ?? ''); + +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 === 'approve-registration') { + $groupDns = array_values(array_filter(array_map('strval', (array) ($_POST['group_dns'] ?? [])))); + $memberOfList = implode(PHP_EOL, $groupDns); + $result = $registration->approve($selectedId, [ + 'member_of_list' => $memberOfList, + 'admin_note' => (string) ($_POST['admin_note'] ?? ''), + ], (string) ($currentUser['username'] ?? 'administrator')); + + if (($result['success'] ?? false) !== true) { + $errors = array_merge($errors, array_values(array_map('strval', (array) ($result['errors'] ?? [])))); + } else { + $messages[] = 'Registrierung wurde freigeschaltet.'; + } + } elseif ($action === 'deactivate-user') { + $result = $registration->deactivateUser($manualUsername); + ($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? ''); + } elseif ($action === 'activate-user') { + $groupDns = array_values(array_filter(array_map('strval', (array) ($_POST['manual_group_dns'] ?? [])))); + $result = $registration->activateExistingUser($manualUsername, $groupDns); + ($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? ''); + } elseif ($action === 'send-reset-mail') { + $origin = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $result = $registration->sendPasswordReset($manualResetUsername, $origin); + ($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? ''); + } elseif ($action === 'set-password') { + if (strlen($manualResetPassword) < 12) { + $errors[] = 'Das neue Passwort muss mindestens 12 Zeichen lang sein.'; + } else { + $result = $registration->setUserPassword($manualResetUsername, $manualResetPassword); + ($result['success'] ?? false) ? $messages[] = (string) ($result['message'] ?? '') : $errors[] = (string) ($result['message'] ?? ''); + } + } + } +} + +$pendingRequests = $registration->pendingApprovals(); +$availableGroups = $registration->availableGroups(); +$selected = $selectedId !== '' + ? $registration->find($selectedId) + : ($pendingRequests[0] ?? null); +?> + + + + + User Management + + + +
+ +
+ + diff --git a/public/auth/forgot-password/index.php b/public/auth/forgot-password/index.php new file mode 100644 index 00000000..bc92df54 --- /dev/null +++ b/public/auth/forgot-password/index.php @@ -0,0 +1,66 @@ +sendPasswordReset($username, $origin); + + if ($result['success'] ?? false) { + $message = (string) ($result['message'] ?? ''); + } else { + $error = (string) ($result['message'] ?? ''); + } +} +?> + + + + + Passwort vergessen + + + +
+ +
+ + diff --git a/public/auth/login/index.php b/public/auth/login/index.php index c01177da..e514ab66 100644 --- a/public/auth/login/index.php +++ b/public/auth/login/index.php @@ -19,6 +19,7 @@ $loginScreen = [ 'branding' => $auth->branding(), 'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null, 'register_url' => '/auth/register', + 'forgot_password_url' => '/auth/forgot-password/', 'preview_url' => $previewUrl, 'keycloak_ready' => $auth->isConfigured(), 'callback_url' => $auth->redirectUri(), diff --git a/public/auth/reset-password/index.php b/public/auth/reset-password/index.php new file mode 100644 index 00000000..da7cf25c --- /dev/null +++ b/public/auth/reset-password/index.php @@ -0,0 +1,79 @@ +validate($token); +$message = null; +$error = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $result = $service->complete( + $token, + (string) ($_POST['password'] ?? ''), + (string) ($_POST['password_confirm'] ?? '') + ); + + if ($result['success'] ?? false) { + $message = (string) ($result['message'] ?? ''); + $record = null; + } else { + $error = (string) ($result['message'] ?? ''); + } +} +?> + + + + + Passwort zurücksetzen + + + +
+ +
+ + diff --git a/src/App/App.php b/src/App/App.php index cf8ed6e9..a510c6c4 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -29,13 +29,15 @@ final class App $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php'); $skins = SkinResolver::all(); $activeSkin = SkinResolver::resolve($_GET['skin'] ?? null); - $apps = AppIconResolver::decorate($registry->all(), $activeSkin); - $windows = (new WindowManager($registry))->defaultWindows(); - $activeWidgetIds = DesktopState::defaultWidgetIds(); $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); $authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; + $authGroups = array_values(array_map('strval', (array) ($authUser['groups'] ?? []))); + $visibleApps = $this->filterAppsForGroups($registry->all(), $authGroups); + $apps = AppIconResolver::decorate($visibleApps, $activeSkin); + $windows = (new WindowManager($registry))->defaultWindows(); + $activeWidgetIds = DesktopState::defaultWidgetIds(); $displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast'); return [ @@ -80,4 +82,30 @@ final class App ], ]; } + + /** + * @param array> $apps + * @param array $groups + * @return array> + */ + private function filterAppsForGroups(array $apps, array $groups): array + { + $normalizedGroups = array_map(static fn (string $group): string => strtolower(trim($group, '/')), $groups); + + return array_values(array_filter($apps, static function (array $app) use ($normalizedGroups): bool { + $requiredGroups = array_values(array_map('strval', (array) ($app['required_groups'] ?? []))); + + if ($requiredGroups === []) { + return true; + } + + foreach ($requiredGroups as $group) { + if (in_array(strtolower(trim($group, '/')), $normalizedGroups, true)) { + return true; + } + } + + return false; + })); + } } diff --git a/src/App/JsonStore.php b/src/App/JsonStore.php new file mode 100644 index 00000000..a0448757 --- /dev/null +++ b/src/App/JsonStore.php @@ -0,0 +1,51 @@ + + */ + public function read(): array + { + if (!is_file($this->storagePath)) { + return []; + } + + $raw = file_get_contents($this->storagePath); + + if ($raw === false || trim($raw) === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $payload + */ + public function write(array $payload): void + { + $directory = dirname($this->storagePath); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents( + $this->storagePath, + json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL, + LOCK_EX + ); + } +} diff --git a/src/App/LdapProvisioner.php b/src/App/LdapProvisioner.php index 7239a2a3..9b652ec5 100644 --- a/src/App/LdapProvisioner.php +++ b/src/App/LdapProvisioner.php @@ -56,6 +56,60 @@ final class LdapProvisioner return 'uid=' . $username . ',' . $baseDn; } + public function usernameExists(string $username): bool + { + return $this->getUserByUsername($username, ['uid']) !== null; + } + + /** + * @param array $attributes + * @return array|null + */ + public function getUserByUsername(string $username, array $attributes = []): ?array + { + try { + $link = $this->connectAsService(); + $userDn = $this->userDnForUsername($username); + $read = @ldap_read($link, $userDn, '(objectClass=*)', $attributes === [] ? ['*'] : $attributes); + + if ($read === false) { + return null; + } + + $entries = ldap_get_entries($link, $read); + + if (!is_array($entries) || (int) ($entries['count'] ?? 0) < 1) { + return null; + } + + $entry = ['dn' => $userDn]; + + foreach ($entries[0] as $key => $value) { + if (is_int($key) || $key === 'count') { + continue; + } + + if (is_array($value)) { + if ((int) ($value['count'] ?? 0) === 1) { + $entry[$key] = (string) ($value[0] ?? ''); + } else { + $items = []; + foreach ($value as $subKey => $subValue) { + if (is_int($subKey)) { + $items[] = (string) $subValue; + } + } + $entry[$key] = $items; + } + } + } + + return $entry; + } catch (\Throwable) { + return null; + } + } + /** * @return array{success: bool, message: string} */ @@ -172,7 +226,12 @@ final class LdapProvisioner throw new RuntimeException('LDAP-Benutzer konnte nicht angelegt werden: ' . $this->lastError($link)); } - $groupUpdates = $this->assignGroups($link, (string) ($request['username'] ?? ''), (string) $userDn, $provisioning); + $groupUpdates = $this->syncGroupsForUser( + $link, + (string) ($request['username'] ?? ''), + (string) $userDn, + $this->resolveTargetGroupsFromProvisioning($provisioning) + ); return [ 'dn' => $userDn, @@ -180,6 +239,64 @@ final class LdapProvisioner ]; } + /** + * @param array $targetGroups + * @return array{success: bool, message: string, dn?: string} + */ + public function activateUser(string $username, array $targetGroups): array + { + try { + $link = $this->connectAsService(); + $userDn = $this->userDnForUsername($username); + $groupUpdates = $this->syncGroupsForUser($link, $username, $userDn, $targetGroups); + + if (!@ldap_modify($link, $userDn, [ + 'shadowExpire' => (string) ($this->config['ldap']['active_shadow_expire'] ?? -1), + ])) { + throw new RuntimeException('Aktivierung im LDAP fehlgeschlagen: ' . $this->lastError($link)); + } + + return [ + 'success' => true, + 'message' => 'Benutzer wurde aktiviert.', + 'dn' => $userDn, + ]; + } catch (\Throwable $exception) { + return [ + 'success' => false, + 'message' => $exception->getMessage(), + ]; + } + } + + /** + * @return array{success: bool, message: string, dn?: string} + */ + public function deactivateUser(string $username): array + { + try { + $link = $this->connectAsService(); + $userDn = $this->userDnForUsername($username); + + if (!@ldap_modify($link, $userDn, [ + 'shadowExpire' => (string) ($this->config['ldap']['shadow_expire'] ?? 1), + ])) { + throw new RuntimeException('Deaktivierung im LDAP fehlgeschlagen: ' . $this->lastError($link)); + } + + return [ + 'success' => true, + 'message' => 'Benutzer wurde deaktiviert.', + 'dn' => $userDn, + ]; + } catch (\Throwable $exception) { + return [ + 'success' => false, + 'message' => $exception->getMessage(), + ]; + } + } + /** * @param array $request * @param array $provisioning @@ -255,14 +372,20 @@ final class LdapProvisioner */ 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'] ?? []))); + return $this->syncGroupsForUser($link, $username, $userDn, $this->resolveTargetGroupsFromProvisioning($provisioning)); + } + + /** + * @param resource|\LDAP\Connection $link + * @param array $targetGroups + * @return array + */ + private function syncGroupsForUser($link, string $username, string $userDn, array $targetGroups): array + { $mode = (string) ($this->config['ldap']['group_assignment_mode'] ?? 'group_memberuid'); $updated = []; - if ($groupDns === [] || $mode === 'none') { + if ($targetGroups === [] || $mode === 'none') { return []; } @@ -272,8 +395,22 @@ final class LdapProvisioner $attribute = (string) ($this->config['ldap']['group_member_attribute'] ?? 'memberUid'); $value = $attribute === 'member' ? $userDn : $username; + $knownGroups = array_values(array_unique(array_filter(array_merge( + $targetGroups, + array_map(static fn (array $group): string => (string) ($group['dn'] ?? ''), (array) ($this->config['ldap']['available_groups'] ?? [])), + array_filter([(string) ($this->config['ldap']['pending_group_dn'] ?? '')]), + array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? []))) + )))); - foreach ($groupDns as $groupDn) { + foreach ($knownGroups as $groupDn) { + if (in_array($groupDn, $targetGroups, true)) { + continue; + } + + @ldap_mod_del($link, $groupDn, [$attribute => [$value]]); + } + + foreach ($targetGroups as $groupDn) { $result = @ldap_mod_add($link, $groupDn, [$attribute => [$value]]); if ($result === false) { @@ -293,6 +430,21 @@ final class LdapProvisioner return $updated; } + /** + * @param array $provisioning + * @return array + */ + private function resolveTargetGroupsFromProvisioning(array $provisioning): array + { + $list = trim((string) ($provisioning['member_of_list'] ?? '')); + + if ($list !== '') { + return $this->parseGroupDns($list); + } + + return array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? []))); + } + /** * @return resource|\LDAP\Connection */ diff --git a/src/App/Mailer.php b/src/App/Mailer.php new file mode 100644 index 00000000..7cc0657b --- /dev/null +++ b/src/App/Mailer.php @@ -0,0 +1,71 @@ + $config + */ + public function __construct( + string $projectRoot, + private readonly array $config, + ) { + $logPath = $projectRoot . '/' . ltrim((string) ($this->config['mail']['log_path'] ?? 'data/mail.log'), '/'); + $this->logStore = new JsonStore($logPath); + } + + /** + * @return array{success: bool, message: string} + */ + public function send(string $toEmail, string $subject, string $body): array + { + $fromEmail = (string) ($this->config['mail']['from_email'] ?? ''); + $fromName = (string) ($this->config['mail']['from_name'] ?? ''); + $headers = [ + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + ]; + + if ($fromEmail !== '') { + $headers[] = 'From: ' . ($fromName !== '' ? sprintf('%s <%s>', $fromName, $fromEmail) : $fromEmail); + } + + $success = false; + + if ((bool) ($this->config['mail']['enabled'] ?? false) && function_exists('mail')) { + $success = @mail($toEmail, $subject, $body, implode("\r\n", $headers)); + } + + $logged = $this->logMail($toEmail, $subject, $body); + + if ($success) { + return ['success' => true, 'message' => 'E-Mail wurde versendet.']; + } + + return [ + 'success' => false, + 'message' => $logged + ? 'Mailversand war nicht erfolgreich. Die Nachricht wurde im Mail-Log abgelegt.' + : 'Mailversand war nicht erfolgreich.', + ]; + } + + private function logMail(string $toEmail, string $subject, string $body): bool + { + $payload = $this->logStore->read(); + $payload[] = [ + 'created_at' => gmdate('c'), + 'to' => $toEmail, + 'subject' => $subject, + 'body' => $body, + ]; + $this->logStore->write($payload); + + return true; + } +} diff --git a/src/App/PasswordResetService.php b/src/App/PasswordResetService.php new file mode 100644 index 00000000..6138120e --- /dev/null +++ b/src/App/PasswordResetService.php @@ -0,0 +1,106 @@ + $config + */ + public function __construct( + private readonly string $projectRoot, + private readonly array $config, + ) { + $tokenPath = $this->projectRoot . '/' . ltrim((string) ($this->config['reset_token_path'] ?? 'data/password-reset-tokens.json'), '/'); + $this->store = new PasswordResetTokenStore($tokenPath); + $this->mailer = new Mailer($projectRoot, $config); + $this->ldap = new LdapProvisioner($config); + } + + /** + * @return array{success: bool, message: string} + */ + public function request(string $username, string $origin): array + { + $username = strtolower(trim($username)); + + if ($username === '') { + return ['success' => false, 'message' => 'Bitte einen Benutzernamen angeben.']; + } + + $user = $this->ldap->getUserByUsername($username, ['mail', 'displayName', 'uid']); + + if ($user === null) { + return ['success' => false, 'message' => 'Benutzer wurde im LDAP nicht gefunden.']; + } + + $email = (string) ($user['mail'] ?? ''); + + if ($email === '') { + return ['success' => false, 'message' => 'Für diesen Benutzer ist keine E-Mail-Adresse im LDAP hinterlegt.']; + } + + $token = bin2hex(random_bytes(24)); + $expiresAt = gmdate('c', time() + ((int) ($this->config['reset_link_ttl_hours'] ?? 8) * 3600)); + $this->store->add([ + 'token' => $token, + 'username' => $username, + 'email' => $email, + 'status' => 'active', + 'created_at' => gmdate('c'), + 'expires_at' => $expiresAt, + ]); + + $link = rtrim($origin, '/') . '/auth/reset-password/?token=' . urlencode($token); + $body = "Hallo,\n\nfür dein Kusche.Berlin Desktop-Konto wurde ein Passwort-Reset angefordert.\n\nLink: {$link}\n\nDer Link ist bis {$expiresAt} gültig.\n\nWenn du das nicht warst, kannst du diese E-Mail ignorieren.\n"; + $mail = $this->mailer->send($email, 'Passwort zurücksetzen', $body); + + return $mail['success'] + ? ['success' => true, 'message' => 'Reset-Link wurde per E-Mail versendet.'] + : ['success' => false, 'message' => $mail['message']]; + } + + /** + * @return array|null + */ + public function validate(string $token): ?array + { + return $this->store->findActive($token); + } + + /** + * @return array{success: bool, message: string} + */ + public function complete(string $token, string $password, string $passwordConfirm): array + { + $record = $this->validate($token); + + if ($record === null) { + return ['success' => false, 'message' => 'Der Reset-Link ist ungültig oder abgelaufen.']; + } + + if (strlen($password) < 12) { + return ['success' => false, 'message' => 'Das neue Passwort muss mindestens 12 Zeichen lang sein.']; + } + + if ($password !== $passwordConfirm) { + return ['success' => false, 'message' => 'Die Passwörter stimmen nicht überein.']; + } + + $result = $this->ldap->updateUserPassword((string) ($record['username'] ?? ''), $password); + + if (!($result['success'] ?? false)) { + return ['success' => false, 'message' => (string) ($result['message'] ?? 'Passwortänderung fehlgeschlagen.')]; + } + + $this->store->deactivate($token, 'used'); + + return ['success' => true, 'message' => 'Das Passwort wurde erfolgreich zurückgesetzt.']; + } +} diff --git a/src/App/PasswordResetTokenStore.php b/src/App/PasswordResetTokenStore.php new file mode 100644 index 00000000..75cd358e --- /dev/null +++ b/src/App/PasswordResetTokenStore.php @@ -0,0 +1,75 @@ +store = new JsonStore($storagePath); + } + + /** + * @return array> + */ + public function all(): array + { + $items = $this->store->read(); + return array_values(array_filter($items, 'is_array')); + } + + /** + * @param array $record + */ + public function add(array $record): void + { + $items = $this->all(); + $items[] = $record; + $this->store->write($items); + } + + /** + * @return array|null + */ + public function findActive(string $token): ?array + { + foreach ($this->all() as $item) { + if ((string) ($item['token'] ?? '') !== $token) { + continue; + } + + if ((string) ($item['status'] ?? 'active') !== 'active') { + return null; + } + + if (strtotime((string) ($item['expires_at'] ?? '')) < time()) { + return null; + } + + return $item; + } + + return null; + } + + public function deactivate(string $token, string $status): void + { + $items = $this->all(); + + foreach ($items as $index => $item) { + if ((string) ($item['token'] ?? '') !== $token) { + continue; + } + + $items[$index]['status'] = $status; + $items[$index]['used_at'] = gmdate('c'); + break; + } + + $this->store->write($items); + } +} diff --git a/src/App/RegistrationIdentityAllocator.php b/src/App/RegistrationIdentityAllocator.php new file mode 100644 index 00000000..c4421208 --- /dev/null +++ b/src/App/RegistrationIdentityAllocator.php @@ -0,0 +1,42 @@ + $config + */ + public function __construct( + string $storagePath, + private readonly array $config, + ) { + $this->store = new JsonStore($storagePath); + } + + /** + * @return array{uid_number: string, gid_number: string, samba_sid: string} + */ + public function allocate(): array + { + $payload = $this->store->read(); + $nextUid = (int) ($payload['next_uid_number'] ?? ($this->config['ldap']['uid_number_start'] ?? 1000010)); + $nextRid = (int) ($payload['next_samba_rid'] ?? ($this->config['ldap']['samba_rid_start'] ?? 1025)); + $gidNumber = (int) ($this->config['ldap']['gid_number_default'] ?? 1000012); + $domainSid = trim((string) ($this->config['ldap']['samba_domain_sid'] ?? '')); + + $payload['next_uid_number'] = $nextUid + 1; + $payload['next_samba_rid'] = $nextRid + 1; + $this->store->write($payload); + + return [ + 'uid_number' => (string) $nextUid, + 'gid_number' => (string) $gidNumber, + 'samba_sid' => $domainSid !== '' ? $domainSid . '-' . $nextRid : '', + ]; + } +} diff --git a/src/App/RegistrationService.php b/src/App/RegistrationService.php index e2008233..94536f5a 100644 --- a/src/App/RegistrationService.php +++ b/src/App/RegistrationService.php @@ -10,6 +10,8 @@ final class RegistrationService private RegistrationLdifBuilder $ldifBuilder; private RegistrationSecretBox $secretBox; private LdapProvisioner $ldapProvisioner; + private RegistrationIdentityAllocator $identityAllocator; + private PasswordResetService $passwordResetService; /** * @param array $config @@ -23,6 +25,9 @@ final class RegistrationService $this->ldifBuilder = new RegistrationLdifBuilder($this->config); $this->secretBox = new RegistrationSecretBox((string) ($this->config['password_crypto_key'] ?? '')); $this->ldapProvisioner = new LdapProvisioner($this->config); + $counterPath = $this->resolveProjectPath((string) ($this->config['identity_counter_path'] ?? 'data/registration-identity-counter.json')); + $this->identityAllocator = new RegistrationIdentityAllocator($counterPath, $this->config); + $this->passwordResetService = new PasswordResetService($this->projectRoot, $this->config); } public function isEnabled(): bool @@ -100,6 +105,10 @@ final class RegistrationService $errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.'; } + if ($this->ldapProvisioner->usernameExists($username)) { + $errors[] = 'Dieser Benutzername ist bereits im LDAP vergeben.'; + } + if ($errors !== []) { return [ 'success' => false, @@ -107,9 +116,26 @@ final class RegistrationService ]; } + $identities = $this->identityAllocator->allocate(); + $pendingGroup = trim((string) ($this->config['ldap']['pending_group_dn'] ?? '')); + $provisioning = [ + 'uid_number' => $identities['uid_number'], + 'gid_number' => $identities['gid_number'], + 'home_directory' => rtrim((string) ($this->config['ldap']['home_directory_prefix'] ?? '/home'), '/') . '/' . $username, + 'login_shell' => trim((string) ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')), + 'member_of_list' => $pendingGroup, + 'samba_sid' => $identities['samba_sid'], + 'apple_generateduid' => '', + 'display_name' => $displayName, + 'cn' => $displayName !== '' ? $displayName : $username, + 'gecos' => $displayName, + 'admin_note' => '', + 'ldap_created_at' => gmdate('c'), + ]; + $request = [ 'id' => bin2hex(random_bytes(8)), - 'status' => 'pending', + 'status' => 'pending_approval', 'created_at' => gmdate('c'), 'updated_at' => gmdate('c'), 'username' => $username, @@ -125,8 +151,29 @@ final class RegistrationService 'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''), ], 'review' => null, - 'provisioning' => null, + 'provisioning' => $provisioning, ]; + $passwordSecret = $this->secretBox->encrypt($password); + + try { + $ldapResult = $this->ldapProvisioner->createInactiveUser($request, $provisioning, $password); + $request['password_secret'] = null; + $request['provisioning']['ldap_dn'] = $ldapResult['dn']; + $request['provisioning']['group_updates'] = $ldapResult['group_updates']; + $request['provisioning']['status'] = 'ldap_created_inactive'; + $request['provisioning']['ldif_path'] = $this->writeLdifDraft($request['id'], $username, $this->ldifBuilder->build($request, $provisioning)); + } catch (\Throwable $exception) { + $request['password_secret'] = $passwordSecret; + $request['status'] = 'provisioning_failed'; + $request['provisioning']['status'] = 'provisioning_failed'; + $request['provisioning']['error'] = $exception->getMessage(); + + return [ + 'success' => false, + 'errors' => ['LDAP-Provisionierung bei Registrierung fehlgeschlagen: ' . $exception->getMessage()], + 'request' => $this->store->create($request), + ]; + } return [ 'success' => true, @@ -146,49 +193,45 @@ final class RegistrationService return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']]; } - $uidNumber = trim((string) ($input['uid_number'] ?? '')); - $gidNumber = trim((string) ($input['gid_number'] ?? '')); - $homeDirectory = trim((string) ($input['home_directory'] ?? '')); - $sambaSid = trim((string) ($input['samba_sid'] ?? '')); $memberOfList = trim((string) ($input['member_of_list'] ?? '')); $adminNote = trim((string) ($input['admin_note'] ?? '')); $errors = []; - if ($uidNumber === '' || !ctype_digit($uidNumber)) { - $errors[] = 'uidNumber ist erforderlich und muss numerisch sein.'; - } - - if ($gidNumber === '' || !ctype_digit($gidNumber)) { - $errors[] = 'gidNumber ist erforderlich und muss numerisch sein.'; - } - - if ($homeDirectory === '') { - $errors[] = 'homeDirectory ist erforderlich.'; + if ($memberOfList === '') { + $errors[] = 'Für die Freischaltung muss mindestens eine Zielgruppe ausgewählt werden.'; } if ($errors !== []) { return ['success' => false, 'errors' => $errors]; } + $activate = $this->ldapProvisioner->activateUser((string) ($request['username'] ?? ''), preg_split('/\r\n|\r|\n/', $memberOfList) ?: []); + + if (!($activate['success'] ?? false)) { + return ['success' => false, 'errors' => [(string) ($activate['message'] ?? 'Aktivierung fehlgeschlagen.')]]; + } + $provisioning = [ - 'uid_number' => $uidNumber, - 'gid_number' => $gidNumber, - 'home_directory' => $homeDirectory, - 'login_shell' => trim((string) ($input['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh'))), + 'uid_number' => (string) ($request['provisioning']['uid_number'] ?? ''), + 'gid_number' => (string) ($request['provisioning']['gid_number'] ?? ''), + 'home_directory' => (string) ($request['provisioning']['home_directory'] ?? ''), + 'login_shell' => (string) ($request['provisioning']['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')), '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'] ?? ''))), - 'cn' => trim((string) ($input['cn'] ?? (string) ($request['display_name'] ?? $request['username'] ?? ''))), - 'gecos' => trim((string) ($input['gecos'] ?? (string) ($request['display_name'] ?? ''))), + 'samba_sid' => (string) ($request['provisioning']['samba_sid'] ?? ''), + 'apple_generateduid' => (string) ($request['provisioning']['apple_generateduid'] ?? ''), + 'display_name' => (string) ($request['provisioning']['display_name'] ?? $request['display_name'] ?? ''), + 'cn' => (string) ($request['provisioning']['cn'] ?? $request['display_name'] ?? $request['username'] ?? ''), + 'gecos' => (string) ($request['provisioning']['gecos'] ?? $request['display_name'] ?? ''), 'admin_note' => $adminNote, - 'ldif_generated_at' => gmdate('c'), + 'ldap_activated_at' => gmdate('c'), + 'ldap_dn' => (string) ($activate['dn'] ?? ($request['provisioning']['ldap_dn'] ?? '')), + 'status' => 'approved_active', ]; $request = $this->store->update( $id, static function (array $item) use ($provisioning, $reviewer, $adminNote): array { - $item['status'] = 'approved_pending_provisioning'; + $item['status'] = 'approved_active'; $item['updated_at'] = gmdate('c'); $item['review'] = [ 'reviewed_at' => gmdate('c'), @@ -206,54 +249,6 @@ final class RegistrationService return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']]; } - $ldif = $this->ldifBuilder->build($request, $provisioning); - $ldifPath = $this->writeLdifDraft((string) ($request['id'] ?? ''), (string) ($request['username'] ?? ''), $ldif); - - try { - $passwordSecret = (string) ($request['password_secret'] ?? ''); - - 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, 'request' => $request, @@ -297,6 +292,66 @@ final class RegistrationService ]; } + /** + * @return array{success: bool, message: string} + */ + public function sendPasswordReset(string $username, string $origin): array + { + return $this->passwordResetService->request($username, $origin); + } + + /** + * @return array{success: bool, message: string} + */ + public function deactivateUser(string $username): array + { + return $this->ldapProvisioner->deactivateUser($username); + } + + /** + * @return array{success: bool, message: string} + */ + public function activateExistingUser(string $username, array $groupDns): array + { + return $this->ldapProvisioner->activateUser($username, $groupDns); + } + + /** + * @return array{success: bool, message: string} + */ + public function setUserPassword(string $username, string $password): array + { + return $this->ldapProvisioner->updateUserPassword($username, $password); + } + + /** + * @return array> + */ + public function pendingApprovals(): array + { + return array_values(array_filter( + $this->all(), + static fn (array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending_approval', 'provisioning_failed'], true) + )); + } + + /** + * @return array> + */ + public function availableGroups(): array + { + return array_values(array_filter( + array_map( + static fn (array $group): array => [ + 'dn' => (string) ($group['dn'] ?? ''), + 'label' => (string) ($group['label'] ?? ($group['dn'] ?? '')), + ], + (array) ($this->config['ldap']['available_groups'] ?? []) + ), + static fn (array $group): bool => $group['dn'] !== '' + )); + } + private function writeLdifDraft(string $id, string $username, string $content): string { $directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));