diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 959eca90..66131380 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,413 +1,444 @@ namespace},{$hosted_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,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)") ' - . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmt_root_dn . '";)', + . '(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,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)");)', + . '(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,' . $domain_base_dn - . ' || ldap:///cn=kolab-admin,' . $mgmt_root_dn . '");)' + . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN + . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, - 'inetdomainbasedn' => $domain_base_dn, + 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; if (!$ldap->get_entry($dn)) { $ldap->add_entry($dn, $entry); } // 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,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)") ' - . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmt_root_dn . '";)', + . '(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,' . $mgmt_root_dn - . ' || ldap:///ou=People,' . $domain_base_dn . '??sub?(objectclass=inetorgperson)");)', + . '(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,' . $domain_base_dn - . ' || ldap:///cn=kolab-admin,' . $mgmt_root_dn . '");)', + . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN + . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', - '(target = "ldap:///ou=*,' . $domain_base_dn . '")' + '(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,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', - '(target = "ldap:///uid=*,ou=People,' . $domain_base_dn . '")(targetattr="*")' + '(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,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', - '(target = "ldap:///cn=*,' . $domain_base_dn . '")(targetattr="objectclass || cn")' + '(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,' . $mgmt_root_dn . '");)', + . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); - if (!$ldap->get_entry($domain_base_dn)) { - $ldap->add_entry($domain_base_dn, $entry); + if (!$ldap->get_entry($domainBaseDN)) { + $ldap->add_entry($domainBaseDN, $entry); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { - if (!$ldap->get_entry("ou={$item},{$domain_base_dn}")) { + if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { $ldap->add_entry( - "ou={$item},{$domain_base_dn}", + "ou={$item},{$domainBaseDN}", [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ] ); } } foreach (['kolab-admin', 'imap-user', 'activesync-user', 'billing-user'] as $item) { - if (!$ldap->get_entry("cn={$item},{$domain_base_dn}")) { + if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { $ldap->add_entry( - "cn={$item},{$domain_base_dn}", + "cn={$item},{$domainBaseDN}", [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ] ); } } $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 */ public static function createUser(User $user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { return false; } $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, ]; self::setUserAttributes($user, $entry); $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; if (!$ldap->get_entry($dn)) { $ldap->add_entry($dn, $entry); } $ldap->close(); } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @return void */ public static function updateDomain($domain) { - // + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $ldapDomain = $ldap->find_domain($domain->namespace); + + $oldEntry = $ldap->get_entry($ldapDomain['dn']); + $newEntry = $oldEntry; + + self::setDomainAttributes($domain, $newEntry); + + $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + + $ldap->close(); } public static function deleteDomain($domain) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hosted_root_dn = \config('ldap.hosted.root_dn'); - $mgmt_root_dn = \config('ldap.admin.root_dn'); + $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('ldap.admin.root_dn'); - $domain_base_dn = "ou={$domain->namespace},{$hosted_root_dn}"; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - if ($ldap->get_entry($domain_base_dn)) { - $ldap->delete_entry_recursive($domain_base_dn); + if ($ldap->get_entry($domainBaseDN)) { + $ldap->delete_entry_recursive($domainBaseDN); } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $ldap->delete_entry($ldap_domain['dn']); } } + + $ldap->close(); } public static function deleteUser($user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { + $ldap->close(); return false; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; if (!$ldap->get_entry($dn)) { + $ldap->close(); return false; } $ldap->delete_entry($dn); + + $ldap->close(); } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @return bool|void */ public static function updateUser(User $user) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); list($_local, $_domain) = explode('@', $user->email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { + $ldap->close(); return false; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$user->email},ou=People,{$base_dn}"; - $old_entry = $ldap->get_entry($dn); - $new_entry = $old_entry; + $oldEntry = $ldap->get_entry($dn); + $newEntry = $old_entry; - self::setUserAttributes($user, $new_entry); + self::setUserAttributes($user, $newEntry); - $ldap->modify_entry($dn, $old_entry, $new_entry); + $ldap->modify_entry($dn, $oldEntry, $newEntry); $ldap->close(); } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { $ldap = new \Net_LDAP3($config); $ldap->connect(); $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); // TODO: error handling 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; } /** * 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; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( - ($level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE) + ( + $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); } } diff --git a/src/app/Console/Commands/DomainCheck.php b/src/app/Console/Commands/DomainCheck.php new file mode 100644 index 00000000..e5345f18 --- /dev/null +++ b/src/app/Console/Commands/DomainCheck.php @@ -0,0 +1,42 @@ +each( + function ($domain) { + $this->info($domain->namespace); + } + ); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php new file mode 100644 index 00000000..eba31be3 --- /dev/null +++ b/src/app/Console/Commands/DomainStatus.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain: {$domain->id}"); + + $this->info($domain->status); + } +} diff --git a/src/app/Console/Commands/DomainSuspend.php b/src/app/Console/Commands/DomainSuspend.php new file mode 100644 index 00000000..681dd20e --- /dev/null +++ b/src/app/Console/Commands/DomainSuspend.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain: {$domain->id}"); + + $domain->suspend(); + } +} diff --git a/src/app/Console/Commands/DomainUnsuspend.php b/src/app/Console/Commands/DomainUnsuspend.php new file mode 100644 index 00000000..ba92a818 --- /dev/null +++ b/src/app/Console/Commands/DomainUnsuspend.php @@ -0,0 +1,51 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $this->info("Found domain {$domain->id}"); + + $domain->unsuspend(); + } +} diff --git a/src/app/Domain.php b/src/app/Domain.php index 7978eccf..dd78b850 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,348 +1,378 @@ wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } + /** + * Suspend this domain. + * + * @return void + */ + public function suspend(): void + { + if ($this->isSuspended()) { + return; + } + + $this->status |= Domain::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Unsuspend this domain. + * + * @return void + */ + public function unsuspend(): void + { + if (!$this->isSuspended()) { + return; + } + + $this->status ^= Domain::STATUS_SUSPENDED; + $this->save(); + } + /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $record = \dns_get_record($this->namespace, DNS_ANY); if ($record === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } if (!empty($record)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { return $this->entitlement()->first()->wallet; } } diff --git a/src/app/Jobs/DomainUpdate.php b/src/app/Jobs/DomainUpdate.php new file mode 100644 index 00000000..59df0914 --- /dev/null +++ b/src/app/Jobs/DomainUpdate.php @@ -0,0 +1,44 @@ +domain_id = $domain_id; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $domain = \App\Domain::find($this->domain_id); + + LDAP::updateDomain($domain); + } +} diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 29c54ec0..af9cc23c 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,107 +1,106 @@ {$domain->getKeyName()} = $allegedly_unique; break; } } $domain->status |= Domain::STATUS_NEW | Domain::STATUS_ACTIVE; } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP, then check if it exists in DNS \App\Jobs\DomainCreate::dispatch($domain); } /** * Handle the domain "deleting" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleting(Domain $domain) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $domain->id) ->where('entitleable_type', Domain::class) ->delete(); } /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleted(Domain $domain) { \App\Jobs\DomainDelete::dispatch($domain->id); } /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { - // + \App\Jobs\DomainUpdate::dispatch($domain->id); } - /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // } /** * Handle the domain "force deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function forceDeleted(Domain $domain) { // } }