diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 72985758..5afbaf7e 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,585 +1,673 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * - * @return void + * @throws \Exception */ - public static function createDomain(Domain $domain) + public static function createDomain(Domain $domain): void { $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}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; + self::setDomainAttributes($domain, $entry); + if (!$ldap->get_entry($dn)) { - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create a domain in LDAP"); + } } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { $ldap->add_entry($domainBaseDN, $entry); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { $ldap->add_entry( "ou={$item},{$domainBaseDN}", [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ] ); } } - foreach (['kolab-admin', 'billing-user'] as $item) { + foreach (['kolab-admin'] as $item) { if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { $ldap->add_entry( "cn={$item},{$domainBaseDN}", [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ] ); } } + // TODO: Assign kolab-admin role to the owner? + if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * - * @return bool|void + * @throws \Exception */ - public static function createUser(User $user) + public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn) && $dn) { self::setUserAttributes($user, $entry); - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create a user in LDAP"); + } } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Update a domain in LDAP. + * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to update. * - * @return void + * @throws \Exception */ - public static function updateDomain(Domain $domain) + public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $ldapDomain = $ldap->find_domain($domain->namespace); + $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('ldap.admin.root_dn'); - $oldEntry = $ldap->get_entry($ldapDomain['dn']); - $newEntry = $oldEntry; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - self::setDomainAttributes($domain, $newEntry); + if ($ldap->get_entry($domainBaseDN)) { + $result = $ldap->delete_entry_recursive($domainBaseDN); + + if (!$result) { + self::throwException($ldap, "Failed to delete a domain from LDAP"); + } + } + + if ($ldap_domain = $ldap->find_domain($domain->namespace)) { + if ($ldap->get_entry($ldap_domain['dn'])) { + $result = $ldap->delete_entry($ldap_domain['dn']); - $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + if (!$result) { + self::throwException($ldap, "Failed to delete a domain from LDAP"); + } + } + } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Delete a domain from LDAP. + * Delete a user from LDAP. * - * @param \App\Domain $domain The domain to update. + * @param \App\User $user The user account to update. * - * @return void + * @throws \Exception */ - public static function deleteDomain(Domain $domain) + public static function deleteUser(User $user): void { $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}"; - - if ($ldap->get_entry($domainBaseDN)) { - $ldap->delete_entry_recursive($domainBaseDN); - } + if (self::getUserEntry($ldap, $user->email, $dn)) { + $result = $ldap->delete_entry($dn); - if ($ldap_domain = $ldap->find_domain($domain->namespace)) { - if ($ldap->get_entry($ldap_domain['dn'])) { - $ldap->delete_entry($ldap_domain['dn']); + if (!$result) { + self::throwException($ldap, "Failed to delete a user from LDAP"); } } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Delete a user from LDAP. + * Get a domain data from LDAP. * - * @param \App\User $user The user account to update. + * @param string $namespace The domain name * - * @return void + * @return array|false|null + * @throws \Exception */ - public static function deleteUser(User $user) + public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - if (self::getUserEntry($ldap, $user->email, $dn)) { - $ldap->delete_entry($dn); + $ldapDomain = $ldap->find_domain($namespace); + + if ($ldapDomain) { + $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } + + return $domain ?? null; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null + * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } + /** + * Update a domain in LDAP. + * + * @param \App\Domain $domain The domain to update. + * + * @throws \Exception + */ + public static function updateDomain(Domain $domain): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $ldapDomain = $ldap->find_domain($domain->namespace); + + if (!$ldapDomain) { + self::throwException($ldap, "Failed to update a domain in LDAP (domain not found)"); + } + + $oldEntry = $ldap->get_entry($ldapDomain['dn']); + $newEntry = $oldEntry; + + self::setDomainAttributes($domain, $newEntry); + + $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException($ldap, "Failed to update a domain in LDAP"); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * - * @return false|void + * @throws \Exception */ - public static function updateUser(User $user) + public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { - return false; + self::throwException($ldap, "Failed to update a user in LDAP (user not found)"); } self::setUserAttributes($user, $newEntry); - $ldap->modify_entry($dn, $oldEntry, $newEntry); + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException($ldap, "Failed to update a user in LDAP"); + } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); - $ldap->connect(); + $connected = $ldap->connect(); + + if (!$connected) { + throw new \Exception("Failed to connect to LDAP"); + } - $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); + $bound = $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); - // TODO: error handling + if (!$bound) { + throw new \Exception("Failed to bind to LDAP"); + } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $firstName = $user->getSetting('first_name'); $lastName = $user->getSetting('last_name'); $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $user->getSetting('organization'); $entry['mailquota'] = 0; $entry['alias'] = $user->aliases->pluck('alias')->toArray(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); if (empty($roles)) { if (array_key_exists('nsroledn', $entry)) { unset($entry['nsroledn']); } return; } $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } if (empty($entry['nsroledn'])) { unset($entry['nsroledn']); } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @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 */ - protected static function getUserEntry($ldap, $email, &$dn = null, $full = false) + private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { list($_local, $_domain) = explode('@', $email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { - return false; + return $domain; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$email},ou=People,{$base_dn}"; $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } + + /** + * Throw exception and close the connection when needed + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $message Exception message + * + * @throws \Exception + */ + private static function throwException($ldap, string $message): void + { + if (empty(self::$ldap) && !empty($ldap)) { + $ldap->close(); + } + + throw new \Exception($message); + } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 2f2990ac..eb64bbea 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,153 +1,258 @@ ldap_config = [ + 'ldap.hosts' => \config('ldap.hosts'), + ]; + $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); + $this->deleteTestDomain('testldap.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + \config($this->ldap_config); + $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); + $this->deleteTestDomain('testldap.com'); parent::tearDown(); } + /** + * Test handling connection errors + * + * @group ldap + */ + public function testConnectException(): void + { + \config(['ldap.hosts' => 'non-existing.host']); + + $this->expectException(\Exception::class); + + LDAP::connect(); + } + /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { - $this->markTestIncomplete(); + Queue::fake(); + + $domain = $this->getTestDomain('testldap.com', [ + 'type' => Domain::TYPE_EXTERNAL, + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + ]); + + // Create the domain + LDAP::createDomain($domain); + + $ldap_domain = LDAP::getDomain($domain->namespace); + + $expected = [ + 'associateddomain' => $domain->namespace, + 'inetdomainstatus' => $domain->status, + 'objectclass' => [ + 'top', + 'domainrelatedobject', + 'inetdomain' + ], + ]; + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); + } + + // TODO: Test other attributes, aci, roles/ous + + // Update the domain + $domain->status |= User::STATUS_LDAP_READY; + + LDAP::updateDomain($domain); + + $expected['inetdomainstatus'] = $domain->status; + + $ldap_domain = LDAP::getDomain($domain->namespace); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); + } + + // Delete the domain + LDAP::deleteDomain($domain); + + $this->assertSame(null, LDAP::getDomain($domain->namespace)); } /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => null, 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 2097152; $expected['nsroledn'] = null; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::where('title', 'activesync')->first(); $sku_groupware = \App\Sku::where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } + + /** + * Test handling update of a non-existing domain + * + * @group ldap + */ + public function testUpdateDomainException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/domain not found/'); + + $domain = new Domain([ + 'namespace' => 'testldap.com', + 'type' => Domain::TYPE_EXTERNAL, + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + ]); + + LDAP::updateDomain($domain); + } + + /** + * Test handling update of a non-existing user + * + * @group ldap + */ + public function testUpdateUserException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/user not found/'); + + $user = new User([ + 'email' => 'test-non-existing-ldap@kolab.org', + 'status' => User::STATUS_ACTIVE, + ]); + + LDAP::updateUser($user); + } }