diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -62,10 +62,8 @@ $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hostedRootDN = \config('ldap.hosted.root_dn'); $mgmtRootDN = \config('ldap.admin.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + $domainBaseDN = self::baseDN($domain->namespace); $aci = [ '(targetattr = "*")' @@ -101,14 +99,12 @@ self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } // create ou, roles, ous @@ -153,62 +149,56 @@ ); if (!$ldap->get_entry($domainBaseDN)) { - $result = $ldap->add_entry($domainBaseDN, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $domainBaseDN, + $entry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { - if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { - $result = $ldap->add_entry( - "ou={$item},{$domainBaseDN}", - [ - 'ou' => $item, - 'description' => $item, - 'objectclass' => [ - 'top', - 'organizationalunit' - ] + $itemDN = self::baseDN($domain->namespace, $item); + if (!$ldap->get_entry($itemDN)) { + $itemEntry = [ + 'ou' => $item, + 'description' => $item, + 'objectclass' => [ + 'top', + 'organizationalunit' ] - ); + ]; - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $itemDN, + $itemEntry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } } foreach (['kolab-admin'] as $item) { - if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { - $result = $ldap->add_entry( - "cn={$item},{$domainBaseDN}", - [ - 'cn' => $item, - 'description' => "{$item} role", - 'objectclass' => [ - 'top', - 'ldapsubentry', - 'nsmanagedroledefinition', - 'nsroledefinition', - 'nssimpleroledefinition' - ] + $itemDN = "cn={$item},{$domainBaseDN}"; + if (!$ldap->get_entry($itemDN)) { + $itemEntry = [ + 'cn' => $item, + 'description' => "{$item} role", + 'objectclass' => [ + 'top', + 'ldapsubentry', + 'nsmanagedroledefinition', + 'nsroledefinition', + 'nssimpleroledefinition' ] - ); + ]; - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $itemDN, + $itemEntry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } } @@ -231,46 +221,27 @@ $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - list($cn, $domainName) = explode('@', $group->email); - - $domain = $group->domain(); - - if (empty($domain)) { - self::throwException( - $ldap, - "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" - ); - } - - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - $groupBaseDN = "ou=Groups,{$domainBaseDN}"; - - $dn = "cn={$cn},{$groupBaseDN}"; + $domainName = explode('@', $group->email, 2)[1]; + $cn = $ldap->quote_string($group->name); + $dn = "cn={$cn}," . self::baseDN($domainName, 'Groups'); $entry = [ - 'cn' => $cn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], - 'uniquemember' => [] ]; self::setGroupAttributes($ldap, $group, $entry); - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" + ); if (empty(self::$ldap)) { $ldap->close(); @@ -325,14 +296,12 @@ self::setUserAttributes($user, $entry); - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" + ); } if (empty(self::$ldap)) { @@ -352,10 +321,7 @@ $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hostedRootDN = \config('ldap.hosted.root_dn'); - $mgmtRootDN = \config('ldap.admin.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + $domainBaseDN = self::baseDN($domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); @@ -568,41 +534,18 @@ $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - list($cn, $domainName) = explode('@', $group->email); - - $domain = $group->domain(); + $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); - if (empty($domain)) { + if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - $groupBaseDN = "ou=Groups,{$domainBaseDN}"; - - $dn = "cn={$cn},{$groupBaseDN}"; - - $entry = [ - 'cn' => $cn, - 'mail' => $group->email, - 'objectclass' => [ - 'top', - 'groupofuniquenames', - 'kolabgroupofuniquenames' - ], - 'uniquemember' => [] - ]; - - $oldEntry = $ldap->get_entry($dn); - - self::setGroupAttributes($ldap, $group, $entry); + self::setGroupAttributes($ldap, $group, $newEntry); - $result = $ldap->modify_entry($dn, $oldEntry, $entry); + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( @@ -712,15 +655,13 @@ $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); + $entry['cn'] = $group->name; + $entry['uniquemember'] = []; + $groupDomain = explode('@', $group->email, 2)[1]; + $domainBaseDN = self::baseDN($groupDomain); $validMembers = []; - $domain = $group->domain(); - - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); @@ -728,7 +669,7 @@ $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it - if ($domainName == $domain->namespace && !$memberEntry) { + if ($domainName == $groupDomain && !$memberEntry) { continue; } @@ -755,10 +696,12 @@ // Update members in sql (some might have been removed), // skip model events to not invoke another update job - $group->members = $validMembers; - Group::withoutEvents(function () use ($group) { - $group->save(); - }); + if ($group->members !== $validMembers) { + $group->members = $validMembers; + Group::withoutEvents(function () use ($group) { + $group->save(); + }); + } } /** @@ -868,20 +811,26 @@ */ private static function getGroupEntry($ldap, $email, &$dn = null) { - list($_local, $_domain) = explode('@', $email, 2); + $domainName = explode('@', $email, 2)[1]; + $base_dn = self::baseDN($domainName, 'Groups'); - $domain = $ldap->find_domain($_domain); + $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; - if (!$domain) { - return $domain; - } + // For groups we're using search() instead of get_entry() because + // a group name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + $result = $ldap->search($base_dn, "(mail=$email)", "sub", $attrs); - $base_dn = $ldap->domain_root_dn($_domain); - $dn = "cn={$_local},ou=Groups,{$base_dn}"; + if ($result && $result->count() == 1) { + $entries = $result->entries(true); + $dn = key($entries); + $entry = $entries[$dn]; + $entry['dn'] = $dn; - $entry = $ldap->get_entry($dn); + return $entry; + } - return $entry ?: null; + return null; } /** @@ -892,20 +841,13 @@ * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * - * @return false|null|array User entry, False on error, NULL if not found + * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { - list($_local, $_domain) = explode('@', $email, 2); - - $domain = $ldap->find_domain($_domain); + $domainName = explode('@', $email, 2)[1]; - if (!$domain) { - return $domain; - } - - $base_dn = $ldap->domain_root_dn($_domain); - $dn = "uid={$email},ou=People,{$base_dn}"; + $dn = "uid={$email}," . self::baseDN($domainName, 'People'); $entry = $ldap->get_entry($dn); @@ -976,6 +918,39 @@ } /** + * A wrapper for Net_LDAP3::add_entry() with error handler + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $dn Entry DN + * @param array $entry Entry attributes + * @param ?string $errorMsg A message to throw as an exception on error + * + * @throws \Exception + */ + private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) + { + // try/catch because Laravel converts warnings into exceptions + // and we want more human-friendly error message than that + try { + $result = $ldap->add_entry($dn, $entry); + } catch (\Exception $e) { + $result = false; + } + + if (!$result) { + if (!$errorMsg) { + $errorMsg = "LDAP Error (" . __LINE__ . ")"; + } + + if (isset($e)) { + $errorMsg .= ": " . $e->getMessage(); + } + + self::throwException($ldap, $errorMsg); + } + } + + /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection @@ -991,4 +966,25 @@ throw new \Exception($message); } + + /** + * Create a base DN string for specified object + * + * @param string $domainName Domain namespace + * @param ?string $ouName Optional name of the sub-tree (OU) + * + * @return string Full base DN + */ + private static function baseDN(string $domainName, string $ouName = null): string + { + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $dn = "ou={$domainName},{$hostedRootDN}"; + + if ($ouName) { + $dn = "ou={$ouName},{$dn}"; + } + + return $dn; + } } diff --git a/src/app/Console/Commands/GroupsCommand.php b/src/app/Console/Commands/GroupsCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/GroupsCommand.php @@ -0,0 +1,12 @@ +sortBy('namespace')->values(); + $result = $result->sortBy('name')->values(); } } elseif (!empty($search)) { if ($group = Group::where('email', $search)->first()) { @@ -41,6 +41,7 @@ $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Domain; use App\Group; +use App\Rules\GroupName; use App\User; use Carbon\Carbon; use Illuminate\Http\Request; @@ -73,11 +74,12 @@ { $user = $this->guard()->user(); - $result = $user->groups()->orderBy('email')->get() + $result = $user->groups()->orderBy('name')->orderBy('email')->get() ->map(function (Group $group) { $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); @@ -284,13 +286,26 @@ return $this->errorResponse(403); } - $email = request()->input('email'); - $members = request()->input('members'); + $email = $request->input('email'); + $members = $request->input('members'); $errors = []; + $rules = [ + 'name' => 'required|string|max:191', + ]; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; + } else { + list(, $domainName) = explode('@', $email); + $rules['name'] = ['required', 'string', new GroupName($owner, $domainName)]; + } + + // Validate the group name + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = array_merge($errors, $v->errors()->toArray()); } // Validate members' email addresses @@ -318,6 +333,7 @@ // Create the group $group = new Group(); + $group->name = $request->input('name'); $group->email = $email; $group->members = $members; $group->save(); @@ -355,11 +371,24 @@ } $owner = $group->wallet()->owner; - - // It is possible to update members property only for now - $members = request()->input('members'); + $name = $request->input('name'); + $members = $request->input('members'); $errors = []; + // Validate the group name + if ($name !== null && $name != $group->name) { + list(, $domainName) = explode('@', $group->email); + $rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = array_merge($errors, $v->errors()->toArray()); + } else { + $group->name = $name; + } + } + // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php --- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php @@ -27,7 +27,7 @@ }); } - $result = $result->sortBy('namespace')->values(); + $result = $result->sortBy('name')->values(); } } elseif (!empty($search)) { if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) { @@ -40,6 +40,7 @@ $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -17,6 +17,10 @@ public function creating(Group $group): void { $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + if (!isset($group->name) && isset($group->email)) { + $group->name = explode('@', $group->email)[0]; + } } /** diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/GroupName.php new file mode 100644 --- /dev/null +++ b/src/app/Rules/GroupName.php @@ -0,0 +1,72 @@ +owner = $owner; + $this->domain = Str::lower($domain); + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute Attribute name + * @param mixed $name The value to validate + * + * @return bool + */ + public function passes($attribute, $name): bool + { + if (empty($name) || !is_string($name)) { + $this->message = \trans('validation.nameinvalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($name) > 191) { + $this->message = \trans('validation.nametoolong'); + return false; + } + + // Check if the name is unique in the domain + // FIXME: Maybe just using the whole groups table would be faster than groups()? + $exists = $this->owner->groups() + ->where('groups.name', $name) + ->where('groups.email', 'like', '%@' . $this->domain) + ->exists(); + + if ($exists) { + $this->message = \trans('validation.nameexists'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/config/ldap.php b/src/config/ldap.php --- a/src/config/ldap.php +++ b/src/config/ldap.php @@ -21,10 +21,7 @@ // probably proxy credentials? ], - 'user_base_dn' => env('LDAP_USER_BASE_DN', null), - 'base_dn' => env('LDAP_BASE_DN', null), 'root_dn' => env('LDAP_ROOT_DN', null), - 'unique_attribute' => env('LDAP_UNIQUE_ATTRIBUTE', 'nsuniqueid'), 'service_bind_dn' => env('LDAP_SERVICE_BIND_DN', null), 'service_bind_pw' => env('LDAP_SERVICE_BIND_PW', null), 'login_filter' => env('LDAP_LOGIN_FILTER', '(&(objectclass=kolabinetorgperson)(uid=%s))'), diff --git a/src/database/migrations/2021_11_10_100000_add_group_name_column.php b/src/database/migrations/2021_11_10_100000_add_group_name_column.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_11_10_100000_add_group_name_column.php @@ -0,0 +1,52 @@ +string('name')->nullable()->after('email'); + } + ); + + // Fill the name with the local part of the email address + DB::table('groups')->update([ + 'name' => DB::raw("SUBSTRING_INDEX(`email`, '@', 1)") + ]); + + Schema::table( + 'groups', + function (Blueprint $table) { + $table->string('name')->nullable(false)->change(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'groups', + function (Blueprint $table) { + $table->dropColumn('name'); + } + ); + } +} diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -56,6 +56,7 @@ 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", + 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -141,6 +141,9 @@ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', + 'nameexists' => 'The specified name is not available.', + 'nameinvalid' => 'The specified name is invalid.', + 'nametoolong' => 'The specified name is too long.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -22,6 +22,12 @@
{{ $t('distlist.name') }} | {{ $t('form.email') }} | |
---|---|---|
|
+
|
|
{{ $t('user.distlists-none') }} | +{{ $t('user.distlists-none') }} |