$config */ public function __construct( private readonly string $projectRoot, private readonly array $config, ) { $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); $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 { return (bool) ($this->config['enabled'] ?? false); } /** * @return array> */ public function all(): array { return $this->store->all(); } /** * @return array|null */ public function find(string $id): ?array { return $this->store->find($id); } /** * @return array|null */ public function findLatestByUsername(string $username): ?array { return $this->store->findLatestByUsername($username); } /** * @param array $input * @return array{success: bool, errors?: array, request?: array} */ public function submit(array $input): array { $username = strtolower(trim((string) ($input['username'] ?? ''))); $givenName = trim((string) ($input['given_name'] ?? '')); $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 = $givenName; $fullName = trim($givenName . ' ' . $familyName); $errors = []; if (!preg_match('/^[a-z0-9._-]{3,32}$/', $username)) { $errors[] = 'Der Benutzername muss 3 bis 32 Zeichen lang sein und darf nur Kleinbuchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'; } if ($givenName === '') { $errors[] = 'Der Vorname ist erforderlich.'; } if ($familyName === '') { $errors[] = 'Der Nachname ist erforderlich.'; } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $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 Registrierung ist im Moment nicht verfügbar.'; } if ($note !== '' && mb_strlen($note) > 2000) { $errors[] = 'Die Hinweise für die Freischaltung sind zu lang.'; } $conflict = $this->store->findConflict($username, $email); if ($conflict !== null) { $errors[] = 'Für diesen Benutzernamen oder diese E-Mail-Adresse existiert bereits eine Registrierung.'; } if ($this->ldapProvisioner->usernameExists($username)) { $errors[] = 'Dieser Benutzername ist leider bereits vergeben.'; } if ($errors !== []) { return [ 'success' => false, 'errors' => $errors, ]; } $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' => $fullName !== '' ? $fullName : $displayName, 'admin_note' => '', 'ldap_created_at' => gmdate('c'), ]; $request = [ 'id' => bin2hex(random_bytes(8)), 'status' => 'pending_approval', 'created_at' => gmdate('c'), 'updated_at' => gmdate('c'), 'username' => $username, 'given_name' => $givenName, 'family_name' => $familyName, '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'] ?? ''), 'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''), ], 'review' => 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) { $errorReference = $this->logProvisioningFailure($request, $provisioning, $exception); $request['password_secret'] = $passwordSecret; $request['status'] = 'provisioning_failed'; $request['provisioning']['status'] = 'provisioning_failed'; $request['provisioning']['error'] = $exception->getMessage(); $request['provisioning']['error_reference'] = $errorReference; return [ 'success' => false, 'errors' => ['Die Registrierung konnte im Moment nicht gespeichert werden. Bitte versuche es später erneut. Referenz: ' . $errorReference], 'request' => $this->store->create($request), ]; } return [ 'success' => true, 'request' => $this->store->create($request), ]; } /** * @param array $input * @return array{success: bool, errors?: array, request?: array} */ public function approve(string $id, array $input, string $reviewer): array { $request = $this->store->find($id); if ($request === null) { return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']]; } $memberOfList = trim((string) ($input['member_of_list'] ?? '')); $adminNote = trim((string) ($input['admin_note'] ?? '')); $errors = []; 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' => (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' => (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, '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_active'; $item['updated_at'] = gmdate('c'); $item['review'] = [ 'reviewed_at' => gmdate('c'), 'reviewed_by' => $reviewer, 'decision' => 'approved', 'note' => $adminNote, ]; $item['provisioning'] = $provisioning; return $item; } ); if ($request === null) { return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']]; } return [ 'success' => true, 'request' => $request, ]; } /** * @return array{success: bool, errors?: array, request?: array} */ public function reject(string $id, string $reason, string $reviewer): array { $reason = trim($reason); if ($reason === '') { return ['success' => false, 'errors' => ['Bitte gib einen Ablehnungsgrund an.']]; } $request = $this->store->update( $id, static function (array $item) use ($reason, $reviewer): array { $item['status'] = 'rejected'; $item['updated_at'] = gmdate('c'); $item['review'] = [ 'reviewed_at' => gmdate('c'), 'reviewed_by' => $reviewer, 'decision' => 'rejected', 'note' => $reason, ]; return $item; } ); if ($request === null) { return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']]; } return [ 'success' => true, 'request' => $request, ]; } /** * @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')); if (!is_dir($directory)) { mkdir($directory, 0775, true); } $filename = $id . '-' . preg_replace('/[^a-z0-9._-]+/i', '-', $username) . '.ldif'; $path = rtrim($directory, '/') . '/' . $filename; file_put_contents($path, $content, LOCK_EX); return $path; } private function resolveProjectPath(string $path): string { if ($path === '') { return $this->projectRoot . '/data/registration-requests.json'; } if (str_starts_with($path, '/')) { return $path; } return $this->projectRoot . '/' . ltrim($path, '/'); } /** * @param array $request * @param array $provisioning */ private function logProvisioningFailure(array $request, array $provisioning, \Throwable $exception): string { $reference = 'REG-' . gmdate('YmdHis') . '-' . substr(bin2hex(random_bytes(4)), 0, 8); $path = $this->resolveProjectPath('data/registration-provisioning-errors.log'); $directory = dirname($path); if (!is_dir($directory)) { mkdir($directory, 0775, true); } $payload = [ 'reference' => $reference, 'created_at' => gmdate('c'), 'message' => $exception->getMessage(), 'username' => (string) ($request['username'] ?? ''), 'email' => (string) ($request['email'] ?? ''), 'display_name' => (string) ($request['display_name'] ?? ''), 'origin' => $request['origin'] ?? [], 'provisioning' => $provisioning, ]; file_put_contents( $path, json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL, FILE_APPEND | LOCK_EX ); return $reference; } }