diff --git a/src/app/Domain.php b/src/app/Domain.php index 33013a6d..eafd7c40 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,502 +1,485 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } - $wallet_id = $user->wallets()->first()->id; - - foreach ($package->skus as $sku) { - for ($i = $sku->pivot->qty; $i > 0; $i--) { - \App\Entitlement::create( - [ - 'wallet_id' => $wallet_id, - 'sku_id' => $sku->id, - 'cost' => $sku->pivot->cost(), - 'fee' => $sku->pivot->fee(), - 'entitleable_id' => $this->id, - 'entitleable_type' => Domain::class - ] - ); - } - } - - return $this; + return $this->assignPackageAndWallet($package, $user->wallets()->first()); } /** * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { return self::withEnvTenantContext() ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } // if the domain is now active, it is not new anymore. if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { $new_status ^= self::STATUS_NEW; } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Checks if there are any objects (users/aliases/groups) in a domain. * Note: Public domains are always reported not empty. * * @return bool True if there are no objects assigned, False otherwise */ public function isEmpty(): bool { if ($this->isPublic()) { return false; } // FIXME: These queries will not use indexes, so maybe we should consider // wallet/entitlements to search in objects that belong to this domain account? $suffix = '@' . $this->namespace; $suffixLen = strlen($suffix); return !( \App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * List the users of a domain, so long as the domain is not a public registration domain. * Note: It returns only users with a mailbox. * * @return \App\User[] A list of users */ public function users(): array { if ($this->isPublic()) { return []; } $wallet = $this->wallet(); if (!$wallet) { return []; } $mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } $entitlements = $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id)->get(); $users = []; foreach ($entitlements as $entitlement) { $users[] = $entitlement->entitleable; } return $users; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } } diff --git a/src/app/Group.php b/src/app/Group.php index 6746d79d..e442e4db 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,267 +1,235 @@ id)) { - throw new \Exception("Group not yet exists"); - } - - if ($this->entitlements()->count()) { - throw new \Exception("Group already assigned to a wallet"); - } - - $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'group')->first(); - $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); - - \App\Entitlement::create([ - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, - 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, - 'entitleable_id' => $this->id, - 'entitleable_type' => Group::class - ]); - - return $this; - } /** * Returns group domain. * * @return ?\App\Domain The domain group belongs to, NULL if it does not exist */ public function domain(): ?Domain { list($local, $domainName) = explode('@', $this->email); return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a group (including deleted groups). * * @param string $email Email address * @param bool $return_group Return Group instance instead of boolean * * @return \App\Group|bool True or Group model object if found, False otherwise */ public static function emailExists(string $email, bool $return_group = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $group = self::withTrashed()->where('email', $email)->first(); if ($group) { return $return_group ? $group : true; } return false; } /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses * * @return array Email addresses of the group members, as an array */ public function getMembersAttribute($members): array { return $members ? explode(',', $members) : []; } /** * Returns whether this group is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this group is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this group is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this group is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this group is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Ensure the email is appropriately cased. * * @param string $email Group email address */ public function setEmailAttribute(string $email) { $this->attributes['email'] = strtolower($email); } /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members */ public function setMembersAttribute(array $members): void { $members = array_unique(array_filter(array_map('strtolower', $members))); sort($members); $this->attributes['members'] = implode(',', $members); } /** * Group status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid group status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Suspend this group. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Group::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this group. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Group::STATUS_SUSPENDED; $this->save(); } } diff --git a/src/app/Resource.php b/src/app/Resource.php index 7345b755..e0f4cbff 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,209 +1,176 @@ id)) { - throw new \Exception("Resource not yet exists"); - } - - if ($this->entitlements()->count()) { - throw new \Exception("Resource already assigned to a wallet"); - } - - $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); - $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); - - \App\Entitlement::create([ - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, - 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, - 'entitleable_id' => $this->id, - 'entitleable_type' => Resource::class - ]); - - return $this; - } - /** * Returns the resource domain. * * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist */ public function domain(): ?Domain { if (isset($this->domain)) { $domainName = $this->domain; } else { list($local, $domainName) = explode('@', $this->email); } return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a resource (including deleted resources). * * @param string $email Email address * @param bool $return_resource Return Resource instance instead of boolean * * @return \App\Resource|bool True or Resource model object if found, False otherwise */ public static function emailExists(string $email, bool $return_resource = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $resource = self::withTrashed()->where('email', $email)->first(); if ($resource) { return $return_resource ? $resource : true; } return false; } /** * Returns whether this resource is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this resource is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this resource's folder exists in IMAP. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this resource is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this resource is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Resource status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_DELETED, self::STATUS_IMAP_READY, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid resource status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php index e22df5cf..a8d68d88 100644 --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -1,229 +1,196 @@ id)) { - throw new \Exception("Shared folder not yet exists"); - } - - if ($this->entitlements()->count()) { - throw new \Exception("Shared folder already assigned to a wallet"); - } - - $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->first(); - $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); - - \App\Entitlement::create([ - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, - 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, - 'entitleable_id' => $this->id, - 'entitleable_type' => SharedFolder::class - ]); - - return $this; - } - /** * Returns the shared folder domain. * * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist */ public function domain(): ?Domain { if (isset($this->domain)) { $domainName = $this->domain; } else { list($local, $domainName) = explode('@', $this->email); } return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a shared folder (including deleted folders). * * @param string $email Email address * @param bool $return_folder Return SharedFolder instance instead of boolean * * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise */ public static function emailExists(string $email, bool $return_folder = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $folder = self::withTrashed()->where('email', $email)->first(); if ($folder) { return $return_folder ? $folder : true; } return false; } /** * Returns whether this folder is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this folder is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this folder exists in IMAP. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this folder is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this folder is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Folder status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_DELETED, self::STATUS_IMAP_READY, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid shared folder status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Folder type mutator * * @throws \Exception */ public function setTypeAttribute($type) { if (!in_array($type, self::SUPPORTED_TYPES)) { throw new \Exception("Invalid shared folder type: {$type}"); } $this->attributes['type'] = $type; } } diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php index 52e0deae..bef0c947 100644 --- a/src/app/Traits/EntitleableTrait.php +++ b/src/app/Traits/EntitleableTrait.php @@ -1,39 +1,195 @@ skus as $sku) { + for ($i = $sku->pivot->qty; $i > 0; $i--) { + Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), + 'entitleable_id' => $this->id, + 'entitleable_type' => self::class + ]); + } + } + + return $this; + } + + /** + * Assign a Sku to an entitleable object. + * + * @param \App\Sku $sku The sku to assign. + * @param int $count Count of entitlements to add + * + * @return $this + * @throws \Exception + */ + public function assignSku(Sku $sku, int $count = 1) + { + // TODO: I guess wallet could be parametrized in future + $wallet = $this->wallet(); + $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); + + // TODO: Make sure the SKU can be assigned to the object + + while ($count > 0) { + Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, + 'entitleable_id' => $this->id, + 'entitleable_type' => self::class + ]); + + $exists++; + $count--; + } + + return $this; + } + + /** + * Assign the object to a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return $this + * @throws \Exception + */ + public function assignToWallet(Wallet $wallet) + { + if (empty($this->id)) { + throw new \Exception("Object not yet exists"); + } + + if ($this->entitlements()->count()) { + throw new \Exception("Object already assigned to a wallet"); + } + + // Find the SKU title, e.g. \App\SharedFolder -> shared-folder + // Note: it does not work with User/Domain model (yet) + $title = Str::kebab(\class_basename(self::class)); + + $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); + $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); + + Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, + 'entitleable_id' => $this->id, + 'entitleable_type' => self::class + ]); + + return $this; + } + /** * Entitlements for this object. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { - return $this->hasMany(\App\Entitlement::class, 'entitleable_id', 'id') + return $this->hasMany(Entitlement::class, 'entitleable_id', 'id') ->where('entitleable_type', self::class); } + /** + * Check if an entitlement for the specified SKU exists. + * + * @param string $title The SKU title + * + * @return bool True if specified SKU entitlement exists + */ + public function hasSku(string $title): bool + { + $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); + + if (!$sku) { + return false; + } + + return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; + } + + /** + * Remove a number of entitlements for the SKU. + * + * @param \App\Sku $sku The SKU + * @param int $count The number of entitlements to remove + * + * @return $this + */ + public function removeSku(Sku $sku, int $count = 1) + { + $entitlements = $this->entitlements() + ->where('sku_id', $sku->id) + ->orderBy('cost', 'desc') + ->orderBy('created_at') + ->get(); + + $entitlements_count = count($entitlements); + + foreach ($entitlements as $entitlement) { + if ($entitlements_count <= $sku->units_free) { + continue; + } + + if ($count > 0) { + $entitlement->delete(); + $entitlements_count--; + $count--; + } + } + + return $this; + } + /** * Returns the wallet by which the object is controlled * * @return ?\App\Wallet A wallet object */ - public function wallet(): ?\App\Wallet + public function wallet(): ?Wallet { $entitlement = $this->entitlements()->withTrashed()->orderBy('created_at', 'desc')->first(); if ($entitlement) { return $entitlement->wallet; } // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case if ($this instanceof \App\User) { return $this->wallets()->first(); } return null; } } diff --git a/src/app/User.php b/src/app/User.php index 595cf940..6b086486 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,909 +1,809 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * 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; } - $wallet_id = $this->wallets()->first()->id; - - foreach ($package->skus as $sku) { - for ($i = $sku->pivot->qty; $i > 0; $i--) { - \App\Entitlement::create( - [ - 'wallet_id' => $wallet_id, - 'sku_id' => $sku->id, - 'cost' => $sku->pivot->cost(), - 'fee' => $sku->pivot->fee(), - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] - ); - } - } - - return $user; + 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()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } - /** - * Assign a Sku to a user. - * - * @param \App\Sku $sku The sku to assign. - * @param int $count Count of entitlements to add - * - * @return \App\User Self - * @throws \Exception - */ - public function assignSku(Sku $sku, int $count = 1): User - { - // TODO: I guess wallet could be parametrized in future - $wallet = $this->wallet(); - $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); - - while ($count > 0) { - \App\Entitlement::create([ - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, - 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, - 'entitleable_id' => $this->id, - 'entitleable_type' => User::class - ]); - - $exists++; - $count--; - } - - 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 (!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); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * 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 Domain[] List of Domain objects */ public function domains($with_accounts = true, $with_public = true): array { $domains = []; if ($with_public) { if ($this->tenant_id) { $domains = Domain::where('tenant_id', $this->tenant_id); } else { $domains = Domain::withEnvTenantContext(); } $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } if ($with_accounts) { foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } } return $domains; } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * 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; } /** * 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) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } - /** - * Check if user has an entitlement for the specified SKU. - * - * @param string $title The SKU title - * - * @return bool True if specified SKU entitlement exists - */ - public function hasSku(string $title): bool - { - $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); - - if (!$sku) { - return false; - } - - return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; - } - /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 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' => \App\Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } - /** - * Remove a number of entitlements for the SKU. - * - * @param \App\Sku $sku The SKU - * @param int $count The number of entitlements to remove - * - * @return User Self - */ - public function removeSku(Sku $sku, int $count = 1): User - { - $entitlements = $this->entitlements() - ->where('sku_id', $sku->id) - ->orderBy('cost', 'desc') - ->orderBy('created_at') - ->get(); - - $entitlements_count = count($entitlements); - - foreach ($entitlements as $entitlement) { - if ($entitlements_count <= $sku->units_free) { - continue; - } - - if ($count > 0) { - $entitlement->delete(); - $entitlements_count--; - $count--; - } - } - - return $this; - } - /** * 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) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return \App\Resource::select(['resources.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'resources.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', \App\Resource::class); } /** * 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) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', \App\SharedFolder::class); } 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; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * 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) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $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 status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * 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) { \Log::info("Successful authentication for {$this->email}"); // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$this->email}"); } return $authenticated; } /** * Retrieve and authenticate a user * * @param string $username The username. * @param string $password The password in plain text. * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array { $user = User::where('email', $username)->first(); if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } if (!$user->validateCredentials($username, $password)) { return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. $secondFactor = request()->secondfactor; } try { (new \App\Auth\SecondFactor($user))->validate($secondFactor); } catch (\Exception $e) { return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; } return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } throw OAuthServerException::invalidCredentials(); } return $result['user']; } }