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']);
}
}