diff --git a/doc/RESELLER.md b/doc/RESELLER.md index 2b8c78a2..81d76b23 100644 --- a/doc/RESELLER.md +++ b/doc/RESELLER.md @@ -1,55 +1,104 @@ # Reseller (Tenant) System Let's start with defining some labels: - `owner` - an entity owning the Kolab installation - `user` - an entity that has a record in Kolab users database - `admin` - a user that is system's owner staff member. Has access to Admin Cockpit, has no mailbox. - `tenant` - an entity owning a reseller subsystem - `reseller` - a user that is tenant's staff member. Has access to Reseller Cockpit, has no mailbox. ## System deployment TODO ## System setup TODO ## Creating tenant/resellers/domains -TODO: How to create tenant, resellers, public domains? +1. Create a new reseller user + +``` +php artisan user:create admin@reseller.kolab.io --role=reseller +``` + +2. Create a tenant + +``` +php artisan tenant:create admin@reseller.kolab.io --title="Reseller Company" +``` + +3. Create a public domain (for customer signups) - this should be executed +on the tenant system. + +``` +php artisan scalpel:domain:create --namespace=reseller.kolab.io --type=1 --status=18 +``` + +4. List all tenants in the system + +``` +php artisan tenants --attr=title +``` + +5. Managing tenant settings + +``` +php artisan tenant:list-settings +php artisan scalpel:tenant-setting:create --tenant_id= --key=mail.sender.address --value=noreply@reseller.kolab.io +php artisan scalpel:tenant-setting:update --value=noreply@reseller.kolab.io +``` +For proper operation some settings need to be set for a tenant. They include: +`app.public_url`, `app.url`, `app.name`, `app.support_url`, +`mail.sender.address`, `mail.sender.name`, `mail.replyto.address`, `mail.replyto.name`. ## Plans and Packages The `tenant:create` command clones all active plans/packages/SKUs. So, all a new tenant will need is to define fees and cost for these new SKUs. Also maybe he does not need all of the plans/packages or wants some new ones? -TODO: How? With deployment seeder or CLI commands, admin UI? +The commands below need to be executed on the tenant system. +1. Listing plans/packages -## Fees +``` +php artisan plan:packages +php artisan package:skus +``` + +2. Listing all SKUs + +``` +php artisan skus --attr=title --attr=cost --attr=fee +``` + +3. Modifying SKU -TODO: How to set a fee? With deployment seeder or CLI commands, admin UI? +``` +php artisan scalpel:sku:update --cost=1000 --fee=900 +``` + + +## Fees Every SKU has a cost and fee defined. Both are monetary values (not percents). Cost is what a customer is paying. Fee is what the system owner gets. Tenant's profit is cost minus fee. Which means that when cost is lower than fee tenant's gonna pay the difference. Important facts about fees: - Discounts lower the cost for customers, but do not impact the fee. - Degraded users' cost is zero, but fee is getting payed by the tenant. - SKU's `units_free` definition impacts both cost and fee in the same way. Fees are getting applied to the tenant's wallet. Note that currently we're using wallet of the first tenant's reseller user (see `Tenant::wallet()`). I.e. it will have to change if we wanted to allow tenants to have more than one staff member. - -TODO: Examples? diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index c9149068..ea211793 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,281 +1,293 @@ commandPrefix == 'scalpel') { + return $object; + } + + $modelsWithOwner = [ + \App\Wallet::class, + ]; + + $tenantId = \config('app.tenant_id'); + + // Add tenant filter + if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($object::class))) { + $object = $object->withEnvTenantContext(); + } elseif (in_array($object::class, $modelsWithOwner)) { + $object = $object->whereExists(function ($query) use ($tenantId) { + $query->select(DB::raw(1)) + ->from('users') + ->whereRaw('wallets.user_id = users.id') + ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); + }); + } + + return $object; + } + /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ protected function createProgressBar($count, $message = null) { $bar = $this->output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { // @phpstan-ignore-next-line $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } - if ($this->commandPrefix == 'scalpel') { - return $model; - } - - $modelsWithOwner = [ - \App\Wallet::class, - ]; - - $tenantId = \config('app.tenant_id'); - - // Add tenant filter - if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { - $model = $model->withEnvTenantContext(); - } elseif (in_array($objectClass, $modelsWithOwner)) { - $model = $model->whereExists(function ($query) use ($tenantId) { - $query->select(DB::raw(1)) - ->from('users') - ->whereRaw('wallets.user_id = users.id') - ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); - }); - } - - return $model; + return $this->applyTenant($model); } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } /** * Find a shared folder. * * @param string $folder Folder ID or email * @param bool $withDeleted Include deleted * * @return \App\SharedFolder|null */ public function getSharedFolder($folder, $withDeleted = false) { return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); } /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } /** * Execute the console command. * * @return mixed */ public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); return false; } $this->info("VĂ¡monos!"); } return true; } /** * Checks that a model is soft-deletable * * @param string $class Model class name * * @return bool */ protected function isSoftDeletable($class) { return class_exists($class) && method_exists($class, 'forceDelete'); } /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; // @phpstan-ignore-next-line foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php new file mode 100644 index 00000000..d03ca143 --- /dev/null +++ b/src/app/Console/Commands/Scalpel/Sku/UpdateCommand.php @@ -0,0 +1,15 @@ +argument('email'); $packages = $this->option('package'); $password = $this->option('password'); $role = $this->option('role'); - list($local, $domainName) = explode('@', $email, 2); + $existingDeletedUser = null; + $packagesToAssign = []; - $domain = $this->getDomain($domainName); + if ($role === User::ROLE_ADMIN || $role === User::ROLE_RESELLER) { + if ($error = $this->validateUserWithRole($email)) { + $this->error($error); + return 1; + } - if (!$domain) { - $this->error("No such domain {$domainName}."); - return 1; - } + // TODO: Assigning user to an existing account + // TODO: Making him an operator of the reseller wallet + } else { + list($local, $domainName) = explode('@', $email, 2); - if ($domain->isPublic()) { - $this->error("Domain {$domainName} is public."); - return 1; - } + $domain = $this->getDomain($domainName); - $owner = $domain->wallet()->owner; - $existingDeletedUser = null; + if (!$domain) { + $this->error("No such domain {$domainName}."); + return 1; + } - // Validate email address - if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) { - $this->error("{$email}: {$error}"); - return 1; - } + if ($domain->isPublic()) { + $this->error("Domain {$domainName} is public."); + return 1; + } - if (!$password) { - $password = \App\Utils::generatePassphrase(); - } + $owner = $domain->wallet()->owner; - $packagesToAssign = []; - foreach ($packages as $package) { - $userPackage = $this->getObject(\App\Package::class, $package, 'title', false); - if (!$userPackage) { - $this->error("Invalid package: {$package}"); + // Validate email address + if ($error = UsersController::validateEmail($email, $owner, $existingDeletedUser)) { + $this->error("{$email}: {$error}"); return 1; } - $packagesToAssign[] = $userPackage; + + foreach ($packages as $package) { + $userPackage = $this->getObject(\App\Package::class, $package, 'title', false); + if (!$userPackage) { + $this->error("Invalid package: {$package}"); + return 1; + } + $packagesToAssign[] = $userPackage; + } + } + + if (!$password) { + $password = \App\Utils::generatePassphrase(); } - //TODO we need a central location for role validation - if ($role && $role != "admin" && $role != "reseller") { - $this->error("Tried to set an invalid role: {$role}"); + try { + $user = new \App\User(); + $user->email = $email; + $user->password = $password; + $user->role = $role; + } catch (\Exception $e) { + $this->error($e->getMessage()); return 1; } DB::beginTransaction(); if ($existingDeletedUser) { $this->info("Force deleting existing but deleted user {$email}"); $existingDeletedUser->forceDelete(); } - $user = \App\User::create( - [ - 'email' => $email, - 'password' => $password - ] - ); - $user->role = $role; $user->save(); + if (empty($owner)) { + $owner = $user; + } + foreach ($packagesToAssign as $package) { - $user->assignPackage($package); + $owner->assignPackage($package, $user); } DB::commit(); $this->info((string) $user->id); } + + /** + * Validate email address for a new admin/reseller user + * + * @param string $email Email address + * + * @return ?string Error message + */ + protected function validateUserWithRole($email): ?string + { + // Validate the email address (basicly just the syntax) + $v = Validator::make( + ['email' => $email], + ['email' => ['required', new ExternalEmail()]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()['email'][0]; + } + + // Check if an email is already taken + if ( + User::emailExists($email, true) + || User::aliasExists($email) + || \App\Group::emailExists($email, true) + || \App\Resource::emailExists($email, true) + || \App\SharedFolder::emailExists($email, true) + || \App\SharedFolder::aliasExists($email) + ) { + return "Email address is already in use"; + } + + return null; + } } diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php index cdcc2d0b..a0e52062 100644 --- a/src/app/Console/ObjectListCommand.php +++ b/src/app/Console/ObjectListCommand.php @@ -1,107 +1,109 @@ description = "List all {$this->objectName} objects"; $this->signature = $this->commandPrefix ? $this->commandPrefix . ":" : ""; if (!empty($this->objectNamePlural)) { $this->signature .= "{$this->objectNamePlural}"; } else { $this->signature .= "{$this->objectName}s"; } if ($this->isSoftDeletable($this->objectClass)) { $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; } $this->signature .= " {--attr=* : Attributes other than the primary unique key to include}" . "{--filter=* : Additional filter(s) or a raw SQL WHERE clause}"; parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { if ($this->isSoftDeletable($this->objectClass) && $this->option('with-deleted')) { $objects = $this->objectClass::withTrashed(); } else { $objects = new $this->objectClass(); } + $objects = $this->applyTenant($objects); + foreach ($this->option('filter') as $filter) { $objects = $this->applyFilter($objects, $filter); } foreach ($objects->cursor() as $object) { if ($object->deleted_at) { $this->info("{$this->toString($object)} (deleted at {$object->deleted_at}"); } else { $this->info("{$this->toString($object)}"); } } } /** * Apply pre-configured filter or raw WHERE clause to the main query. * * @param object $query Query builder * @param string $filter Pre-defined filter identifier or raw SQL WHERE clause * * @return object Query builder */ public function applyFilter($query, string $filter) { // Get objects marked as deleted, i.e. --filter=TRASHED // Note: For use with --with-deleted option if (strtolower($filter) === 'trashed') { return $query->whereNotNull('deleted_at'); } // Get objects with specified status, e.g. --filter=STATUS:SUSPENDED if (preg_match('/^status:([a-z]+)$/i', $filter, $matches)) { $status = strtoupper($matches[1]); $const = "{$this->objectClass}::STATUS_{$status}"; if (defined($const)) { return $query->where('status', '&', constant($const)); } throw new \Exception("Unknown status in --filter={$filter}"); } // Get objects older/younger than specified time, e.g. --filter=MIN-AGE:1Y if (preg_match('/^(min|max)-age:([0-9]+)([mdy])$/i', $filter, $matches)) { $operator = strtolower($matches[1]) == 'min' ? '<=' : '>='; $count = (int) $matches[2]; $period = strtolower($matches[3]); $date = \Carbon\Carbon::now(); if ($period == 'y') { $date->subYearsWithoutOverflow($count); } elseif ($period == 'm') { $date->subMonthsWithoutOverflow($count); } else { $date->subDays($count); } return $query->where('created_at', $operator, $date); } return $query->whereRaw($filter); } } diff --git a/src/app/User.php b/src/app/User.php index c054d8fc..11f0d508 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,862 +1,880 @@ The attributes that are mass assignable */ protected $fillable = [ 'id', 'email', 'password', 'password_ldap', 'status', ]; /** @var array The attributes that should be hidden for arrays */ protected $hidden = [ 'password', 'password_ldap', 'role' ]; /** @var array The attributes that can be null */ protected $nullable = [ 'password', - 'password_ldap' + 'password_ldap', + 'role', ]; /** @var array The attributes that should be cast */ protected $casts = [ 'created_at' => 'datetime:Y-m-d H:i:s', 'deleted_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( Wallet::class, // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { if (!$domain) { throw new \Exception("Attempted to assign a domain package without passing a domain."); } $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!is_object($object) || !method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } $query->where('domains.type', '&', Domain::TYPE_PUBLIC) ->where('domains.status', '&', Domain::STATUS_ACTIVE); }); } return $domains; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Storage items for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function fsItems() { return $this->hasMany(Fs\Item::class); } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * Returns whether this user is restricted. * * @return bool */ public function isRestricted(): bool { return ($this->status & self::STATUS_RESTRICTED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { return $this->hasMany(UserPassword::class); } /** * Restrict this user. * * @return void */ public function restrict(): void { if ($this->isRestricted()) { return; } $this->status |= User::STATUS_RESTRICTED; $this->save(); } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { return $this->entitleables(Resource::class, $with_accounts); } /** * Return rooms controlled by the current user. * * @param bool $with_accounts Include rooms assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function rooms($with_accounts = true) { return $this->entitleables(Meet\Room::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { return $this->entitleables(SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** * Un-restrict this user. * * @param bool $deep Unrestrict also all users in the account * * @return void */ public function unrestrict(bool $deep = false): void { if ($this->isRestricted()) { $this->status ^= User::STATUS_RESTRICTED; $this->save(); } // Remove the flag from all users in the user's wallets if ($deep) { $this->wallets->each(function ($wallet) { User::whereIn('id', $wallet->entitlements()->select('entitleable_id') ->where('entitleable_type', User::class)) ->each(function ($user) { $user->unrestrict(); }); }); } } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { return $this->entitleables(User::class, $with_accounts); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany(VerificationCode::class, 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany(Wallet::class); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = Hash::make($password); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } + /** + * User role mutator + * + * @param ?string $role The user role + */ + public function setRoleAttribute($role) + { + if ($role !== null && !in_array($role, [self::ROLE_ADMIN, self::ROLE_RESELLER])) { + throw new \Exception("Invalid role: {$role}"); + } + + $this->attributes['role'] = $role; + } + /** * Suspend all users/domains/groups in this account. */ public function suspendAccount(): void { $this->suspend(); foreach ($this->wallets as $wallet) { $wallet->entitlements()->select('entitleable_id', 'entitleable_type') ->distinct() ->get() ->each(function ($entitlement) { if ( defined($entitlement->entitleable_type . '::STATUS_SUSPENDED') && $entitlement->entitleable ) { $entitlement->entitleable->suspend(); } }); } } /** * Validate the user credentials * * @param string $username The username. * @param string $password The password in plain text. * @param bool $updatePassword Store the password if currently empty * * @return bool true on success */ public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool { $authenticated = false; if ($this->email === \strtolower($username)) { if (!empty($this->password)) { if (Hash::check($password, $this->password)) { $authenticated = true; } } elseif (!empty($this->password_ldap)) { if (substr($this->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($password . $salt, true) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password . $salt)) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$this->email}"); } } if ($authenticated) { // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } return $authenticated; } /** * Validate request location regarding geo-lockin * * @param string $ip IP address to check, usually request()->ip() * * @return bool */ public function validateLocation($ip): bool { $countryCodes = json_decode($this->getSetting('limit_geo', "[]")); if (empty($countryCodes)) { return true; } return in_array(\App\Utils::countryForIP($ip), $countryCodes); } /** * Check if multi factor verification is enabled * * @return bool */ public function mfaEnabled(): bool { return \App\CompanionApp::where('user_id', $this->id) ->where('mfa_enabled', true) ->exists(); } /** * Retrieve and authenticate a user * * @param string $username The username * @param string $password The password in plain text * @param ?string $clientIP The IP address of the client * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $clientIP = null, $verifyMFA = true): array { $error = null; if (!$clientIP) { $clientIP = request()->ip(); } $user = User::where('email', $username)->first(); if (!$user) { $error = AuthAttempt::REASON_NOTFOUND; } // Check user password if (!$error && !$user->validateCredentials($username, $password)) { $error = AuthAttempt::REASON_PASSWORD; } if ($verifyMFA) { // Check user (request) location if (!$error && !$user->validateLocation($clientIP)) { $error = AuthAttempt::REASON_GEOLOCATION; } // Check 2FA if (!$error) { try { (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor); } catch (\Exception $e) { $error = AuthAttempt::REASON_2FA_GENERIC; $message = $e->getMessage(); } } // Check 2FA - Companion App if (!$error && $user->mfaEnabled()) { $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$attempt->waitFor2FA()) { $error = AuthAttempt::REASON_2FA; } } } if ($error) { if ($user && empty($attempt)) { $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$attempt->isAccepted()) { $attempt->deny($error); $attempt->save(); $attempt->notify(); } } if ($user) { \Log::info("Authentication failed for {$user->email}"); } return ['reason' => $error, 'errorMessage' => $message ?? \trans("auth.error.{$error}")]; } \Log::info("Successful authentication for {$user->email}"); return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public static function findAndValidateForPassport($username, $password): User { $verifyMFA = true; if (request()->scope == "mfa") { \Log::info("Not validating MFA because this is a request for an mfa scope."); // Don't verify MFA if this is only an mfa token. // If we didn't do this, we couldn't pair backup devices. $verifyMFA = false; } $result = self::findAndAuthenticate($username, $password, null, $verifyMFA); if (isset($result['reason'])) { if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } // TODO: Display specific error message if 2FA via Companion App was expected? throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/tests/Feature/Console/Tenant/CreateTest.php b/src/tests/Feature/Console/Tenant/CreateTest.php index 628b3779..6ff9619f 100644 --- a/src/tests/Feature/Console/Tenant/CreateTest.php +++ b/src/tests/Feature/Console/Tenant/CreateTest.php @@ -1,98 +1,98 @@ deleteTestUser('test-tenant@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { if ($this->tenantId) { Queue::fake(); \App\User::where('tenant_id', $this->tenantId)->forceDelete(); \App\Plan::where('tenant_id', $this->tenantId)->delete(); \App\Package::where('tenant_id', $this->tenantId)->delete(); \App\Sku::where('tenant_id', $this->tenantId)->delete(); \App\Tenant::find($this->tenantId)->delete(); } parent::tearDown(); } /** * Test command runs */ public function testHandle(): void { Queue::fake(); // Warning: We're not using artisan() here, as this will not // allow us to test "empty output" cases // User not existing $code = \Artisan::call("tenant:create unknown@user.com"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("User not found.", $output); $user = $this->getTestUser('test-tenant@kolabnow.com'); $this->assertEmpty($user->role); $this->assertEquals($user->tenant_id, \config('app.tenant_id')); - // User not existing + // Existing user $code = \Artisan::call("tenant:create {$user->email} --title=\"Test Tenant\""); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertMatchesRegularExpression("/^Created tenant [0-9]+./", $output); preg_match("/^Created tenant ([0-9]+)./", $output, $matches); $this->tenantId = $matches[1]; $tenant = \App\Tenant::find($this->tenantId); $user->refresh(); $this->assertNotEmpty($tenant); $this->assertSame('Test Tenant', $tenant->title); $this->assertSame('reseller', $user->role); $this->assertSame($tenant->id, $user->tenant_id); // Assert cloned SKUs $skus = \App\Sku::where('tenant_id', \config('app.tenant_id'))->where('active', true); $skus->each(function ($sku) use ($tenant) { $sku_new = \App\Sku::where('tenant_id', $tenant->id) ->where('title', $sku->title)->get(); $this->assertSame(1, $sku_new->count()); $sku_new = $sku_new->first(); $this->assertSame($sku->name, $sku_new->name); $this->assertSame($sku->description, $sku_new->description); $this->assertSame($sku->cost, $sku_new->cost); $this->assertSame($sku->units_free, $sku_new->units_free); $this->assertSame($sku->period, $sku_new->period); $this->assertSame($sku->handler_class, $sku_new->handler_class); $this->assertNotEmpty($sku_new->active); }); // TODO: Plans, packages } } diff --git a/src/tests/Feature/Console/User/CreateTest.php b/src/tests/Feature/Console/User/CreateTest.php index 5885d4fd..338d81ad 100644 --- a/src/tests/Feature/Console/User/CreateTest.php +++ b/src/tests/Feature/Console/User/CreateTest.php @@ -1,76 +1,116 @@ deleteTestUser('user@kolab.org'); $this->deleteTestUser('admin@kolab.org'); + $this->deleteTestUser('reseller@unknown.domain.tld'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@kolab.org'); $this->deleteTestUser('admin@kolab.org'); + $this->deleteTestUser('reseller@unknown.domain.tld'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { Queue::fake(); // Warning: We're not using artisan() here, as this will not // allow us to test "empty output" cases + // Invalid email + $code = \Artisan::call("user:create jack..test@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("jack..test@kolab.org: The specified email is invalid.", $output); + + // Non-existing domain + $code = \Artisan::call("user:create jack@kolab"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such domain kolab.", $output); + // Existing email $code = \Artisan::call("user:create jack@kolab.org"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("jack@kolab.org: The specified email is not available.", $output); // Existing email (of a user alias) $code = \Artisan::call("user:create jack.daniels@kolab.org"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("jack.daniels@kolab.org: The specified email is not available.", $output); // Public domain not allowed in the group email address $code = \Artisan::call("user:create user@kolabnow.com"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("Domain kolabnow.com is public.", $output); - // Valid - $code = \Artisan::call("user:create user@kolab.org"); + // Valid (user) + $code = \Artisan::call("user:create user@kolab.org --package=kolab"); $output = trim(\Artisan::output()); $user = User::where('email', 'user@kolab.org')->first(); $this->assertSame(0, $code); $this->assertEquals($user->id, $output); + $this->assertSame(1, $user->countEntitlementsBySku('mailbox')); + $this->assertSame(1, $user->countEntitlementsBySku('groupware')); + $this->assertSame(5, $user->countEntitlementsBySku('storage')); - // Valid - $code = \Artisan::call("user:create admin@kolab.org --package=kolab --role=admin --password=simple123"); + // Valid (admin) + $code = \Artisan::call("user:create admin@kolab.org --role=admin --password=simple123"); $output = trim(\Artisan::output()); $user = User::where('email', 'admin@kolab.org')->first(); $this->assertSame(0, $code); $this->assertEquals($user->id, $output); - $this->assertEquals($user->role, "admin"); + $this->assertEquals($user->role, User::ROLE_ADMIN); + + // Valid (reseller) + $code = \Artisan::call("user:create reseller@unknown.domain.tld --role=reseller --password=simple123"); + $output = trim(\Artisan::output()); + $user = User::where('email', 'reseller@unknown.domain.tld')->first(); + $this->assertSame(0, $code); + $this->assertEquals($user->id, $output); + $this->assertEquals($user->role, User::ROLE_RESELLER); + + // Invalid role + $code = \Artisan::call("user:create unknwon@kolab.org --role=unknown"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Invalid role: unknown", $output); + + // Existing email (but with role-reseller) + $code = \Artisan::call("user:create jack@kolab.org --role=reseller"); + $output = trim(\Artisan::output()); + $user = User::where('email', 'reseller@unknown.domain.tld')->first(); + $this->assertSame(1, $code); + $this->assertEquals("Email address is already in use", $output); + + // TODO: Test a case where deleted user exists } } diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php index 5e403357..6c680396 100644 --- a/src/tests/Unit/UserTest.php +++ b/src/tests/Unit/UserTest.php @@ -1,119 +1,139 @@ 'user@email.com']); $user->password = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$04\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } /** * Test User password mutator */ public function testSetPasswordLdapAttribute(): void { $user = new User(['email' => 'user@email.com']); $user->password_ldap = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$04\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } /** * Test User password validation */ public function testPasswordValidation(): void { $user = new User(['email' => 'user@email.com']); $user->password = 'test'; $this->assertSame(true, $user->validateCredentials('user@email.com', 'test')); $this->assertSame(false, $user->validateCredentials('user@email.com', 'wrong')); $this->assertSame(true, $user->validateCredentials('User@Email.Com', 'test')); $this->assertSame(false, $user->validateCredentials('wrong', 'test')); // Ensure the fallback to the ldap_password works if the current password is empty $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $ldapUser = new User(['email' => 'user2@email.com']); $ldapUser->setRawAttributes(['password' => '', 'password_ldap' => $ssh512, 'email' => 'user2@email.com']); $this->assertSame($ldapUser->password, ''); $this->assertSame($ldapUser->password_ldap, $ssh512); $this->assertSame(true, $ldapUser->validateCredentials('user2@email.com', 'test', false)); $ldapUser->delete(); } + /** + * Test User role mutator + */ + public function testSetRoleAttribute(): void + { + $user = new User(['email' => 'user@email.com']); + + $user->role = User::ROLE_ADMIN; + $this->assertSame(User::ROLE_ADMIN, $user->role); + + $user->role = User::ROLE_RESELLER; + $this->assertSame(User::ROLE_RESELLER, $user->role); + + $user->role = null; + $this->assertSame(null, $user->role); + + $this->expectException(\Exception::class); + $user->role = 'unknown'; + } + /** * Test basic User funtionality */ public function testStatus(): void { $statuses = [ User::STATUS_NEW, User::STATUS_ACTIVE, User::STATUS_SUSPENDED, User::STATUS_DELETED, User::STATUS_IMAP_READY, User::STATUS_LDAP_READY, User::STATUS_DEGRADED, User::STATUS_RESTRICTED, ]; $users = \Tests\Utils::powerSet($statuses); foreach ($users as $user_statuses) { $user = new User( [ 'email' => 'user@email.com', 'status' => \array_sum($user_statuses), ] ); $this->assertTrue($user->isNew() === in_array(User::STATUS_NEW, $user_statuses)); $this->assertTrue($user->isActive() === in_array(User::STATUS_ACTIVE, $user_statuses)); $this->assertTrue($user->isSuspended() === in_array(User::STATUS_SUSPENDED, $user_statuses)); $this->assertTrue($user->isDeleted() === in_array(User::STATUS_DELETED, $user_statuses)); $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); $this->assertTrue($user->isDegraded() === in_array(User::STATUS_DEGRADED, $user_statuses)); $this->assertTrue($user->isRestricted() === in_array(User::STATUS_RESTRICTED, $user_statuses)); } } /** * Test setStatusAttribute exception */ public function testStatusInvalid(): void { $this->expectException(\Exception::class); $user = new User( [ 'email' => 'user@email.com', 'status' => 1234567, ] ); } }