diff --git a/src/app/Backends/Helper.php b/src/app/Backends/Helper.php index d850b8d0..743f9d13 100644 --- a/src/app/Backends/Helper.php +++ b/src/app/Backends/Helper.php @@ -1,55 +1,116 @@ 'Default', 'displayname' => 'Calendar', 'components' => ['VEVENT'], 'type' => 'calendar', ], [ 'path' => 'Tasks', 'displayname' => 'Tasks', 'components' => ['VTODO'], 'type' => 'calendar', ], [ // FIXME: Same here, should we use 'Contacts'? 'path' => 'Default', 'displayname' => 'Contacts', 'type' => 'addressbook', ], ]; } /** * List of default IMAP folders */ public static function defaultImapFolders(): array { - // TODO: Move the list from config/imap.php - return []; + $folders = [ + 'Drafts' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'mail.drafts', + '/private/specialuse' => '\Drafts', + ], + ], + 'Sent' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'mail.sentitems', + '/private/specialuse' => '\Sent', + ], + ], + 'Trash' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'mail.wastebasket', + '/private/specialuse' => '\Trash', + ], + ], + 'Spam' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'mail.junkemail', + '/private/specialuse' => '\Junk', + ], + ], + ]; + + if (\env('IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS', true)) { + $folders = array_merge($folders, [ + 'Calendar' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'event.default', + '/shared/vendor/kolab/folder-type' => 'event', + ], + ], + 'Contacts' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'contact.default', + '/shared/vendor/kolab/folder-type' => 'event', + ], + ], + 'Tasks' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'task.default', + '/shared/vendor/kolab/folder-type' => 'task', + ], + ], + 'Notes' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'note.default', + '/shared/vendor/kolab/folder-type' => 'note', + ], + ], + 'Files' => [ + 'metadata' => [ + '/private/vendor/kolab/folder-type' => 'file.default', + '/shared/vendor/kolab/folder-type' => 'file', + ], + ], + ]); + } + + return $folders; } } diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php index fd0b56d9..74eaf1f8 100644 --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -1,785 +1,785 @@ 'lrs', 'read-write' => 'lrswitedn', 'full' => 'lrswipkxtecdn', ]; /** * Delete a group. * * @param \App\Group $group Group * * @return bool True if a group was deleted successfully, False otherwise * @throws \Exception */ public static function deleteGroup(Group $group): bool { $domainName = explode('@', $group->email, 2)[1]; // Cleanup ACL // FIXME: Since all groups in Kolab4 have email address, // should we consider using it in ACL instead of the name? // Also we need to decide what to do and configure IMAP appropriately, // right now groups in ACL does not work for me at all. // Commented out in favor of a nightly cleanup job, for performance reasons // \App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName); return true; } /** * Create a mailbox. * * @param \App\User $user User * * @return bool True if a mailbox was created successfully, False otherwise * @throws \Exception */ public static function createUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); // Mailbox already exists if (self::folderExists($imap, $mailbox)) { $imap->closeConnection(); self::createDefaultFolders($user); return true; } // Create the mailbox if (!$imap->createFolder($mailbox)) { \Log::error("Failed to create mailbox {$mailbox}"); $imap->closeConnection(); return false; } // Wait until it's propagated (for Cyrus Murder setup) // FIXME: Do we still need this? if (strpos($imap->conn->data['GREETING'] ?? '', 'Cyrus IMAP Murder') !== false) { $tries = 30; while ($tries-- > 0) { $folders = $imap->listMailboxes('', $mailbox); if (is_array($folders) && count($folders)) { break; } sleep(1); $imap->closeConnection(); $imap = self::initIMAP($config); } } // Set quota $quota = $user->countEntitlementsBySku('storage') * 1048576; if ($quota) { $imap->setQuota($mailbox, ['storage' => $quota]); } self::createDefaultFolders($user); $imap->closeConnection(); return true; } /** * Create default folders for the user. * * @param \App\User $user User */ public static function createDefaultFolders(User $user): void { - if ($defaultFolders = \config('imap.default_folders')) { + if ($defaultFolders = \config('services.imap.default_folders')) { $config = self::getConfig(); // Log in as user to set private annotations and subscription state $imap = self::initIMAP($config, $user->email); foreach ($defaultFolders as $name => $folderconfig) { try { $mailbox = self::toUTF7($name); self::createFolder($imap, $mailbox, true, $folderconfig['metadata']); } catch (\Exception $e) { \Log::warning("Failed to create the default folder. " . $e->getMessage()); } } $imap->closeConnection(); } } /** * Delete a mailbox. * * @param \App\User $user User * * @return bool True if a mailbox was deleted successfully, False otherwise * @throws \Exception */ public static function deleteUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox (no need to delete subfolders?) $result = $imap->deleteFolder($mailbox); if (!$result) { // Ignore the error if the folder doesn't exist (maybe it was removed already). if (!self::folderExists($imap, $mailbox)) { \Log::info("The mailbox to delete was already removed: $mailbox"); $result = true; } } $imap->closeConnection(); // Cleanup ACL // Commented out in favor of a nightly cleanup job, for performance reasons // \App\Jobs\IMAP\AclCleanupJob::dispatch($user->email); return $result; } /** * Update a mailbox (quota). * * @param \App\User $user User * * @return bool True if a mailbox was updated successfully, False otherwise * @throws \Exception */ public static function updateUser(User $user): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $user->email); $result = true; // Set quota $quota = $user->countEntitlementsBySku('storage') * 1048576; if ($quota) { $result = $imap->setQuota($mailbox, ['storage' => $quota]); } $imap->closeConnection(); return $result; } /** * Create a resource. * * @param \App\Resource $resource Resource * * @return bool True if a resource was created successfully, False otherwise * @throws \Exception */ public static function createResource(Resource $resource): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['invitation_policy', 'folder']); $mailbox = self::toUTF7($settings['folder']); $metadata = ['/shared/vendor/kolab/folder-type' => 'event']; $acl = []; if (!empty($settings['invitation_policy'])) { if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { $acl = ["{$m[1]}, full"]; } } self::createFolder($imap, $mailbox, false, $metadata, Utils::ensureAclPostPermission($acl)); $imap->closeConnection(); return true; } /** * Update a resource. * * @param \App\Resource $resource Resource * @param array $props Old resource properties * * @return bool True if a resource was updated successfully, False otherwise * @throws \Exception */ public static function updateResource(Resource $resource, array $props = []): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['invitation_policy', 'folder']); $folder = $settings['folder']; $mailbox = self::toUTF7($folder); // Rename the mailbox (only possible if we have the old folder) if (!empty($props['folder']) && $props['folder'] != $folder) { $oldMailbox = self::toUTF7($props['folder']); if (!$imap->renameFolder($oldMailbox, $mailbox)) { \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); $imap->closeConnection(); return false; } } // ACL $acl = []; if (!empty($settings['invitation_policy'])) { if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { $acl = ["{$m[1]}, full"]; } } self::aclUpdate($imap, $mailbox, Utils::ensureAclPostPermission($acl)); $imap->closeConnection(); return true; } /** * Delete a resource. * * @param \App\Resource $resource Resource * * @return bool True if a resource was deleted successfully, False otherwise * @throws \Exception */ public static function deleteResource(Resource $resource): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $resource->getSettings(['folder']); $mailbox = self::toUTF7($settings['folder']); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox (no need to delete subfolders?) $result = $imap->deleteFolder($mailbox); $imap->closeConnection(); return $result; } /** * Create a shared folder. * * @param \App\SharedFolder $folder Shared folder * * @return bool True if a falder was created successfully, False otherwise * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['acl', 'folder']); $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : []; $mailbox = self::toUTF7($settings['folder']); $metadata = ['/shared/vendor/kolab/folder-type' => $folder->type]; self::createFolder($imap, $mailbox, false, $metadata, Utils::ensureAclPostPermission($acl)); $imap->closeConnection(); return true; } /** * Update a shared folder. * * @param \App\SharedFolder $folder Shared folder * @param array $props Old folder properties * * @return bool True if a falder was updated successfully, False otherwise * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder, array $props = []): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['acl', 'folder']); $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : []; $folder = $settings['folder']; $mailbox = self::toUTF7($folder); // Rename the mailbox if (!empty($props['folder']) && $props['folder'] != $folder) { $oldMailbox = self::toUTF7($props['folder']); if (!$imap->renameFolder($oldMailbox, $mailbox)) { \Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}"); $imap->closeConnection(); return false; } } // Note: Shared folder type does not change // ACL self::aclUpdate($imap, $mailbox, Utils::ensureAclPostPermission($acl)); $imap->closeConnection(); return true; } /** * Delete a shared folder. * * @param \App\SharedFolder $folder Shared folder * * @return bool True if a falder was deleted successfully, False otherwise * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $settings = $folder->getSettings(['folder']); $mailbox = self::toUTF7($settings['folder']); // To delete the mailbox cyrus-admin needs extra permissions $imap->setACL($mailbox, $config['user'], 'c'); // Delete the mailbox $result = $imap->deleteFolder($mailbox); $imap->closeConnection(); return $result; } /** * Check if a shared folder is set up. * * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld * * @return bool True if a folder exists and is set up, False otherwise */ public static function verifySharedFolder(string $folder): bool { $config = self::getConfig(); $imap = self::initIMAP($config); // Convert the folder from UTF8 to UTF7-IMAP if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) { $folderName = self::toUTF7($matches[2]); $folder = $matches[1] . $folderName . $matches[3]; } // FIXME: just listMailboxes() does not return shared folders at all $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); $imap->closeConnection(); // Note: We have to use error code to distinguish an error from "no mailbox" response if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { return false; } if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { throw new \Exception("Failed to get folder metadata from IMAP"); } return true; } /** * Convert UTF8 string to UTF7-IMAP encoding */ public static function toUTF7(string $string): string { return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8'); } /** * Check if an account is set up * * @param string $username User login (email address) * * @return bool True if an account exists and is set up, False otherwise */ public static function verifyAccount(string $username): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $mailbox = self::toUTF7('user/' . $username); // Mailbox already exists if (self::folderExists($imap, $mailbox)) { $imap->closeConnection(); return true; } $imap->closeConnection(); return false; } /** * Check if an account is set up * * @param string $username User login (email address) * * @return bool True if an account exists and is set up, False otherwise */ public static function verifyDefaultFolders(string $username): bool { $config = self::getConfig(); $imap = self::initIMAP($config, $username); - foreach (\config('imap.default_folders') as $mb => $_metadata) { + foreach (\config('services.imap.default_folders') as $mb => $_metadata) { $mailbox = self::toUTF7($mb); if (!self::folderExists($imap, $mailbox)) { $imap->closeConnection(); return false; } } $imap->closeConnection(); return true; } /** * Check if we can connect to the imap server * * @return bool True on success */ public static function healthcheck(): bool { $config = self::getConfig(); $imap = self::initIMAP($config); $imap->closeConnection(); return true; } /** * Remove ACL for a specified user/group anywhere in the IMAP * * @param string $ident ACL identifier (user email or e.g. group name) * @param string $domain ACL domain */ public static function aclCleanup(string $ident, string $domain = ''): void { $config = self::getConfig(); $imap = self::initIMAP($config); if (strpos($ident, '@')) { $domain = explode('@', $ident, 2)[1]; } $callback = function ($folder) use ($imap, $ident) { $acl = $imap->getACL($folder); if (is_array($acl) && isset($acl[$ident])) { \Log::info("Cleanup: Removing {$ident} from ACL on {$folder}"); $imap->deleteACL($folder, $ident); } }; $folders = $imap->listMailboxes('', "user/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $folders = $imap->listMailboxes('', "shared/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $imap->closeConnection(); } /** * Remove ACL entries pointing to non-existent users/groups, for a specified domain * * @param string $domain Domain namespace * @param bool $dry_run Output ACL entries to delete, but do not delete */ public static function aclCleanupDomain(string $domain, bool $dry_run = false): void { $config = self::getConfig(); $imap = self::initIMAP($config); // Collect available (existing) users/groups // FIXME: Should we limit this to the requested domain or account? // FIXME: For groups should we use name or email? $idents = User::pluck('email') // ->concat(Group::pluck('name')) ->concat(['anyone', 'anonymous', $config['user']]) ->all(); $callback = function ($folder) use ($imap, $idents, $dry_run) { $acl = $imap->getACL($folder); if (is_array($acl)) { $owner = null; if (preg_match('|^user/([^/@]+).*@([^@/]+)$|', $folder, $m)) { $owner = $m[1] . '@' . $m[2]; } foreach (array_keys($acl) as $key) { if ($owner && $key === $owner) { // Don't even try to remove the folder's owner entry continue; } if (!in_array($key, $idents)) { if ($dry_run) { echo "{$folder} {$key} {$acl[$key]}\n"; } else { \Log::info("Cleanup: Removing {$key} from ACL on {$folder}"); $imap->deleteACL($folder, $key); } } } } }; $folders = $imap->listMailboxes('', "user/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $folders = $imap->listMailboxes('', "shared/*@{$domain}"); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } array_walk($folders, $callback); $imap->closeConnection(); } /** * Create a folder and set some default properties * * @param \rcube_imap_generic $imap The imap instance * @param string $mailbox Mailbox name * @param bool $subscribe Subscribe to the folder * @param array $metadata Metadata to set on the folder * @param array $acl Acl to set on the folder * * @return bool True when having a folder created, False if it already existed. * @throws \Exception */ private static function createFolder($imap, string $mailbox, $subscribe = false, $metadata = null, $acl = null) { if (self::folderExists($imap, $mailbox)) { return false; } if (!$imap->createFolder($mailbox)) { throw new \Exception("Failed to create mailbox {$mailbox}"); } if (!empty($acl)) { self::aclUpdate($imap, $mailbox, $acl, true); } if ($subscribe) { $imap->subscribe($mailbox); } foreach ($metadata as $key => $value) { $imap->setMetadata($mailbox, [$key => $value]); } return true; } /** * Convert Kolab ACL into IMAP user->rights array */ private static function aclToImap($acl): array { if (empty($acl)) { return []; } return \collect($acl) ->mapWithKeys(function ($item, $key) { list($user, $rights) = explode(',', $item, 2); $rights = trim($rights); return [trim($user) => self::ACL_MAP[$rights] ?? $rights]; }) ->all(); } /** * Update folder ACL */ private static function aclUpdate($imap, $mailbox, $acl, bool $isNew = false) { $imapAcl = $isNew ? [] : $imap->getACL($mailbox); if (is_array($imapAcl)) { foreach (self::aclToImap($acl) as $user => $rights) { if (empty($imapAcl[$user]) || implode('', $imapAcl[$user]) !== $rights) { $imap->setACL($mailbox, $user, $rights); } unset($imapAcl[$user]); } foreach ($imapAcl as $user => $rights) { $imap->deleteACL($mailbox, $user); } } } /** * Check if an IMAP folder exists */ private static function folderExists($imap, string $folder): bool { $folders = $imap->listMailboxes('', $folder); if (!is_array($folders)) { $imap->closeConnection(); throw new \Exception("Failed to get IMAP folders"); } return count($folders) > 0; } /** * Initialize connection to IMAP */ private static function initIMAP(array $config, string $login_as = null) { $imap = new \rcube_imap_generic(); if (\config('app.debug')) { $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); } if ($login_as) { $config['options']['auth_cid'] = $config['user']; $config['options']['auth_pw'] = $config['password']; $config['options']['auth_type'] = 'PLAIN'; $config['user'] = $login_as; } $imap->connect($config['host'], $config['user'], $config['password'], $config['options']); if (!$imap->connected()) { $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error); \Log::error($message); throw new \Exception("Connection to IMAP failed"); } return $imap; } /** * Get LDAP configuration for specified access level */ private static function getConfig() { - $uri = \parse_url(\config('imap.uri')); + $uri = \parse_url(\config('services.imap.uri')); $default_port = 143; $ssl_mode = null; if (isset($uri['scheme'])) { if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) { $default_port = 993; $ssl_mode = 'ssl'; } elseif ($uri['scheme'] === 'tls') { $ssl_mode = 'tls'; } } $config = [ 'host' => $uri['host'], - 'user' => \config('imap.admin_login'), - 'password' => \config('imap.admin_password'), + 'user' => \config('services.imap.admin_login'), + 'password' => \config('services.imap.admin_password'), 'options' => [ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port, 'ssl_mode' => $ssl_mode, 'socket_options' => [ 'ssl' => [ - 'verify_peer' => \config('imap.verify_peer'), - 'verify_peer_name' => \config('imap.verify_peer'), - 'verify_host' => \config('imap.verify_host') + 'verify_peer' => \config('services.imap.verify_peer'), + 'verify_peer_name' => \config('services.imap.verify_peer'), + 'verify_host' => \config('services.imap.verify_host') ], ], ], ]; return $config; } /** * Debug logging callback */ public static function logDebug($conn, $msg): void { $msg = '[IMAP] ' . $msg; \Log::debug($msg); } } diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index a0b89834..35ac8747 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1422 +1,1422 @@ close(); self::$ldap = null; } } /** * Validates that ldap is available as configured. * * @throws \Exception */ public static function healthcheck(): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $mgmtRootDN = \config('ldap.admin.root_dn'); - $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('services.ldap.admin.root_dn'); + $hostedRootDN = \config('services.ldap.hosted.root_dn'); $result = $ldap->search($mgmtRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured management domain $mgmtRootDN"); } $result = $ldap->search($hostedRootDN, '', 'base'); if (!$result || $result->count() != 1) { self::throwException($ldap, "Failed to find the configured hosted domain $hostedRootDN"); } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $mgmtRootDN = \config('ldap.admin.root_dn'); - $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('services.ldap.admin.root_dn'); + $hostedRootDN = \config('services.ldap.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,' . $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)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // 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)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; if (!self::getGroupEntry($ldap, $group->email)) { self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; if (!self::getResourceEntry($ldap, $resource->email)) { self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; if (!self::getSharedFolderEntry($ldap, $folder->email)) { self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } 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. * * @throws \Exception */ 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)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($ldap, $domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * 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 domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ 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) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } 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); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( - \config("ldap.{$privilege}.bind_dn"), - \config("ldap.{$privilege}.bind_pw") + \config("services.ldap.{$privilege}.bind_dn"), + \config("services.ldap.{$privilege}.bind_pw") ); 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; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); // Make sure the policy does not contain duplicates, they aren't allowed // by the ldap definition of kolabAllowSMTPSender attribute $sender_policy = json_decode($settings['sender_policy'] ?: '[]', true); $sender_policy = array_values(array_unique(array_map('strtolower', $sender_policy))); $entry['kolaballowsmtpsender'] = $sender_policy; $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; $group->saveQuietly(); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = []; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = [$m[1] . ', full']; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } $entry['acl'] = Utils::ensureAclPostPermission($entry['acl']); } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : []; $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = Utils::ensureAclPostPermission($acl); $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['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'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; foreach ($user->entitlements as $entitlement) { 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'); + $hostedRootDN = \config('services.ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } } /** * 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'), + 'domain_base_dn' => \config('services.ldap.domain_base_dn'), + 'domain_filter' => \config('services.ldap.domain_filter'), + 'domain_name_attribute' => \config('services.ldap.domain_name_attribute'), + 'hosts' => \config('services.ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // 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. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * 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 null|array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $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); } /** * 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); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * 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)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php index 6ed0e2e3..e13cc78f 100644 --- a/src/app/Backends/Roundcube.php +++ b/src/app/Backends/Roundcube.php @@ -1,282 +1,282 @@ table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->delete(); } /** * List all files from the Enigma filestore. * * @param string $email User email address * * @return array List of Enigma filestore records */ public static function enigmaList(string $email): array { return self::dbh()->table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->orderBy('filename') ->get() ->all(); } /** * Synchronize Enigma filestore from/to specified directory * * @param string $email User email address * @param string $homedir Directory location */ public static function enigmaSync(string $email, string $homedir): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $root = \config('filesystems.disks.pgp.root'); $fs = Storage::disk('pgp'); $files = []; $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get(); foreach ($result as $record) { $file = $homedir . '/' . $record->filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $files[] = $record->filename; if ($mtime < $record->mtime) { $file_id = $record->file_id; $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') ->where('file_id', $file_id) ->first(); $data = $record ? base64_decode($record->data) : false; if ($data === false) { \Log::error("Failed to sync $file ({$file_id}). Decode error."); continue; } if ($fs->put($file, $data, true)) { // Note: Laravel Filesystem API does not provide touch method touch("$root/$file", $record->mtime); if ($debug) { \Log::debug("[SYNC] Fetched file: $file"); } } } } // Remove files not in database foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) { $file = $homedir . '/' . $file; if ($fs->delete($file)) { if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } // No records found, do initial sync if already have the keyring if (empty($file)) { self::enigmaSave($email, $homedir); } } /** * Save the keys database * * @param string $email User email address * @param string $homedir Directory location * @param bool $is_empty Set to Tre if it is a initial save */ public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $fs = Storage::disk('pgp'); $records = []; if (!$is_empty) { $records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get() ->keyBy('filename') ->all(); } foreach (self::enigmaFilesList($homedir) as $filename) { $file = $homedir . '/' . $filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $existing = !empty($records[$filename]) ? $records[$filename] : null; unset($records[$filename]); if ($mtime && (empty($existing) || $mtime > $existing->mtime)) { $data = base64_encode($fs->get($file)); /* if (empty($maxsize)) { $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; } if (strlen($data) > $maxsize) { \Log::error("Failed to save $file. Size exceeds max_allowed_packet."); continue; } */ $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert( ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename], ['mtime' => $mtime, 'data' => $data] ); if ($debug) { \Log::debug("[SYNC] Pushed file: $file"); } } } // Delete removed files from database foreach (array_keys($records) as $filename) { $file = $homedir . '/' . $filename; $result = $db->table(self::FILESTORE_TABLE) ->where('user_id', $user_id) ->where('context', 'enigma') ->where('filename', $filename) ->delete(); if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } /** * Delete a Roundcube user. * * @param string $email User email address */ public static function deleteUser(string $email): void { $db = self::dbh(); $db->table(self::USERS_TABLE)->where('username', \strtolower($email))->delete(); } /** * Find the Roundcube user identifier for the specified user. * * @param string $email User email address * @param bool $create Make sure the user record exists * * @returns ?int Roundcube user identifier */ public static function userId(string $email, bool $create = true): ?int { $db = self::dbh(); $user = $db->table(self::USERS_TABLE)->select('user_id') ->where('username', \strtolower($email)) ->first(); // Create a user record, without it we can't use the Roundcube storage if (empty($user)) { if (!$create) { return null; } - $uri = \parse_url(\config('imap.uri')); + $uri = \parse_url(\config('services.imap.uri')); $user_id = (int) $db->table(self::USERS_TABLE)->insertGetId( [ 'username' => $email, 'mail_host' => $uri['host'], 'created' => now()->toDateTimeString(), ], 'user_id' ); $username = \App\User::where('email', $email)->first()->name(); $db->table(self::IDENTITIES_TABLE)->insert([ 'user_id' => $user_id, 'email' => $email, 'name' => $username, 'changed' => now()->toDateTimeString(), 'standard' => 1, ]); return $user_id; } return (int) $user->user_id; } /** * Returns list of Enigma user homedir files to backup/sync */ private static function enigmaFilesList(string $homedir) { $files = []; $fs = Storage::disk('pgp'); foreach (self::$enigma_files as $file) { if ($fs->exists($homedir . '/' . $file)) { $files[] = $file; } } foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) { if (preg_match('/\.key$/', $file)) { $files[] = substr($file, strlen($homedir . '/')); } } return $files; } } diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php index 15eec18f..b01bbc67 100644 --- a/src/app/CompanionApp.php +++ b/src/app/CompanionApp.php @@ -1,108 +1,108 @@ The attributes that are mass assignable */ protected $fillable = [ 'name', 'user_id', 'device_id', 'notification_token', 'mfa_enabled', ]; /** * Send a notification via firebase. * * @param array $deviceIds A list of device id's to send the notification to * @param array $data The data to include in the notification. * * @throws \Exception on notification failure * @return bool true if a notification has been sent */ private static function pushFirebaseNotification($deviceIds, $data): bool { \Log::debug("sending notification to " . var_export($deviceIds, true)); - $apiKey = \config('firebase.api_key'); - $apiUrl = \config('firebase.api_url'); - $verify = \config('firebase.api_verify_tls'); + $apiKey = \config('services.firebase.api_key'); + $apiUrl = \config('services.firebase.api_url'); + $verify = \config('services.firebase.api_verify_tls'); if (empty($apiKey)) { return false; } Http::withOptions(['verify' => $verify]) ->withHeaders(['Authorization' => "key={$apiKey}"]) ->timeout(5) ->connectTimeout(5) ->post($apiUrl, ['registration_ids' => $deviceIds, 'data' => $data]) ->throwUnlessStatus(200); return true; } /** * Send a notification to a user. * * @throws \Exception on notification failure * @return bool true if a notification has been sent */ public static function notifyUser($userId, $data): bool { $notificationTokens = CompanionApp::where('user_id', $userId) ->where('mfa_enabled', true) ->pluck('notification_token') ->all(); if (empty($notificationTokens)) { \Log::debug("There is no 2fa device to notify."); return false; } return self::pushFirebaseNotification($notificationTokens, $data); } /** * Returns whether this companion app is paired with a device. * * @return bool */ public function isPaired(): bool { return !empty($this->device_id); } /** * The PassportClient of this CompanionApp * * @return \App\Auth\PassportClient|null */ public function passportClient() { return \App\Auth\PassportClient::find($this->oauth_client_id); } /** * Set the PassportClient of this CompanionApp * * @param \Laravel\Passport\Client $client The client object */ public function setPassportClient(\Laravel\Passport\Client $client) { return $this->oauth_client_id = $client->id; } } diff --git a/src/app/DataMigrator/IMAP.php b/src/app/DataMigrator/IMAP.php index 70743a4b..89f9a4df 100644 --- a/src/app/DataMigrator/IMAP.php +++ b/src/app/DataMigrator/IMAP.php @@ -1,463 +1,463 @@ account = $account; $this->engine = $engine; // TODO: Move this to self::authenticate()? $config = self::getConfig($account->username, $account->password, $account->uri); $this->imap = self::initIMAP($config); } /** * Object destructor */ public function __destruct() { try { $this->imap->closeConnection(); } catch (\Throwable $e) { // Ignore. It may throw when destructing the object in tests // We also don't really care abount an error on this operation } } /** * Authenticate */ public function authenticate(): void { } /** * Create a folder. * * @param Folder $folder Folder data * * @throws \Exception on error */ public function createFolder(Folder $folder): void { if ($folder->type != 'mail') { throw new \Exception("IMAP does not support folder of type {$folder->type}"); } if ($folder->fullname == 'INBOX') { // INBOX always exists return; } if (!$this->imap->createFolder($folder->fullname)) { \Log::warning("Failed to create the folder: {$this->imap->error}"); if (str_contains($this->imap->error, "Mailbox already exists")) { // Not an error } else { throw new \Exception("Failed to create an IMAP folder {$folder->fullname}"); } } // TODO: Migrate folder subscription state } /** * Create an item in a folder. * * @param Item $item Item to import * * @throws \Exception */ public function createItem(Item $item): void { $mailbox = $item->folder->fullname; if (strlen($item->content)) { $result = $this->imap->append( $mailbox, $item->content, $item->data['flags'], $item->data['internaldate'], true ); if ($result === false) { throw new \Exception("Failed to append IMAP message into {$mailbox}"); } } elseif ($item->filename) { $result = $this->imap->appendFromFile( $mailbox, $item->filename, null, $item->data['flags'], $item->data['internaldate'], true ); if ($result === false) { throw new \Exception("Failed to append IMAP message into {$mailbox}"); } } // When updating an existing email message we have to... if ($item->existing) { if (!empty($result)) { // Remove the old one $this->imap->flag($mailbox, $item->existing['uid'], 'DELETED'); $this->imap->expunge($mailbox, $item->existing['uid']); } else { // Update flags foreach ($item->existing['flags'] as $flag) { if (!in_array($flag, $item->data['flags'])) { $this->imap->unflag($mailbox, $item->existing['uid'], $flag); } } foreach ($item->data['flags'] as $flag) { if (!in_array($flag, $item->existing['flags'])) { $this->imap->flag($mailbox, $item->existing['uid'], $flag); } } } } } /** * Fetching an item */ public function fetchItem(Item $item): void { [$uid, $messageId] = explode(':', $item->id, 2); $mailbox = $item->folder->fullname; // Get message flags $header = $this->imap->fetchHeader($mailbox, (int) $uid, true, false, ['FLAGS']); if ($header === false) { throw new \Exception("Failed to get IMAP message headers for {$mailbox}/{$uid}"); } // Remove flags that we can't append (e.g. RECENT) $flags = $this->filterImapFlags(array_keys($header->flags)); // If message already exists in the destination account we should update only flags // and be done with it. On the other hand for Drafts it's not unusual to get completely // different body for the same Message-ID. Same can happen not only in Drafts, I suppose. // So, we compare size and INTERNALDATE timestamp. if ( !$item->existing || $header->timestamp != $item->existing['timestamp'] || $header->size != $item->existing['size'] ) { // Handle message content in memory (up to 20MB), bigger messages will use a temp file if ($header->size > Engine::MAX_ITEM_SIZE) { // Save the message content to a file $location = $item->folder->tempFileLocation($uid . '.eml'); $fp = fopen($location, 'w'); if (!$fp) { throw new \Exception("Failed to open 'php://temp' stream"); } $result = $this->imap->handlePartBody($mailbox, $uid, true, '', null, null, $fp); } else { $result = $this->imap->handlePartBody($mailbox, $uid, true); } if ($result === false) { if (!empty($fp)) { fclose($fp); } throw new \Exception("Failed to fetch IMAP message for {$mailbox}/{$uid}"); } if (!empty($fp) && !empty($location)) { $item->filename = $location; fclose($fp); } else { $item->content = $result; } } $item->data = [ 'flags' => $flags, 'internaldate' => $header->internaldate, ]; } /** * Fetch a list of folder items */ public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void { // Get existing messages' headers from the destination mailbox $existing = $importer->getItems($folder); $mailbox = $folder->fullname; // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted // It would also allow us to get headers in chunks 200 messages at a time, or so. // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)] $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']); if ($messages === false) { throw new \Exception("Failed to get all IMAP message headers for {$mailbox}"); } if (empty($messages)) { \Log::debug("Nothing to migrate for {$mailbox}"); return; } $set = new ItemSet(); foreach ($messages as $message) { // If Message-Id header does not exist create it based on internaldate/From/Date $id = $this->getMessageId($message, $mailbox); // Skip message that exists and did not change $exists = null; if (isset($existing[$id])) { $flags = $this->filterImapFlags(array_keys($message->flags)); if ( $flags == $existing[$id]['flags'] && $message->timestamp == $existing[$id]['timestamp'] && $message->size == $existing[$id]['size'] ) { continue; } $exists = $existing[$id]; } $set->items[] = Item::fromArray([ 'id' => $message->uid . ':' . $id, 'folder' => $folder, 'existing' => $exists, ]); if (count($set->items) == self::CHUNK_SIZE) { $callback($set); $set = new ItemSet(); } } if (count($set->items)) { $callback($set); } // TODO: Delete messages that do not exist anymore? } /** * Get folders hierarchy */ public function getFolders($types = []): array { $folders = $this->imap->listMailboxes('', ''); if ($folders === false) { throw new \Exception("Failed to get list of IMAP folders"); } // TODO: Migrate folder subscription state $result = []; foreach ($folders as $folder) { if ($this->shouldSkip($folder)) { \Log::debug("Skipping folder {$folder}."); continue; } $result[] = Folder::fromArray([ 'fullname' => $folder, 'type' => 'mail' ]); } return $result; } /** * Get a list of folder items, limited to their essential propeties * used in incremental migration to skip unchanged items. */ public function getItems(Folder $folder): array { $mailbox = $folder->fullname; // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)] $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']); if ($messages === false) { throw new \Exception("Failed to get IMAP message headers in {$mailbox}"); } $result = []; foreach ($messages as $message) { // Remove flags that we can't append (e.g. RECENT) $flags = $this->filterImapFlags(array_keys($message->flags)); // Generate message ID if the header does not exist $id = $this->getMessageId($message, $mailbox); $result[$id] = [ 'uid' => $message->uid, 'flags' => $flags, 'size' => $message->size, 'timestamp' => $message->timestamp, ]; } return $result; } /** * Initialize IMAP connection and authenticate the user */ private static function initIMAP(array $config, string $login_as = null): \rcube_imap_generic { $imap = new \rcube_imap_generic(); if (\config('app.debug')) { $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); } if ($login_as) { $config['options']['auth_cid'] = $config['user']; $config['options']['auth_pw'] = $config['password']; $config['options']['auth_type'] = 'PLAIN'; $config['user'] = $login_as; } $imap->connect($config['host'], $config['user'], $config['password'], $config['options']); if (!$imap->connected()) { $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error); \Log::error($message); throw new \Exception("Connection to IMAP failed"); } return $imap; } /** * Get IMAP configuration */ private static function getConfig($user, $password, $uri): array { $uri = \parse_url($uri); $default_port = 143; $ssl_mode = null; if (isset($uri['scheme'])) { if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) { $default_port = 993; $ssl_mode = 'ssl'; } elseif ($uri['scheme'] === 'tls') { $ssl_mode = 'tls'; } } $config = [ 'host' => $uri['host'], 'user' => $user, 'password' => $password, 'options' => [ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port, 'ssl_mode' => $ssl_mode, 'socket_options' => [ 'ssl' => [ // TODO: These configuration options make sense for "local" Kolab IMAP, // but when connecting to external one we might want to just disable // cert validation, or make it optional via Account URI parameters - 'verify_peer' => \config('imap.verify_peer'), - 'verify_peer_name' => \config('imap.verify_peer'), - 'verify_host' => \config('imap.verify_host') + 'verify_peer' => \config('services.imap.verify_peer'), + 'verify_peer_name' => \config('services.imap.verify_peer'), + 'verify_host' => \config('services.imap.verify_host') ], ], ], ]; return $config; } /** * Limit IMAP flags to these that can be migrated */ private function filterImapFlags($flags) { // TODO: Support custom flags migration return array_filter( $flags, function ($flag) { return isset($this->imap->flags[$flag]); } ); } /** * Check if the folder should not be migrated */ private function shouldSkip($folder): bool { // TODO: This should probably use NAMESPACE information if (preg_match('~(Shared Folders|Other Users)/.*~', $folder)) { return true; } return false; } /** * Return Message-Id, generate unique identifier if Message-Id does not exist */ private function getMessageId($message, $folder): string { if (!empty($message->messageID)) { return $message->messageID; } return md5($folder . $message->from . ($message->date ?: $message->timestamp)); } } diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index ca5cf117..491b2980 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,383 +1,383 @@ email == $login) { return $user; } throw new \Exception("Password mismatch"); } $user = User::where('email', $login)->first(); if (!$user) { throw new \Exception("User not found"); } if (!Hash::check($password, $user->password)) { throw new \Exception("Password mismatch"); } return $user; } /** * Authorize with the provided credentials. * * @param string $login The login name * @param string $password The password * @param string $clientIP The client ip * * @return \App\User The user * * @throws \Exception If the authorization fails. */ private function authorizeRequest($login, $password, $clientIP) { if (empty($login)) { throw new \Exception("Empty login"); } if (empty($password)) { throw new \Exception("Empty password"); } if (empty($clientIP)) { throw new \Exception("No client ip"); } if ($userid = AuthUtils::tokenValidate($password)) { $user = User::find($userid); if ($user && $user->email == $login) { return $user; } throw new \Exception("Password mismatch"); } $result = User::findAndAuthenticate($login, $password, $clientIP); if (empty($result['user'])) { throw new \Exception($result['errorMessage'] ?? "Unknown error"); } // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) // TODO: validate the user is A-OK (active, not suspended, ldapready, imapready) // TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of // attempts over the same authAttempt. return $result['user']; } /** * Convert domain.tld\username into username@domain for activesync * * @param string $username The original username. * * @return string The username in canonical form */ private function normalizeUsername($username) { $usernameParts = explode("\\", $username); if (count($usernameParts) == 2) { $username = $usernameParts[1]; if (!strpos($username, '@') && !empty($usernameParts[0])) { $username .= '@' . $usernameParts[0]; } } return $username; } /** * Authentication request from the ngx_http_auth_request_module * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function httpauth(Request $request) { /** Php-Auth-Pw: simple123 Php-Auth-User: john@kolab.org Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: cross-site Sec-Gpc: 1 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0 X-Forwarded-For: 31.10.153.58 X-Forwarded-Proto: https X-Original-Uri: /iRony/ X-Real-Ip: 31.10.153.58 */ $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', '')); $password = $request->headers->get('Php-Auth-Pw', null); $ip = $request->headers->get('X-Real-Ip', null); if (empty($username)) { // Allow unauthenticated requests return response(''); } if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response("", 401); } try { $this->authorizeRequest($username, $password, $ip); } catch (\Exception $e) { \Log::debug("Authentication attempt failed: {$e->getMessage()}"); return response("", 403); } \Log::debug("Authentication attempt succeeded"); return response(''); } /** * Authentication request from the cyrus sasl * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function cyrussasl(Request $request) { $data = $request->getContent(); // Assumes "%u %r %p" as form data in the cyrus sasl config file $array = explode(' ', rawurldecode($data)); if (count($array) != 3) { \Log::debug("Authentication attempt failed: invalid data provided."); return response("", 403); } $username = $array[0]; $realm = $array[1]; $password = $array[2]; if (!empty($realm)) { $username = "$username@$realm"; } if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response('', 403); } try { $this->authorizeRequestCredentialsOnly($username, $password); } catch (\Exception $e) { \Log::debug("Authentication attempt failed for $username: {$e->getMessage()}"); return response('', 403); } \Log::debug("Authentication attempt succeeded for $username"); return response(''); } /** * Authentication request. * * @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. => * I suppose that's not necessary given that we have the information avialable in the headers? * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function authenticate(Request $request) { /** * Auth-Login-Attempt: 1 * Auth-Method: plain * Auth-Pass: simple123 * Auth-Protocol: imap * Auth-Ssl: on * Auth-User: john@kolab.org * Client-Ip: 127.0.0.1 * Host: 127.0.0.1 * * Auth-SSL: on * Auth-SSL-Verify: SUCCESS * Auth-SSL-Subject: /CN=example.com * Auth-SSL-Issuer: /CN=example.com * Auth-SSL-Serial: C07AD56B846B5BFF * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Client-Ip', null); try { $user = $this->authorizeRequest($username, $password, $ip); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case 'imap': return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password); case 'smtp': return $this->authenticateSMTP($request, $password); default: return $this->byebye($request, "unknown protocol in request"); } } /** * Authentication request for roundcube imap. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function authenticateRoundcube(Request $request) { /** * Auth-Login-Attempt: 1 * Auth-Method: plain * Auth-Pass: simple123 * Auth-Protocol: imap * Auth-Ssl: on * Auth-User: john@kolab.org * Client-Ip: 127.0.0.1 * Host: 127.0.0.1 * * Auth-SSL: on * Auth-SSL-Verify: SUCCESS * Auth-SSL-Subject: /CN=example.com * Auth-SSL-Issuer: /CN=example.com * Auth-SSL-Serial: C07AD56B846B5BFF * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Proxy-Protocol-Addr', null); try { $user = $this->authorizeRequest($username, $password, $ip); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case 'imap': return $this->authenticateIMAP($request, false, $password); default: return $this->byebye($request, "unknown protocol in request"); } } /** * Create an imap authentication response. * * @param \Illuminate\Http\Request $request The API request. * @param bool $prefGuam Whether or not Guam is enabled. * @param string $password The password to include in the response. * * @return \Illuminate\Http\Response The response */ private function authenticateIMAP(Request $request, $prefGuam, $password) { if ($prefGuam) { - $port = \config('imap.guam_port'); + $port = \config('services.imap.guam_port'); } else { - $port = \config('imap.imap_port'); + $port = \config('services.imap.imap_port'); } $response = response('')->withHeaders( [ "Auth-Status" => "OK", - "Auth-Server" => gethostbyname(\config('imap.host')), + "Auth-Server" => gethostbyname(\config('services.imap.host')), "Auth-Port" => $port, "Auth-Pass" => $password ] ); return $response; } /** * Create an smtp authentication response. * * @param \Illuminate\Http\Request $request The API request. * @param string $password The password to include in the response. * * @return \Illuminate\Http\Response The response */ private function authenticateSMTP(Request $request, $password) { $response = response('')->withHeaders( [ "Auth-Status" => "OK", - "Auth-Server" => gethostbyname(\config('smtp.host')), - "Auth-Port" => \config('smtp.port'), + "Auth-Server" => gethostbyname(\config('services.smtp.host')), + "Auth-Port" => \config('services.smtp.port'), "Auth-Pass" => $password ] ); return $response; } /** * Create a failed-authentication response. * * @param \Illuminate\Http\Request $request The API request. * @param string $reason The reason for the failure. * * @return \Illuminate\Http\Response The response */ private function byebye(Request $request, $reason = null) { \Log::debug("Byebye: {$reason}"); $response = response('')->withHeaders( [ "Auth-Status" => "authentication failure", "Auth-Wait" => 3 ] ); return $response; } } diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php index 08b7da93..279afc92 100644 --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -1,123 +1,123 @@ isDeleted()`), or * * the user is actually deleted (`$user->deleted_at`), or * * the user is already marked as ready in LDAP (`$user->isLdapReady()`). */ class CreateJob extends UserJob { /** @var int Enable waiting for a user record to exist */ protected $waitForUser = 5; /** * Execute the job. * * @return void * * @throws \Exception */ public function handle() { $user = $this->getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } - if ($user->email == \config('imap.admin_login')) { + if ($user->email == \config('services.imap.admin_login')) { // Ignore Cyrus admin account return; } // sanity checks if ($user->isDeleted()) { $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); return; } if ($user->trashed()) { $this->fail(new \Exception("User {$this->userId} is actually deleted.")); return; } $withLdap = \config('app.with_ldap'); // see if the domain is ready $domain = $user->domain(); if (!$domain) { $this->fail(new \Exception("The domain for {$this->userId} does not exist.")); return; } if ($domain->isDeleted()) { $this->fail(new \Exception("The domain for {$this->userId} is marked as deleted.")); return; } if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } if (\config('abuse.suspend_enabled') && !$user->isSuspended()) { $code = \Artisan::call("user:abuse-check {$this->userId}"); if ($code == 2) { \Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}"); \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, "Suspected spammer"); $user->status |= \App\User::STATUS_SUSPENDED; } } if ($withLdap && !$user->isLdapReady()) { \App\Backends\LDAP::createUser($user); $user->status |= \App\User::STATUS_LDAP_READY; $user->save(); } if (!$user->isImapReady()) { if (\config('app.with_imap')) { if (!\App\Backends\IMAP::createUser($user)) { throw new \Exception("Failed to create mailbox for user {$this->userId}."); } } else { if (!\App\Backends\IMAP::verifyAccount($user->email)) { $this->release(15); return; } } $user->status |= \App\User::STATUS_IMAP_READY; } // FIXME: Should we ignore exceptions on this operation or introduce DAV_READY status? \App\Backends\DAV::initDefaultFolders($user); // Make user active in non-mandate mode only if ( !($wallet = $user->wallet()) || !($plan = $user->wallet()->plan()) || $plan->mode != \App\Plan::MODE_MANDATE ) { $user->status |= \App\User::STATUS_ACTIVE; } $user->save(); } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index c366e68d..0b35e952 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,168 +1,168 @@ 'overrideValue1', + * 'services.imap.uri' => 'overrideValue1', * 'queue.connections.database.table' => 'overrideValue2', * ]; */ private function applyOverrideConfig(): void { $overrideConfig = (array) \config('override'); foreach (array_keys($overrideConfig) as $key) { \config([$key => $overrideConfig[$key]]); } } /** * Bootstrap any application services. */ public function boot(): void { \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\EventLog::observe(\App\Observers\EventLogObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class); \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\SignupToken::observe(\App\Observers\SignupTokenObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); \App\UserSetting::observe(\App\Observers\UserSettingObserver::class); \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); \App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class); \App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class); Schema::defaultStringLength(191); // Register some template helpers Blade::directive( 'theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; } ); Builder::macro( 'withEnvTenantContext', function (string $table = null) { $tenantId = \config('app.tenant_id'); if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withObjectTenantContext', function (object $object, string $table = null) { $tenantId = $object->tenant_id; if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); Builder::macro( 'withSubjectTenantContext', function (string $table = null) { if ($user = auth()->user()) { $tenantId = $user->tenant_id; } else { $tenantId = \config('app.tenant_id'); } if ($tenantId) { /** @var Builder $this */ return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId); } /** @var Builder $this */ return $this->whereNull(($table ? "$table." : "") . "tenant_id"); } ); // Query builder 'whereLike' mocro Builder::macro( 'whereLike', function (string $column, string $search, int $mode = 0) { $search = addcslashes($search, '%_'); switch ($mode) { case 2: $search .= '%'; break; case 1: $search = '%' . $search; break; default: $search = '%' . $search . '%'; } /** @var Builder $this */ return $this->where($column, 'like', $search); } ); Http::macro('withSlowLog', function () { return Http::withOptions([ 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { $threshold = \config('logging.slow_log'); if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { $url = $stats->getEffectiveUri(); $method = $stats->getRequest()->getMethod(); \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); } }, ]); }); $this->applyOverrideConfig(); } } diff --git a/src/config/firebase.php b/src/config/firebase.php deleted file mode 100644 index 9c842771..00000000 --- a/src/config/firebase.php +++ /dev/null @@ -1,7 +0,0 @@ - Project Settings -> CLOUD MESSAGING -> Server key*/ - 'api_key' => env('FIREBASE_API_KEY'), - 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'), - 'api_verify_tls' => (bool) env('FIREBASE_API_VERIFY_TLS', true) - ]; diff --git a/src/config/imap.php b/src/config/imap.php deleted file mode 100644 index 4a2ada8b..00000000 --- a/src/config/imap.php +++ /dev/null @@ -1,79 +0,0 @@ - [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'mail.drafts', - '/private/specialuse' => '\Drafts', - ], - ], - 'Sent' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'mail.sentitems', - '/private/specialuse' => '\Sent', - ], - ], - 'Trash' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'mail.wastebasket', - '/private/specialuse' => '\Trash', - ], - ], - 'Spam' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'mail.junkemail', - '/private/specialuse' => '\Junk', - ], - ], - ]; - if (env('IMAP_WITH_GROUPWARE_DEFAULT_FOLDERS', true)) { - $folders = array_merge($folders, [ - 'Calendar' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'event.default', - '/shared/vendor/kolab/folder-type' => 'event', - ], - ], - 'Contacts' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'contact.default', - '/shared/vendor/kolab/folder-type' => 'event', - ], - ], - 'Tasks' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'task.default', - '/shared/vendor/kolab/folder-type' => 'task', - ], - ], - 'Notes' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'note.default', - '/shared/vendor/kolab/folder-type' => 'note', - ], - ], - 'Files' => [ - 'metadata' => [ - '/private/vendor/kolab/folder-type' => 'file.default', - '/shared/vendor/kolab/folder-type' => 'file', - ], - ], - ]); - } - return $folders; - } -} - -return [ - 'uri' => env('IMAP_URI', 'ssl://kolab:11993'), - 'admin_login' => env('IMAP_ADMIN_LOGIN', 'cyrus-admin'), - 'admin_password' => env('IMAP_ADMIN_PASSWORD', null), - 'verify_peer' => env('IMAP_VERIFY_PEER', true), - 'verify_host' => env('IMAP_VERIFY_HOST', true), - 'host' => env('IMAP_HOST', '172.18.0.5'), - 'imap_port' => env('IMAP_PORT', 12143), - 'guam_port' => env('IMAP_GUAM_PORT', 9143), - 'default_folders' => imap_defaultFolders() -]; diff --git a/src/config/ldap.php b/src/config/ldap.php deleted file mode 100644 index 08342ecb..00000000 --- a/src/config/ldap.php +++ /dev/null @@ -1,32 +0,0 @@ - explode(' ', env('LDAP_HOSTS', '127.0.0.1')), - 'port' => env('LDAP_PORT', 636), - 'use_tls' => (boolean)env('LDAP_USE_TLS', false), - 'use_ssl' => (boolean)env('LDAP_USE_SSL', true), - - 'admin' => [ - 'bind_dn' => env('LDAP_ADMIN_BIND_DN', null), - 'bind_pw' => env('LDAP_ADMIN_BIND_PW', null), - 'root_dn' => env('LDAP_ADMIN_ROOT_DN', null), - ], - - 'hosted' => [ - 'bind_dn' => env('LDAP_HOSTED_BIND_DN', null), - 'bind_pw' => env('LDAP_HOSTED_BIND_PW', null), - 'root_dn' => env('LDAP_HOSTED_ROOT_DN', null), - ], - - 'domain_owner' => [ - // probably proxy credentials? - ], - - 'root_dn' => env('LDAP_ROOT_DN', null), - '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))'), - 'filter' => env('LDAP_FILTER', '(&(objectclass=kolabinetorgperson)(uid=%s))'), - 'domain_name_attribute' => env('LDAP_DOMAIN_NAME_ATTRIBUTE', 'associateddomain'), - 'domain_base_dn' => env('LDAP_DOMAIN_BASE_DN', null), - 'domain_filter' => env('LDAP_DOMAIN_FILTER', '(associateddomain=%s)') -]; diff --git a/src/config/services.php b/src/config/services.php index d9b2f134..a84247d7 100644 --- a/src/config/services.php +++ b/src/config/services.php @@ -1,80 +1,136 @@ [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), ], 'postmark' => [ 'token' => env('POSTMARK_TOKEN'), ], 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], 'sparkpost' => [ 'secret' => env('SPARKPOST_SECRET'), ], 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'), 'mollie' => [ 'key' => env('MOLLIE_KEY'), ], 'stripe' => [ 'key' => env('STRIPE_KEY'), 'public_key' => env('STRIPE_PUBLIC_KEY'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], 'coinbase' => [ 'key' => env('COINBASE_KEY'), 'webhook_secret' => env('COINBASE_WEBHOOK_SECRET'), 'api_verify_tls' => env('COINBASE_VERIFY_TLS', true), ], + 'firebase' => [ + // api_key available in: Firebase Console -> Project Settings -> CLOUD MESSAGING -> Server key + 'api_key' => env('FIREBASE_API_KEY'), + 'api_url' => env('FIREBASE_API_URL', 'https://fcm.googleapis.com/fcm/send'), + 'api_verify_tls' => (bool) env('FIREBASE_API_VERIFY_TLS', true) + ], + 'openexchangerates' => [ 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), ], + /* + ---------------------------------------------------------------------------- + Kolab Services + ---------------------------------------------------------------------------- + */ + 'dav' => [ 'uri' => env('DAV_URI', 'https://proxy/'), 'default_folders' => \App\Backends\Helper::defaultDavFolders(), 'verify' => (bool) env('DAV_VERIFY', true), ], + 'imap' => [ + 'uri' => env('IMAP_URI', 'ssl://kolab:11993'), + 'admin_login' => env('IMAP_ADMIN_LOGIN', 'cyrus-admin'), + 'admin_password' => env('IMAP_ADMIN_PASSWORD', null), + 'verify_peer' => env('IMAP_VERIFY_PEER', true), + 'verify_host' => env('IMAP_VERIFY_HOST', true), + 'host' => env('IMAP_HOST', '172.18.0.5'), + 'imap_port' => env('IMAP_PORT', 12143), + 'guam_port' => env('IMAP_GUAM_PORT', 9143), + 'default_folders' => \App\Backends\Helper::defaultImapFolders(), + ], + + 'ldap' => [ + 'hosts' => explode(' ', env('LDAP_HOSTS', '127.0.0.1')), + 'port' => env('LDAP_PORT', 636), + 'use_tls' => (boolean)env('LDAP_USE_TLS', false), + 'use_ssl' => (boolean)env('LDAP_USE_SSL', true), + + 'admin' => [ + 'bind_dn' => env('LDAP_ADMIN_BIND_DN', null), + 'bind_pw' => env('LDAP_ADMIN_BIND_PW', null), + 'root_dn' => env('LDAP_ADMIN_ROOT_DN', null), + ], + + 'hosted' => [ + 'bind_dn' => env('LDAP_HOSTED_BIND_DN', null), + 'bind_pw' => env('LDAP_HOSTED_BIND_PW', null), + 'root_dn' => env('LDAP_HOSTED_ROOT_DN', null), + ], + + 'domain_owner' => [ + // probably proxy credentials? + ], + + 'root_dn' => env('LDAP_ROOT_DN', null), + '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))'), + 'filter' => env('LDAP_FILTER', '(&(objectclass=kolabinetorgperson)(uid=%s))'), + 'domain_name_attribute' => env('LDAP_DOMAIN_NAME_ATTRIBUTE', 'associateddomain'), + 'domain_base_dn' => env('LDAP_DOMAIN_BASE_DN', null), + 'domain_filter' => env('LDAP_DOMAIN_FILTER', '(associateddomain=%s)') + ], + 'autodiscover' => [ 'uri' => env('AUTODISCOVER_URI', env('APP_URL', 'http://localhost')), ], 'activesync' => [ 'uri' => env('ACTIVESYNC_URI', 'https://proxy/Microsoft-Server-ActiveSync'), ], + 'smtp' => [ + 'host' => env('SMTP_HOST', '172.18.0.5'), + 'port' => env('SMTP_PORT', 10465), + ], + 'wopi' => [ 'uri' => env('WOPI_URI', 'http://roundcube/chwala/'), ], 'webmail' => [ 'uri' => env('WEBMAIL_URI', 'http://roundcube/roundcubemail/'), - ] + ], ]; diff --git a/src/config/smtp.php b/src/config/smtp.php deleted file mode 100644 index 400824ed..00000000 --- a/src/config/smtp.php +++ /dev/null @@ -1,6 +0,0 @@ - env('SMTP_HOST', '172.18.0.5'), - 'port' => env('SMTP_PORT', 10465), -]; diff --git a/src/phpunit.xml b/src/phpunit.xml index cb01f1d9..7ec2c119 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,48 +1,52 @@ tests/Unit tests/Functional tests/Feature + + tests/Infrastructure + + tests/Browser tests/Browser/PaymentCoinbaseTest.php ./app diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php index c0845194..41a1ef3d 100644 --- a/src/tests/Feature/Backends/DAVTest.php +++ b/src/tests/Feature/Backends/DAVTest.php @@ -1,100 +1,100 @@ markTestSkipped(); } } /** * {@inheritDoc} */ public function tearDown(): void { if ($this->user) { $this->deleteTestUser($this->user->email); } parent::tearDown(); } /** * Test initializing default folders for a user. * * @group imap * @group dav */ public function testInitDefaultFolders(): void { Queue::fake(); $props = ['password' => 'test-pass']; $this->user = $user = $this->getTestUser('davtest-' . time() . '@' . \config('app.domain'), $props); // Create the IMAP mailbox, it is required otherwise DAV requests will fail - \config(['imap.default_folders' => null]); + \config(['services.imap.default_folders' => null]); IMAP::createUser($user); $dav_folders = [ [ 'path' => 'Default', 'displayname' => 'Calendar-Test', 'components' => ['VEVENT'], 'type' => 'calendar', ], [ 'path' => 'Tasks', 'displayname' => 'Tasks-Test', 'components' => ['VTODO'], 'type' => 'calendar', ], [ 'path' => 'Default', 'displayname' => 'Contacts-Test', 'type' => 'addressbook', ], ]; \config(['services.dav.default_folders' => $dav_folders]); DAV::initDefaultFolders($user); $dav = new DAV($user->email, $props['password']); $folders = $dav->listFolders(DAV::TYPE_VCARD); $this->assertCount(1, $folders); $this->assertSame('Contacts-Test', $folders[0]->name); $folders = $dav->listFolders(DAV::TYPE_VEVENT); $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); $folders = array_values($folders); $this->assertCount(1, $folders); $this->assertSame(['VEVENT'], $folders[0]->components); $this->assertSame(['collection', 'calendar'], $folders[0]->types); $this->assertSame('Calendar-Test', $folders[0]->name); $folders = $dav->listFolders(DAV::TYPE_VTODO); $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); $folders = array_values($folders); $this->assertCount(1, $folders); $this->assertSame(['VTODO'], $folders[0]->components); $this->assertSame(['collection', 'calendar'], $folders[0]->types); $this->assertSame('Tasks-Test', $folders[0]->name); } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 2b49305f..210d302e 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,676 +1,676 @@ markTestSkipped(); } $this->ldap_config = [ - 'ldap.hosts' => \config('ldap.hosts'), + 'services.ldap.hosts' => \config('services.ldap.hosts'), ]; $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members } /** * {@inheritDoc} */ public function tearDown(): void { \config($this->ldap_config); $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members parent::tearDown(); } /** * Test handling connection errors * * @group ldap */ public function testConnectException(): void { - \config(['ldap.hosts' => 'non-existing.host']); + \config(['services.ldap.hosts' => 'non-existing.host']); $this->expectException(\Exception::class); LDAP::connect(); } /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { 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/updating/deleting a group record * * @group ldap */ public function testGroup(): void { Queue::fake(); - $root_dn = \config('ldap.hosted.root_dn'); + $root_dn = \config('services.ldap.hosted.root_dn'); $group = $this->getTestGroup('group@kolab.org', [ 'members' => ['member1@testldap.com', 'member2@testldap.com'] ]); $group->setSetting('sender_policy', '["test.com"]'); // Create the group LDAP::createGroup($group); $ldap_group = LDAP::getGroup($group->email); $expected = [ 'cn' => 'group', 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], 'kolaballowsmtpsender' => 'test.com', 'uniquemember' => [ 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } // Update members $group->members = ['member3@testldap.com']; $group->save(); $group->setSetting('sender_policy', '["test.com","Test.com","-"]'); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = ['test.com', '-']; // duplicates removed $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // Update members (add non-existing local member, expect it to be aot-removed from the group) // Update group name and sender_policy $group->members = ['member3@testldap.com', 'member-local@kolab.org']; $group->name = 'Te(=ść)1'; $group->save(); $group->setSetting('sender_policy', null); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = null; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // We called save() twice, and setSettings() three times, // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); // Delete the group LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } /** * Test creating/updating/deleting a resource record * * @group ldap */ public function testResource(): void { Queue::fake(); - $root_dn = \config('ldap.hosted.root_dn'); + $root_dn = \config('services.ldap.hosted.root_dn'); $resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']); $resource->setSetting('invitation_policy', null); // Make sure the resource does not exist // LDAP::deleteResource($resource); // Create the resource LDAP::createResource($resource); $ldap_resource = LDAP::getResource($resource->email); $expected = [ 'cn' => 'Test1', 'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn, 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org', 'kolabinvitationpolicy' => null, 'owner' => null, 'acl' => 'anyone, p', ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Update resource name and invitation_policy $resource->name = 'Te(=ść)1'; $resource->save(); $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); LDAP::updateResource($resource); $expected['kolabtargetfolder'] = 'shared/Resources/Te(=ść)1@kolab.org'; $expected['kolabinvitationpolicy'] = 'ACT_MANUAL'; $expected['owner'] = 'uid=john@kolab.org,ou=People,ou=kolab.org,' . $root_dn; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Resources,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $expected['acl'] = ['john@kolab.org, full', 'anyone, p']; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Remove the invitation policy $resource->setSetting('invitation_policy', '[]'); LDAP::updateResource($resource); $expected['acl'] = 'anyone, p'; $expected['kolabinvitationpolicy'] = null; $expected['owner'] = null; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Delete the resource LDAP::deleteResource($resource); $this->assertSame(null, LDAP::getResource($resource->email)); } /** * Test creating/updating/deleting a shared folder record * * @group ldap */ public function testSharedFolder(): void { Queue::fake(); - $root_dn = \config('ldap.hosted.root_dn'); + $root_dn = \config('services.ldap.hosted.root_dn'); $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']); $folder->setSetting('acl', null); // Make sure the shared folder does not exist // LDAP::deleteSharedFolder($folder); // Create the shared folder LDAP::createSharedFolder($folder); $ldap_folder = LDAP::getSharedFolder($folder->email); $expected = [ 'cn' => 'test-folder', 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn, 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/test-folder@kolab.org', 'acl' => 'anyone, p', 'alias' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Update folder name and acl $folder->name = 'Te(=ść)1'; $folder->save(); $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]'); $aliases = ['t1-' . $folder->email, 't2-' . $folder->email]; $folder->setAliases($aliases); LDAP::updateSharedFolder($folder); $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org'; $expected['acl'] = ['john@kolab.org, read-write', 'anyone, lrsp']; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $expected['alias'] = $aliases; $ldap_folder = LDAP::getSharedFolder($folder->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Delete the resource LDAP::deleteSharedFolder($folder); $this->assertSame(null, LDAP::getSharedFolder($folder->email)); } /** * 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' => [ - 'cn=imap-user,' . \config('ldap.hosted.root_dn') + 'cn=imap-user,' . \config('services.ldap.hosted.root_dn') ], '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::withEnvTenantContext()->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'] = 5242880; $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::withEnvTenantContext()->where('title', 'activesync')->first(); $sku_groupware = \App\Sku::withEnvTenantContext()->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); // Test degraded user $sku_storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_2fa = \App\Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user->status |= User::STATUS_DEGRADED; $user->update(['status' => $user->status]); $user->assignSku($sku_storage, 2); $user->assignSku($sku_2fa, 1); LDAP::updateUser($user->fresh()); $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = \config('app.storage.min_qty') * 1048576; $expected['nsroledn'] = [ - 'cn=2fa-user,' . \config('ldap.hosted.root_dn'), - 'cn=degraded-user,' . \config('ldap.hosted.root_dn') + 'cn=2fa-user,' . \config('services.ldap.hosted.root_dn'), + 'cn=degraded-user,' . \config('services.ldap.hosted.root_dn') ]; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // TODO: Test user who's owner is degraded // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } /** * Test handling errors on a resource creation * * @group ldap */ public function testCreateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create resource/'); $resource = new Resource([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => Resource::STATUS_ACTIVE, ]); LDAP::createResource($resource); } /** * Test handling errors on a group creation * * @group ldap */ public function testCreateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create group/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::createGroup($group); } /** * Test handling errors on a shared folder creation * * @group ldap */ public function testCreateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create shared folder/'); $folder = new SharedFolder([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => SharedFolder::STATUS_ACTIVE, ]); LDAP::createSharedFolder($folder); } /** * Test handling errors on user creation * * @group ldap */ public function testCreateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create user/'); $user = new User([ 'email' => 'test-non-existing-ldap@non-existing.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::createUser($user); } /** * 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 group * * @group ldap */ public function testUpdateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/group not found/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::updateGroup($group); } /** * Test handling update of a non-existing resource * * @group ldap */ public function testUpdateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/resource not found/'); $resource = new Resource([ 'email' => 'test-resource@kolab.org', ]); LDAP::updateResource($resource); } /** * Test handling update of a non-existing shared folder * * @group ldap */ public function testUpdateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/folder not found/'); $folder = new SharedFolder([ 'email' => 'test-folder-unknown@kolab.org', ]); LDAP::updateSharedFolder($folder); } /** * 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); } } diff --git a/src/tests/Feature/Controller/NGINXTest.php b/src/tests/Feature/Controller/NGINXTest.php index c8ae654e..8e5593a7 100644 --- a/src/tests/Feature/Controller/NGINXTest.php +++ b/src/tests/Feature/Controller/NGINXTest.php @@ -1,319 +1,319 @@ getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings([ 'limit_geo' => null, 'guam_enabled' => null, ]); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $this->useServicesUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); \App\CompanionApp::where('user_id', $john->id)->delete(); \App\AuthAttempt::where('user_id', $john->id)->delete(); $john->setSettings([ 'limit_geo' => null, 'guam_enabled' => null, ]); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); parent::tearDown(); } /** * Test the webhook */ public function testNGINXWebhook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Auth-Login-Attempt' => '1', 'Auth-Method' => 'plain', 'Auth-Pass' => $pass, 'Auth-Protocol' => 'imap', 'Auth-Ssl' => 'on', 'Auth-User' => 'john@kolab.org', 'Client-Ip' => '127.0.0.1', 'Host' => '127.0.0.1', 'Auth-SSL' => 'on', 'Auth-SSL-Verify' => 'SUCCESS', 'Auth-SSL-Subject' => '/CN=example.com', 'Auth-SSL-Issuer' => '/CN=example.com', 'Auth-SSL-Serial' => 'C07AD56B846B5BFF', 'Auth-SSL-Fingerprint' => '29d6a80a123d13355ed16b4b04605e29cb55a5ad' ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - $response->assertHeader('auth-port', \config('imap.imap_port')); + $response->assertHeader('auth-port', \config('services.imap.imap_port')); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Auth-Pass'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['Client-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // SMTP Auth Protocol $modifiedHeaders = $headers; $modifiedHeaders['Auth-Protocol'] = "smtp"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - $response->assertHeader('auth-server', gethostbyname(\config('smtp.host'))); - $response->assertHeader('auth-port', \config('smtp.port')); + $response->assertHeader('auth-server', gethostbyname(\config('services.smtp.host'))); + $response->assertHeader('auth-port', \config('services.smtp.port')); $response->assertHeader('auth-pass', $pass); // Empty Auth Protocol $modifiedHeaders = $headers; $modifiedHeaders['Auth-Protocol'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // Guam $john->setSettings(['guam_enabled' => 'true']); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); - $response->assertHeader('auth-server', gethostbyname(\config('imap.host'))); - $response->assertHeader('auth-port', \config('imap.guam_port')); + $response->assertHeader('auth-server', gethostbyname(\config('services.imap.host'))); + $response->assertHeader('auth-port', \config('services.imap.guam_port')); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Geo-lockin (failure) $john->setSettings(['limit_geo' => '["PL","US"]']); $headers['Auth-Protocol'] = 'imap'; $headers['Client-Ip'] = '127.0.0.1'; $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); $authAttempt = \App\AuthAttempt::where('ip', $headers['Client-Ip'])->where('user_id', $john->id)->first(); $this->assertSame('geolocation', $authAttempt->reason); \App\AuthAttempt::where('user_id', $john->id)->delete(); // Geo-lockin (success) \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); $this->assertCount(0, \App\AuthAttempt::where('user_id', $john->id)->get()); // Token auth (valid) $modifiedHeaders['Auth-Pass'] = AuthUtils::tokenCreate($john->id); $modifiedHeaders['Auth-Protocol'] = 'smtp'; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'OK'); // Token auth (invalid payload) $modifiedHeaders['Auth-User'] = 'jack@kolab.org'; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx"); $response->assertStatus(200); $response->assertHeader('auth-status', 'authentication failure'); } /** * Test the httpauth webhook */ public function testNGINXHttpAuthHook(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); $pass = \App\Utils::generatePassphrase(); $headers = [ 'Php-Auth-Pw' => $pass, 'Php-Auth-User' => 'john@kolab.org', 'X-Forwarded-For' => '127.0.0.1', 'X-Forwarded-Proto' => 'https', 'X-Original-Uri' => '/iRony/', 'X-Real-Ip' => '127.0.0.1', ]; // Pass $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // domain.tld\username $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "kolab.org\\john"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Invalid Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = "Invalid"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Password $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-Pw'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(401); // Empty User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Invalid User $modifiedHeaders = $headers; $modifiedHeaders['Php-Auth-User'] = "foo@kolab.org"; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // Empty Ip $modifiedHeaders = $headers; $modifiedHeaders['X-Real-Ip'] = ""; $response = $this->withHeaders($modifiedHeaders)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); $companionApp = $this->getTestCompanionApp( 'testdevice', $john, [ 'notification_token' => 'notificationtoken', 'mfa_enabled' => 1, 'name' => 'testname', ] ); // 2-FA with accepted auth attempt $authAttempt = \App\AuthAttempt::recordAuthAttempt($john, "127.0.0.1"); $authAttempt->accept(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); // Deny $authAttempt->deny(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(403); // 2-FA without device $companionApp->delete(); $response = $this->withHeaders($headers)->get("api/webhooks/nginx-httpauth"); $response->assertStatus(200); } /** * Test the roundcube webhook */ public function testRoundcubeHook(): void { $this->markTestIncomplete(); } /** * Test the cyrus-sasl webhook */ public function testCyrusSaslHook(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/DataMigrator/IMAPTest.php b/src/tests/Feature/DataMigrator/IMAPTest.php index be38ca25..6b158f57 100644 --- a/src/tests/Feature/DataMigrator/IMAPTest.php +++ b/src/tests/Feature/DataMigrator/IMAPTest.php @@ -1,151 +1,151 @@ initAccount($src); $this->initAccount($dst); // Add some mail to the source account $this->imapAppend($src, 'INBOX', 'mail/1.eml'); $this->imapAppend($src, 'INBOX', 'mail/2.eml', ['SEEN']); $this->imapCreateFolder($src, 'ImapDataMigrator'); $this->imapCreateFolder($src, 'ImapDataMigrator/Test'); $this->imapAppend($src, 'ImapDataMigrator/Test', 'mail/1.eml'); $this->imapAppend($src, 'ImapDataMigrator/Test', 'mail/2.eml'); // Clean up the destination folders structure $this->imapDeleteFolder($dst, 'ImapDataMigrator/Test'); $this->imapDeleteFolder($dst, 'ImapDataMigrator'); // Run the migration $migrator = new Engine(); $migrator->migrate($src, $dst, ['force' => true, 'sync' => true]); // Assert the destination mailbox $dstFolders = $this->imapListFolders($dst); $this->assertContains('ImapDataMigrator', $dstFolders); $this->assertContains('ImapDataMigrator/Test', $dstFolders); // Assert the migrated messages $dstMessages = $this->imapList($dst, 'INBOX'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['SEEN'], array_keys($msg->flags)); $dstMessages = $this->imapList($dst, 'ImapDataMigrator/Test'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); // TODO: Test INTERNALDATE migration } /** * Test IMAP to IMAP incremental migration run * * @group imap * @depends testInitialMigration */ public function testIncrementalMigration(): void { - $uri = \config('imap.uri'); + $uri = \config('services.imap.uri'); if (strpos($uri, '://') === false) { $uri = 'imap://' . $uri; } $src = new Account(str_replace('://', '://john%40kolab.org:simple123@', $uri)); $dst = new Account(str_replace('://', '://jack%40kolab.org:simple123@', $uri)); // Add some mails to the source account $srcMessages = $this->imapList($src, 'INBOX'); $msg1 = array_shift($srcMessages); $msg2 = array_shift($srcMessages); $this->imapAppend($src, 'INBOX', 'mail/3.eml'); $this->imapAppend($src, 'INBOX', 'mail/4.eml'); $this->imapFlagAs($src, 'INBOX', $msg1->uid, ['SEEN']); $this->imapFlagAs($src, 'INBOX', $msg2->uid, ['UNSEEN', 'FLAGGED']); // Run the migration $migrator = new Engine(); $migrator->migrate($src, $dst, ['force' => true, 'sync' => true]); // In INBOX two new messages and two old ones with changed flags // The order of messages tells us that there was no redundant APPEND+DELETE $dstMessages = $this->imapList($dst, 'INBOX'); $this->assertCount(4, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['SEEN'], array_keys($msg->flags)); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['FLAGGED'], array_keys($msg->flags)); $ids = array_map(fn ($msg) => $msg->messageID, $dstMessages); $this->assertSame(['',''], $ids); // Nothing changed in the other folder $dstMessages = $this->imapList($dst, 'ImapDataMigrator/Test'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); } } diff --git a/src/tests/Feature/Jobs/Group/UpdateTest.php b/src/tests/Feature/Jobs/Group/UpdateTest.php index 7f7dc3f0..83ab698a 100644 --- a/src/tests/Feature/Jobs/Group/UpdateTest.php +++ b/src/tests/Feature/Jobs/Group/UpdateTest.php @@ -1,92 +1,92 @@ deleteTestGroup('group@kolab.org'); } public function tearDown(): void { $this->deleteTestGroup('group@kolab.org'); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { Queue::fake(); // Test non-existing group ID $job = new \App\Jobs\Group\UpdateJob(123); $job->handle(); $this->assertTrue($job->hasFailed()); $this->assertSame("Group 123 could not be found in the database.", $job->failureMessage); // Create the group $group = $this->getTestGroup('group@kolab.org', ['members' => []]); if (!\config('app.with_ldap')) { $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); $this->assertTrue($job->isDeleted()); $this->markTestSkipped(); } // Create the group in LDAP LDAP::createGroup($group); // Test if group properties (members) actually changed in LDAP $group->members = ['test1@gmail.com']; $group->status |= Group::STATUS_LDAP_READY; $group->save(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); $ldapGroup = LDAP::getGroup($group->email); - $root_dn = \config('ldap.hosted.root_dn'); + $root_dn = \config('services.ldap.hosted.root_dn'); $this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']); // Test that suspended group is removed from LDAP $group->suspend(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); $this->assertNull(LDAP::getGroup($group->email)); // Test that unsuspended group is added back to LDAP $group->unsuspend(); $job = new \App\Jobs\Group\UpdateJob($group->id); $job->handle(); /** @var array */ $ldapGroup = LDAP::getGroup($group->email); $this->assertNotNull($ldapGroup); $this->assertSame($group->email, $ldapGroup['mail']); $this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']); } }