diff --git a/src/.env.example b/src/.env.example index 37557836..4e98b688 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,156 +1,162 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES=en,de APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] +PGP_ENABLED= +PGP_BINARY= +PGP_AGENT= +PGP_GPGCONF= +PGP_LENGTH= + REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" JWT_SECRET= JWT_TTL=60 COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php index 13e114d3..af928e5c 100644 --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -1,322 +1,311 @@ [], ]; /** * Class constructor * * @param \App\User $user User object */ public function __construct($user) { $this->user = $user; parent::__construct(); } /** * Validate 2-factor authentication code * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse|null */ public function requestHandler($request) { // get list of configured authentication factors $factors = $this->factors(); // do nothing if no factors configured if (empty($factors)) { return null; } if (empty($request->secondfactor) || !is_string($request->secondfactor)) { $errors = ['secondfactor' => \trans('validation.2fareq')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // try to verify each configured factor foreach ($factors as $factor) { // verify the submitted code // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') { // if ($request->secondfactor === 'dummy') { // return null; // } // } else if ($this->verify($factor, $request->secondfactor)) { return null; } } $errors = ['secondfactor' => \trans('validation.2fainvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } /** * Remove all configured 2FA methods for the current user * * @return bool True on success, False otherwise */ public function removeFactors(): bool { $this->cache = []; $prefs = []; $prefs[$this->key2property('blob')] = null; $prefs[$this->key2property('factors')] = null; return $this->savePrefs($prefs); } /** * Returns a list of 2nd factor methods configured for the user */ public function factors(): array { // First check if the user has the 2FA SKU if ($this->user->hasSku('2fa')) { $factors = (array) $this->enumerate(); $factors = array_unique($factors); return $factors; } return []; } /** * Helper method to verify the given method/code tuple * * @param string $factor Factor identifier (:) * @param string $code Authentication code * * @return bool True on successful validation */ protected function verify($factor, $code): bool { $driver = $this->getDriver($factor); return $driver->verify($code, time()); } /** * Load driver class for the given authentication factor * * @param string $factor Factor identifier (:) * * @return \Kolab2FA\Driver\Base */ protected function getDriver(string $factor) { list($method) = explode(':', $factor, 2); $config = \config('2fa.' . $method, []); $driver = \Kolab2FA\Driver\Base::factory($factor, $config); // configure driver $driver->storage = $this; $driver->username = $this->user->email; return $driver; } /** * Helper for seeding a Roundcube account with 2FA setup * for testing. * * @param string $email Email address */ public static function seed(string $email): void { $config = [ 'kolab_2fa_blob' => [ 'totp:8132a46b1f741f88de25f47e' => [ 'label' => 'Mobile app (TOTP)', 'created' => 1584573552, 'secret' => 'UAF477LDHZNWVLNA', 'active' => true, ], // 'dummy:dummy' => [ // 'active' => true, // ], ], 'kolab_2fa_factors' => [ 'totp:8132a46b1f741f88de25f47e', // 'dummy:dummy', ] ]; self::dbh()->table('users')->updateOrInsert( ['username' => $email, 'mail_host' => '127.0.0.1'], ['preferences' => serialize($config)] ); } /** * Helper for generating current TOTP code for a test user * * @param string $email Email address * * @return string Generated code */ public static function code(string $email): string { $sf = new self(\App\User::where('email', $email)->first()); $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); return (string) $driver->get_code(); } //****************************************************** // Methods required by Kolab2FA Storage Base //****************************************************** /** * Initialize the storage driver with the given config options */ public function init(array $config) { $this->config = array_merge($this->config, $config); } /** * List methods activated for this user */ public function enumerate() { if ($factors = $this->getFactors()) { return array_keys(array_filter($factors, function ($prop) { return !empty($prop['active']); })); } return []; } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $factors = $this->getFactors(); $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { \Log::debug(__METHOD__ . ' ' . @json_encode($value)); // TODO: Not implemented return false; } /** * Remove the data stored for the given key */ public function remove($key) { return $this->write($key, null); } /** * */ protected function getFactors(): array { $prefs = $this->getPrefs(); $key = $this->key2property('blob'); return isset($prefs[$key]) ? (array) $prefs[$key] : []; } /** * */ protected function key2property($key) { // map key to configured property name if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { return $this->config['keymap'][$key]; } // default return 'kolab_2fa_' . $key; } /** * Gets user preferences from Roundcube users table */ protected function getPrefs() { $user = $this->dbh()->table('users') ->select('preferences') ->where('username', strtolower($this->user->email)) ->first(); return $user ? (array) unserialize($user->preferences) : null; } /** * Saves user preferences in Roundcube users table. * This will merge into old preferences */ protected function savePrefs($prefs) { $old_prefs = $this->getPrefs(); if (!is_array($old_prefs)) { return false; } $prefs = array_merge($old_prefs, $prefs); $this->dbh()->table('users') ->where('username', strtolower($this->user->email)) ->update(['preferences' => serialize($prefs)]); return true; } /** * Init connection to the Roundcube database */ public static function dbh() { - $dsn = \config('2fa.dsn'); - - if (empty($dsn)) { - \Log::warning("2-FACTOR database not configured"); - - return DB::connection(\config('database.default')); - } - - \Config::set('database.connections.2fa', ['url' => $dsn]); - - return DB::connection('2fa'); + return \App\Backends\Roundcube::dbh(); } } diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php new file mode 100644 index 00000000..88f0fd55 --- /dev/null +++ b/src/app/Backends/PGP.php @@ -0,0 +1,208 @@ +deleteDirectory($homedir); + } else { + Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir)); + + foreach (Storage::disk('pgp')->files($homedir) as $subdir) { + Storage::disk('pgp')->deleteDirectory($subdir); + } + } + + // Remove all files from the Enigma database + // Note: This will cause existing files in the Roundcube filesystem + // to be removed, but only if the user used the Enigma functionality + Roundcube::enigmaCleanup($user->email); + } + + /** + * Generate a keypair. + * This will also initialize the user GPG homedir content. + * + * @param \App\User $user User object + * @param string $email Email address to use for the key + * + * @throws \Exception + */ + public static function keypairCreate(User $user, string $email): void + { + self::initGPG($user, true); + + if ($user->email === $email) { + // Make sure the homedir is empty for a new user + self::homedirCleanup($user); + } + + $keygen = new \Crypt_GPG_KeyGenerator(self::$config); + + $key = $keygen + // ->setPassphrase() + // ->setExpirationDate(0) + ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) + ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) + ->generateKey(null, $email); + + // Store the keypair in Roundcube Enigma storage + self::dbSave(true); + + // Get the ASCII armored data of the public key + $armor = self::$gpg->exportPublicKey((string) $key, true); + + // Register the public key in DNS + self::keyRegister($email, $armor); + + // FIXME: Should we remove the files from the worker filesystem? + // They are still in database and Roundcube hosts' filesystem + } + + /** + * List (public and private) keys from a user keyring. + * + * @param \App\User $user User object + * + * @returns \Crypt_GPG_Key[] List of keys + * @throws \Exception + */ + public static function listKeys(User $user): array + { + self::initGPG($user); + + return self::$gpg->getKeys(''); + } + + /** + * Debug logging callback + */ + public static function logDebug($msg): void + { + \Log::debug("[GPG] $msg"); + } + + /** + * Register the key in the WOAT DNS system + * + * @param string $email Email address + * @param string $key The ASCII-armored key content + */ + public static function keyRegister(string $email, string $key) + { + // TODO + } + + /** + * Remove the key from the WOAT DNS system + * + * @param string $email Email address + */ + public static function keyUnregister(string $email) + { + // TODO + } + + /** + * Prepare Crypt_GPG configuration + */ + private static function initConfig(User $user, $nosync = false): void + { + if (!empty(self::$config) && self::$config['email'] == $user->email) { + return; + } + + $debug = \config('app.debug'); + $binary = \config('pgp.binary'); + $agent = \config('pgp.agent'); + $gpgconf = \config('pgp.gpgconf'); + + $dir = self::setHomedir($user); + $options = [ + 'email' => $user->email, // this one is not a Crypt_GPG option + 'dir' => $dir, // this one is not a Crypt_GPG option + 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir, + 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, + ]; + + if ($binary) { + $options['binary'] = $binary; + } + + if ($agent) { + $options['agent'] = $agent; + } + + if ($gpgconf) { + $options['gpgconf'] = $gpgconf; + } + + self::$config = $options; + + // Sync the homedir directory content with the Enigma storage + if (!$nosync) { + self::dbSync(); + } + } + + /** + * Initialize Crypt_GPG + */ + private static function initGPG(User $user, $nosync = false): void + { + self::initConfig($user, $nosync); + + self::$gpg = new \Crypt_GPG(self::$config); + } + + /** + * Prepare a homedir for the user + */ + private static function setHomedir(User $user): string + { + // Create a subfolder using two first digits of the user ID + $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email; + + Storage::disk('pgp')->makeDirectory($dir); + + return $dir; + } + + /** + * Synchronize keys database of a user + */ + private static function dbSync(): void + { + Roundcube::enigmaSync(self::$config['email'], self::$config['dir']); + } + + /** + * Save the keys database + */ + private static function dbSave($is_empty = false): void + { + Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty); + } +} diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php new file mode 100644 index 00000000..809e79a5 --- /dev/null +++ b/src/app/Backends/Roundcube.php @@ -0,0 +1,256 @@ +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) { + $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') + ->where('file_id', $record->file_id) + ->first(); + + $data = $record ? base64_decode($record->data) : false; + + if ($data === false) { + \Log::error("Failed to sync $file ({$record->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(true, $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"); + } + } + } + + /** + * 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')); + + return (int) $db->table(self::USERS_TABLE)->insertGetId( + [ + 'username' => $email, + 'mail_host' => $uri['host'], + 'created' => now()->toDateTimeString(), + ], + '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/Jobs/PGP/KeyCreateJob.php b/src/app/Jobs/PGP/KeyCreateJob.php new file mode 100644 index 00000000..15393702 --- /dev/null +++ b/src/app/Jobs/PGP/KeyCreateJob.php @@ -0,0 +1,69 @@ +isDeleted()`), or + * * the user is actually deleted (`$user->deleted_at`) + * * the alias is actually deleted + * * there was an error in keypair generation process + */ +class KeyCreateJob extends UserJob +{ + /** + * Create a new job instance. + * + * @param int $userId User identifier. + * @param string $userEmail User email address for the key + * + * @return void + */ + public function __construct(int $userId, string $userEmail) + { + $this->userId = $userId; + $this->userEmail = $userEmail; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + $user = $this->getUser(); + + if (!$user) { + 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; + } + + if ( + $this->userEmail != $user->email + && !$user->aliases()->where('alias', $this->userEmail)->exists() + ) { + $this->fail(new \Exception("Alias {$this->userEmail} is actually deleted.")); + return; + } + + \App\Backends\PGP::keypairCreate($user, $this->userEmail); + } +} diff --git a/src/app/Jobs/PGP/KeyUnregisterJob.php b/src/app/Jobs/PGP/KeyUnregisterJob.php new file mode 100644 index 00000000..4c17f477 --- /dev/null +++ b/src/app/Jobs/PGP/KeyUnregisterJob.php @@ -0,0 +1,42 @@ +email = $email; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + \App\Backends\PGP::keyUnregister($this->email); + } +} diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index 8f9891a5..791e06e6 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,84 +1,93 @@ alias = \strtolower($alias->alias); list($login, $domain) = explode('@', $alias->alias); $domain = Domain::where('namespace', $domain)->first(); if (!$domain) { \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); return false; } if ($alias->user) { if ($alias->user->tenant_id != $domain->tenant_id) { \Log::error("Reseller for user '{$alias->user->email}' and domain '{$domain->namespace}' differ."); return false; } } return true; } /** * Handle the user alias "created" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function created(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); + + if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias); + } } } /** * Handle the user setting "updated" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function updated(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); } } /** * Handle the user setting "deleted" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function deleted(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); + + if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyUnregisterJob::dispatch($alias->alias); + } } } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 7e3c86f9..1bc37b4e 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,374 +1,378 @@ id) { while (true) { $allegedly_unique = \App\Utils::uuidInt(); if (!User::withTrashed()->find($allegedly_unique)) { $user->{$user->getKeyName()} = $allegedly_unique; break; } } } $user->email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; $user->tenant_id = \config('app.tenant_id'); } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => 'CHF', /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); + + if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); + } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { if ($user->isForceDeleting()) { $this->forceDeleting($user); return; } // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = Entitlement::where('entitleable_id', $user->id) ->where('entitleable_type', User::class)->get(); foreach ($entitlements as $entitlement) { $entitlement->delete(); } // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; $groups = []; $entitlements = []; foreach ($assignments as $entitlement) { if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } } // Domains/users/entitlements need to be deleted one by one to make sure // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', array_unique($users))->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { $_domain->delete(); } } if (!empty($groups)) { foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { $_group->delete(); } } foreach ($entitlements as $entitlement) { $entitlement->delete(); } // FIXME: What do we do with user wallets? \App\Jobs\User\DeleteJob::dispatch($user->id); } /** * Handle the "deleting" event on forceDelete() call. * * @param User $user The user that is being deleted. * * @return void */ public function forceDeleting(User $user) { // TODO: We assume that at this moment all belongings are already soft-deleted. // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; $groups = []; $users = []; foreach ($assignments as $entitlement) { $entitlements[] = $entitlement->id; if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ( $entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id ) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } } // Remove the user "direct" entitlements explicitely, if they belong to another // user's wallet they will not be removed by the wallets foreign key cascade Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->forceDelete(); // Users need to be deleted one by one to make sure observers can do the proper cleanup. if (!empty($users)) { foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) { $_user->forceDelete(); } } // Domains can be just removed if (!empty($domains)) { Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); } // Groups can be just removed if (!empty($groups)) { Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) ->delete(); Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { $wallets = $user->wallets()->pluck('id')->all(); // Restore user entitlements // We'll restore only these that were deleted last. So, first we get // the maximum deleted_at timestamp and then use it to select // entitlements for restore $deleted_at = \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->max('deleted_at'); if ($deleted_at) { $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // Restore user entitlements \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->where('deleted_at', '>=', $threshold) ->update(['updated_at' => now(), 'deleted_at' => null]); // Note: We're assuming that cost of entitlements was correct // on user deletion, so we don't have to re-calculate it again. } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\User\ReadJob::dispatch($user->id); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php index 76a46e68..a4b572bf 100644 --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -1,104 +1,105 @@ id != $tenantId) { $tenant = null; if ($tenantId) { $tenant = self::findOrFail($tenantId); } } // Supported options (TODO: document this somewhere): // - app.name (tenants.title will be returned) // - app.public_url and app.url // - app.support_url // - mail.from.address and mail.from.name // - mail.reply_to.address and mail.reply_to.name // - app.kb.account_delete and app.kb.account_suspended + // - pgp.enable if ($key == 'app.name') { return $tenant ? $tenant->title : \config($key); } $value = $tenant ? $tenant->getSetting($key) : null; return $value !== null ? $value : \config($key); } /** * Discounts assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function discounts() { return $this->hasMany('App\Discount'); } /** * Any (additional) settings of this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\TenantSetting'); } /** * SignupInvitations assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function signupInvitations() { return $this->hasMany('App\SignupInvitation'); } /* * Returns the wallet of the tanant (reseller's wallet). * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); return $user ? $user->wallets->first() : null; } } diff --git a/src/composer.json b/src/composer.json index eb4764cd..da982cf5 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,84 +1,85 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.3", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.13", "dyrynda/laravel-nullable-fields": "*", "fideloper/proxy": "^4.0", "guzzlehttp/guzzle": "^7.3", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/horizon": "^3", "laravel/tinker": "^2.4", "mlocati/spf-lib": "^3.0", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", + "pear/crypt_gpg": "dev-master", "silviolleite/laravelpwa": "^2.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-er-diagram-generator": "^1.3", "code-lts/doctum": "^5.1", "kirschbaum-development/mail-intercept": "^0.2.4", "laravel/dusk": "~6.15.0", "nunomaduro/larastan": "^0.7", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^9" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/2fa.php b/src/config/2fa.php index 85017364..df8a8765 100644 --- a/src/config/2fa.php +++ b/src/config/2fa.php @@ -1,14 +1,12 @@ [ 'digits' => (int) env('MFA_TOTP_DIGITS', 6), 'interval' => (int) env('MFA_TOTP_INTERVAL', 30), 'digest' => env('MFA_TOTP_DIGEST', 'sha1'), 'issuer' => env('APP_NAME', 'Laravel'), ], - 'dsn' => env('MFA_DSN'), - ]; diff --git a/src/config/database.php b/src/config/database.php index 1e5c49f0..b0b4cc2d 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,152 +1,156 @@ env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], '2fa' => [ 'driver' => 'mysql', 'url' => env('MFA_DSN') ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'timezone' => '+00:00', 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'schema' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, ], + + 'roundcube' => [ + 'url' => env('DB_ROUNDCUBE_URL', env('MFA_DSN')), + ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'predis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 1), ], ], ]; diff --git a/src/config/filesystems.php b/src/config/filesystems.php index 77fa5ded..cdab2570 100644 --- a/src/config/filesystems.php +++ b/src/config/filesystems.php @@ -1,69 +1,74 @@ env('FILESYSTEM_DRIVER', 'local'), /* |-------------------------------------------------------------------------- | Default Cloud Filesystem Disk |-------------------------------------------------------------------------- | | Many applications store files both locally and in the cloud. For this | reason, you may specify a default "cloud" driver here. This driver | will be bound as the Cloud disk implementation in the container. | */ 'cloud' => env('FILESYSTEM_CLOUD', 's3'), /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Here you may configure as many filesystem "disks" as you wish, and you | may even configure multiple disks of the same driver. Defaults have | been setup for each driver as an example of the required options. | | Supported Drivers: "local", "ftp", "sftp", "s3", "rackspace" | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), ], + 'pgp' => [ + 'driver' => 'local', + 'root' => storage_path('app/keys'), + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), ], ], ]; diff --git a/src/config/pgp.php b/src/config/pgp.php new file mode 100644 index 00000000..55571107 --- /dev/null +++ b/src/config/pgp.php @@ -0,0 +1,20 @@ + env('PGP_ENABLE', false), + + // gpg binary location + 'binary' => env('PGP_BINARY'), + + // gpg-agent location + 'agent' => env('PGP_AGENT'), + + // gpgconf location + 'gpgconf' => env('PGP_GPGCONF'), + + // Default size of the new RSA key + 'length' => (int) env('PGP_LENGTH', 3072), + +]; diff --git a/src/phpunit.xml b/src/phpunit.xml index 29013e4e..cbb78f98 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,45 +1,46 @@ tests/Unit tests/Functional tests/Feature tests/Browser ./app + diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php new file mode 100644 index 00000000..2f3e6c86 --- /dev/null +++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php @@ -0,0 +1,123 @@ +getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $user = $this->getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group pgp + */ + public function testHandle(): void + { + $user = $this->getTestUser('john@kolab.org'); + + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); + $job->handle(); + + // Assert the Enigma storage has been initialized and contains the key + $files = Roundcube::enigmaList($user->email); + // TODO: More detailed asserts on the filestore content, but it's specific to GPG version + $this->assertTrue(count($files) > 1); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(1, $keys); + + $userIds = $keys[0]->getUserIds(); + $this->assertCount(1, $userIds); + $this->assertSame($user->email, $userIds[0]->getEmail()); + $this->assertSame('', $userIds[0]->getName()); + $this->assertSame('', $userIds[0]->getComment()); + $this->assertSame(true, $userIds[0]->isValid()); + $this->assertSame(false, $userIds[0]->isRevoked()); + + $key = $keys[0]->getPrimaryKey(); + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(true, $key->hasPrivate()); + $this->assertSame(true, $key->canSign()); + $this->assertSame(false, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + $key = $keys[0]->getSubKeys()[1]; + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(false, $key->canSign()); + $this->assertSame(true, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + // TODO: Assert the public key in DNS? + + // Test an alias + Queue::fake(); + UserAlias::create(['user_id' => $user->id, 'alias' => 'test-alias@kolab.org']); + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, 'test-alias@kolab.org'); + $job->handle(); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(2, $keys); + + $userIds = $keys[1]->getUserIds(); + $this->assertCount(1, $userIds); + $this->assertSame('test-alias@kolab.org', $userIds[0]->getEmail()); + $this->assertSame('', $userIds[0]->getName()); + $this->assertSame('', $userIds[0]->getComment()); + $this->assertSame(true, $userIds[0]->isValid()); + $this->assertSame(false, $userIds[0]->isRevoked()); + + $key = $keys[1]->getPrimaryKey(); + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(true, $key->hasPrivate()); + $this->assertSame(true, $key->canSign()); + $this->assertSame(false, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + + $key = $keys[1]->getSubKeys()[1]; + $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); + $this->assertSame(0, $key->getExpirationDate()); + $this->assertSame((int) \config('pgp.length'), $key->getLength()); + $this->assertSame(false, $key->canSign()); + $this->assertSame(true, $key->canEncrypt()); + $this->assertSame(false, $key->isRevoked()); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index a497bcfd..379d39e9 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,909 +1,949 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { + \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { - // Fake the queue, assert that no jobs were pushed... Queue::fake(); - Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } + /** + * Verify user creation process invokes the PGP keys creation job (if configured) + */ + public function testCreatePGPJob(): void + { + Queue::fake(); + + \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); + + $user = User::create([ + 'email' => 'user-test@' . \config('app.domain') + ]); + + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + + Queue::assertPushed( + \App\Jobs\PGP\KeyCreateJob::class, + function ($job) use ($user) { + $userEmail = TestCase::getObjectProperty($job, 'userEmail'); + $userId = TestCase::getObjectProperty($job, 'userId'); + + return $userEmail === $user->email + && $userId === $user->id; + } + ); + } + /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::hasSku() method */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 5); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); + Queue::fake(); + // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); + $user->tenant->setSetting('pgp.enable', 1); + // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); + + $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); + Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); + $user->tenant->setSetting('pgp.enable', 1); + // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); + $user->tenant->setSetting('pgp.enable', 0); + Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); + Queue::assertPushed(\App\Jobs\PGP\KeyUnregisterJob::class, 1); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } }