diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php index 7e5a3cc..432c4bc 100644 --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -1,196 +1,194 @@ 'datetime', 'last_seen' => 'datetime' ]; /** * Prepare a date for array / JSON serialization. * * Required to not omit timezone and match the format of update_at/created_at timestamps. * * @param \DateTimeInterface $date * @return string */ protected function serializeDate(\DateTimeInterface $date): string { return Carbon::instance($date)->toIso8601ZuluString('microseconds'); } /** * Returns true if the authentication attempt is accepted. * * @return bool */ public function isAccepted(): bool { - if ($this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at) { - return true; - } - return false; + return $this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at; } /** * Returns true if the authentication attempt is denied. * * @return bool */ public function isDenied(): bool { - return ($this->status == self::STATUS_DENIED); + return $this->status == self::STATUS_DENIED; } /** * Accept the authentication attempt. */ public function accept($reason = AuthAttempt::REASON_NONE) { $this->expires_at = Carbon::now()->addHours(8); $this->status = self::STATUS_ACCEPTED; $this->reason = $reason; $this->save(); } /** * Deny the authentication attempt. */ public function deny($reason = AuthAttempt::REASON_NONE) { $this->status = self::STATUS_DENIED; $this->reason = $reason; $this->save(); } /** * Notify the user of this authentication attempt. * * @return bool false if there was no means to notify */ public function notify(): bool { return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); } /** * Notify the user and wait for a confirmation. */ private function notifyAndWait() { if (!$this->notify()) { //FIXME if the webclient can confirm too we don't need to abort here. \Log::warning("There is no 2fa device to notify."); return false; } \Log::debug("Authentication attempt: {$this->id}"); $confirmationTimeout = 120; $timeout = Carbon::now()->addSeconds($confirmationTimeout); do { if ($this->isDenied()) { \Log::debug("The authentication attempt was denied {$this->id}"); return false; } if ($this->isAccepted()) { \Log::debug("The authentication attempt was accepted {$this->id}"); return true; } if ($timeout < Carbon::now()) { \Log::debug("The authentication attempt timed-out: {$this->id}"); return false; } sleep(2); $this->refresh(); } while (true); } /** * Record a new authentication attempt or update an existing one. * * @param \App\User $user The user attempting to authenticate. * @param string $clientIP The ip the authentication attempt is coming from. * * @return \App\AuthAttempt */ public static function recordAuthAttempt(\App\User $user, $clientIP) { $authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); if (!$authAttempt) { $authAttempt = new \App\AuthAttempt(); $authAttempt->ip = $clientIP; $authAttempt->user_id = $user->id; } $authAttempt->last_seen = Carbon::now(); $authAttempt->save(); return $authAttempt; } /** * Trigger a notification if necessary and wait for confirmation. * * @return bool Returns true if the attempt is accepted on confirmation */ public function waitFor2FA(): bool { if ($this->isAccepted()) { return true; } + if ($this->isDenied()) { return false; } if (!$this->notifyAndWait()) { return false; } return $this->isAccepted(); } } diff --git a/src/app/Domain.php b/src/app/Domain.php index 0162f9f..d60f0e8 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,480 +1,417 @@ 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; } 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 []; } return $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id) ->get() ->pluck('entitleable') ->all(); } /** * 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 e442e4d..58e1564 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,235 +1,126 @@ 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/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index ce67f32..8fc7a4a 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,360 +1,341 @@ checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { $domain = Domain::withEnvTenantContext()->find($id); if (empty($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($domain)) { return $this->errorResponse(403); } // It is possible to delete domain only if there are no users/aliases/groups using it. if (!$domain->isEmpty()) { $response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')]; return response()->json($response, 422); } $domain->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-delete-success'), ]); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } // Validate the input $v = Validator::make( $request->all(), [ 'namespace' => ['required', 'string', new UserEmailDomain()] ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $namespace = \strtolower(request()->input('namespace')); // Domain already exists if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) { // Check if the domain is soft-deleted and belongs to the same user $deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet()) && $wallet->owner && $wallet->owner->id == $owner->id; if (!$deleteBeforeCreate) { $errors = ['namespace' => \trans('validation.domainnotavailable')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!$package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Force-delete the existing domain if it is soft-deleted and belongs to the same user if (!empty($deleteBeforeCreate)) { $domain->forceDelete(); } // Create the domain $domain = Domain::create([ 'namespace' => $namespace, 'type' => \App\Domain::TYPE_EXTERNAL, ]); $domain->assignPackage($package, $owner); DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.domain-create-success'), ]); } /** * Get the information about the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $this->objectToClient($domain, true); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['mx'] = self::getMXConfig($domain->namespace); // Domain configuration, e.g. spf whitelist $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); // Entitlements info $response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain); // Some basic information about the domain wallet $wallet = $domain->wallet(); $response['wallet'] = $wallet->toArray(); if ($wallet->discount) { $response['wallet']['discount'] = $wallet->discount->discount; $response['wallet']['discount_description'] = $wallet->discount->description; } return response()->json($response); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** - * Prepare domain statuses for the UI - * - * @param \App\Domain $domain Domain object - * - * @return array Statuses array - */ - protected static function objectState($domain): array - { - return [ - 'isLdapReady' => $domain->isLdapReady(), - 'isConfirmed' => $domain->isConfirmed(), - 'isVerified' => $domain->isVerified(), - 'isSuspended' => $domain->isSuspended(), - 'isActive' => $domain->isActive(), - 'isDeleted' => $domain->isDeleted() || $domain->trashed(), - ]; - } - - /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo($domain): array { // If that is not a public domain, add domain specific steps return self::processStateInfo( $domain, [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"], ] ); } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php index 55d8baf..241abf8 100644 --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -1,344 +1,327 @@ true, 'distlist-ldap-ready' => $group->isLdapReady(), ] ); } /** * Create a new group record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $email = $request->input('email'); $members = $request->input('members'); $errors = []; $rules = [ 'name' => 'required|string|max:191', ]; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; } else { list(, $domainName) = explode('@', $email); $rules['name'] = ['required', 'string', new GroupName($owner, $domainName)]; } // Validate the group name $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = array_merge($errors, $v->errors()->toArray()); } // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ($members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === \strtolower($email)) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create the group $group = new Group(); $group->name = $request->input('name'); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-create-success'), ]); } /** * Update a group. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($group)) { return $this->errorResponse(403); } $owner = $group->wallet()->owner; $name = $request->input('name'); $members = $request->input('members'); $errors = []; // Validate the group name if ($name !== null && $name != $group->name) { list(, $domainName) = explode('@', $group->email); $rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = array_merge($errors, $v->errors()->toArray()); } else { $group->name = $name; } } // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ((array) $members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === $group->email) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $group->members = $members; $group->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-update-success'), ]); } /** * Execute (synchronously) specified step in a group setup process. * * @param \App\Group $group Group object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Group $group, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($group->domain(), $step); } switch ($step) { case 'distlist-ldap-ready': // Group not in LDAP, create it $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $group->refresh(); return $group->isLdapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** - * Prepare group statuses for the UI - * - * @param \App\Group $group Group object - * - * @return array Statuses array - */ - protected static function objectState($group): array - { - return [ - 'isLdapReady' => $group->isLdapReady(), - 'isSuspended' => $group->isSuspended(), - 'isActive' => $group->isActive(), - 'isDeleted' => $group->isDeleted() || $group->trashed(), - ]; - } - - /** * Validate an email address for use as a group email * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateGroupEmail($email, \App\User $user): ?string { if (empty($email)) { return \trans('validation.required', ['attribute' => 'email']); } if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', \strtolower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } $wallet = $domain->wallet(); // The domain must be owned by the user if (!$wallet || !$user->wallets()->find($wallet->id)) { return \trans('validation.domainnotavailable'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => [new \App\Rules\UserEmailLocal(true)]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if a user with specified address already exists if (User::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Validate an email address for use as a group member * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateMemberEmail($email, \App\User $user): ?string { $v = Validator::make( ['email' => $email], ['email' => [new \App\Rules\ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // A local domain user must exist if (!User::where('email', \strtolower($email))->first()) { list($login, $domain) = explode('@', \strtolower($email)); $domain = Domain::where('namespace', $domain)->first(); // We return an error only if the domain belongs to the group owner if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { return \trans('validation.notalocaluser'); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php index 0e09118..add86e7 100644 --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -1,209 +1,192 @@ $resource->isLdapReady(), - 'isImapReady' => $resource->isImapReady(), - 'isActive' => $resource->isActive(), - 'isDeleted' => $resource->isDeleted() || $resource->trashed(), - ]; - } - - /** * Resource status (extended) information * * @param \App\Resource $resource Resource object * * @return array Status information */ public static function statusInfo($resource): array { return self::processStateInfo( $resource, [ 'resource-new' => true, 'resource-ldap-ready' => $resource->isLdapReady(), 'resource-imap-ready' => $resource->isImapReady(), ] ); } /** * Create a new resource record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $domain = request()->input('domain'); $rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); $resource->domain = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-create-success'), ]); } /** * Update a resource. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($resource)) { return $this->errorResponse(403); } $owner = $resource->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the resource name if ($name !== null && $name != $resource->name) { $domainName = explode('@', $resource->email, 2)[1]; $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $resource->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $resource->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.resource-update-success'), ]); } /** * Execute (synchronously) specified step in a resource setup process. * * @param \App\Resource $resource Resource object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Resource $resource, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($resource->domain(), $step); } switch ($step) { case 'resource-ldap-ready': // Resource not in LDAP, create it $job = new \App\Jobs\Resource\CreateJob($resource->id); $job->handle(); $resource->refresh(); return $resource->isLdapReady(); case 'resource-imap-ready': // Resource not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\Resource\VerifyJob::dispatch($resource->id); return null; } $job = new \App\Jobs\Resource\VerifyJob($resource->id); $job->handle(); $resource->refresh(); return $resource->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php index 146a365..2d86d1f 100644 --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -1,214 +1,197 @@ $folder->isLdapReady(), - 'isImapReady' => $folder->isImapReady(), - 'isActive' => $folder->isActive(), - 'isDeleted' => $folder->isDeleted() || $folder->trashed(), - ]; - } - - /** * SharedFolder status (extended) information * * @param \App\SharedFolder $folder SharedFolder object * * @return array Status information */ public static function statusInfo($folder): array { return self::processStateInfo( $folder, [ 'shared-folder-new' => true, 'shared-folder-ldap-ready' => $folder->isLdapReady(), 'shared-folder-imap-ready' => $folder->isImapReady(), ] ); } /** * Create a new shared folder record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $domain = request()->input('domain'); $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], 'type' => ['required', 'string', new SharedFolderType()] ]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = request()->input('name'); $folder->type = request()->input('type'); $folder->domain = $domain; $folder->save(); $folder->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-create-success'), ]); } /** * Update a shared folder. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Shared folder identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $folder = SharedFolder::find($id); if (!$this->checkTenant($folder)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($folder)) { return $this->errorResponse(403); } $owner = $folder->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the folder name if ($name !== null && $name != $folder->name) { $domainName = explode('@', $folder->email, 2)[1]; $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $folder->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $folder->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-update-success'), ]); } /** * Execute (synchronously) specified step in a shared folder setup process. * * @param \App\SharedFolder $folder Shared folder object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(SharedFolder $folder, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($folder->domain(), $step); } switch ($step) { case 'shared-folder-ldap-ready': // Shared folder not in LDAP, create it $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isLdapReady(); case 'shared-folder-imap-ready': // Shared folder not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id); return null; } $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 6669cde..36d32ec 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,730 +1,726 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { - return [ - 'isImapReady' => $user->isImapReady(), - 'isLdapReady' => $user->isLdapReady(), - 'isSuspended' => $user->isSuspended(), - 'isActive' => $user->isActive(), - 'isDeleted' => $user->isDeleted() || $user->trashed(), - 'isDegraded' => $user->isDegraded(), - 'isAccountDegraded' => $user->isDegraded(true), - ]; + $state = parent::objectState($user); + + $state['isAccountDegraded'] = $user->isDegraded(true); + + return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group or resource with specified address already exists if ( ($existing = Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) ) { // If this is a deleted group/resource in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php index e8451fc..d9173e4 100644 --- a/src/app/Http/Controllers/RelationController.php +++ b/src/app/Http/Controllers/RelationController.php @@ -1,335 +1,350 @@ model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($resource)) { return $this->errorResponse(403); } $resource->delete(); return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-delete-success"), ]); } /** * Listing of resources belonging to the authenticated user. * * The resource entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $method = Str::plural(\lcfirst(\class_basename($this->model))); $query = call_user_func_array([$user, $method], $this->relationArgs); if (!empty($this->order)) { foreach ($this->order as $col) { $query->orderBy($col); } } $result = $query->get() ->map(function ($resource) { return $this->objectToClient($resource); }); return response()->json($result); } /** * Prepare resource statuses for the UI * * @param object $resource Resource object * * @return array Statuses array */ protected static function objectState($resource): array { - return []; + $state = []; + + $reflect = new \ReflectionClass(get_class($resource)); + + foreach (array_keys($reflect->getConstants()) as $const) { + if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') { + $method = Str::camel('is_' . strtolower(substr($const, 7))); + $state[$method] = $resource->{$method}(); + } + } + + if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) { + $state['isDeleted'] = $resource->trashed(); + } + + return $state; } /** * Prepare a resource object for the UI. * * @param object $object An object * @param bool $full Include all object properties * * @return array Object information */ protected function objectToClient($object, bool $full = false): array { if ($full) { $result = $object->toArray(); } else { $result = ['id' => $object->id]; foreach ($this->objectProps as $prop) { $result[$prop] = $object->{$prop}; } } $result = array_merge($result, $this->objectState($object)); return $result; } /** * Object status' process information. * * @param object $object The object to process * @param array $steps The steps definition * * @return array Process state information */ protected static function processStateInfo($object, array $steps): array { $process = []; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), ]; if (is_array($state)) { $step['link'] = $state[1]; $state = $state[0]; } $step['state'] = $state; $process[] = $step; } // Add domain specific steps if (method_exists($object, 'domain')) { $domain = $object->domain(); // If that is not a public domain if ($domain && !$domain->isPublic()) { $domain_status = API\V4\DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Object status' process information update. * * @param object $object The object to process * * @return array Process state information */ protected function processStateUpdate($object): array { $response = $this->statusInfo($object); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = $this->statusInfo($object); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } return $response; } /** * Set the resource configuration. * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $resource = $this->model::find($id); if (!method_exists($this->model, 'setConfig')) { return $this->errorResponse(404); } if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($resource)) { return $this->errorResponse(403); } $errors = $resource->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans("app.{$this->label}-setconfig-success"), ]); } /** * Display information of a resource specified by $id. * * @param string $id The resource to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->objectToClient($resource, true); if (!empty($statusInfo = $this->statusInfo($resource))) { $response['statusInfo'] = $statusInfo; } // Resource configuration, e.g. sender_policy, invitation_policy, acl if (method_exists($resource, 'getConfig')) { $response['config'] = $resource->getConfig(); } return response()->json($response); } /** * Fetch resource status (and reload setup process) * * @param int $id Resource identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $resource = $this->model::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($resource)) { return $this->errorResponse(403); } $response = $this->processStateUpdate($resource); $response = array_merge($response, $this->objectState($resource)); return response()->json($response); } /** * Resource status (extended) information * * @param object $resource Resource object * * @return array Status information */ public static function statusInfo($resource): array { return []; } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index c1d6042..702bbdd 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,405 +1,402 @@ wallet = $wallet; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); /* // Steps for old "first suspend then delete" approach $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminder', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminder', // Try to top-up the wallet before suspending the account self::THRESHOLD_BEFORE_SUSPEND => 'topUpWallet', // Suspend the account self::THRESHOLD_SUSPEND => 'suspendAccount', // Warn about the upcomming account deletion self::THRESHOLD_BEFORE_DELETE => 'warnBeforeDelete', // Delete the account self::THRESHOLD_DELETE => 'deleteAccount', ]; */ // Steps for "demote instead of suspend+delete" approach $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminderForDegrade', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminderForDegrade', // Try to top-up the wallet before the account degradation self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', // Degrade the account self::THRESHOLD_DEGRADE => 'degradeAccount', ]; if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { $this->degradedReminder(); return self::THRESHOLD_DEGRADE_REMINDER; } foreach (array_reverse($steps, true) as $type => $method) { if (self::threshold($this->wallet, $type) < $now) { $this->{$method}(); return $type; } } return null; } /** * Send the initial reminder (for the suspend+delete process) */ protected function initialReminder() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } // TODO: Should we check if the account is already suspended? $this->sendMail(\App\Mail\NegativeBalance::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the initial reminder (for the process of degrading a account) */ protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $this->sendMail(\App\Mail\NegativeBalance::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the second reminder (for the suspend+delete process) */ protected function secondReminder() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } // TODO: Should we check if the account is already suspended? $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Send the second reminder (for the process of degrading a account) */ protected function secondReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Suspend the account (and send the warning) */ protected function suspendAccount() { if ($this->wallet->getSetting('balance_warning_suspended')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } // Suspend the account $this->wallet->owner->suspend(); foreach ($this->wallet->entitlements as $entitlement) { - if ( - $entitlement->entitleable_type == \App\Domain::class - || $entitlement->entitleable_type == \App\User::class - ) { + if (method_exists($entitlement->entitleable_type, 'suspend')) { $entitlement->entitleable->suspend(); } } $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_suspended', $now); } /** * Send the last warning before delete */ protected function warnBeforeDelete() { if ($this->wallet->getSetting('balance_warning_before_delete')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_before_delete', $now); } /** * Send the periodic reminder to the degraded account owners */ protected function degradedReminder() { // Sanity check if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { return; } $now = \Carbon\Carbon::now(); $last = $this->wallet->getSetting('degraded_last_reminder'); if ($last) { $last = new Carbon($last); $period = 14; if ($last->addDays($period) > $now) { return; } $this->sendMail(\App\Mail\DegradedAccountReminder::class, true); } $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); } /** * Degrade the account */ protected function degradeAccount() { // The account may be already deleted, or degraded if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $email = $this->wallet->owner->email; // The dirty work will be done by UserObserver $this->wallet->owner->degrade(); \Log::info( sprintf( "[WalletCheck] Account degraded %s (%s)", $this->wallet->id, $email ) ); $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); } /** * Delete the account */ protected function deleteAccount() { // TODO: This will not work when we actually allow multiple-wallets per account // but in this case we anyway have to change the whole thing // and calculate summarized balance from all wallets. // The dirty work will be done by UserObserver if ($this->wallet->owner) { $email = $this->wallet->owner->email; $this->wallet->owner->delete(); \Log::info( sprintf( "[WalletCheck] Account deleted %s (%s)", $this->wallet->id, $email ) ); } } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email */ protected function sendMail($class, $with_external = false): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params); } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } $thresholds = [ // A day before the second reminder self::THRESHOLD_BEFORE_REMINDER => 7 - 1, // Second notification self::THRESHOLD_REMINDER => 7, // A day before account suspension self::THRESHOLD_BEFORE_SUSPEND => 14 + 7 - 1, // Account suspension self::THRESHOLD_SUSPEND => 14 + 7, // Warning about the upcomming account deletion self::THRESHOLD_BEFORE_DELETE => 21 + 14 + 7 - 3, // Acount deletion self::THRESHOLD_DELETE => 21 + 14 + 7, // Last chance to top-up the wallet self::THRESHOLD_BEFORE_DEGRADE => 13, // Account degradation self::THRESHOLD_DEGRADE => 14, ]; if (!empty($thresholds[$type])) { return $negative_since->addDays($thresholds[$type]); } return null; } /** * Try to automatically top-up the wallet */ protected function topUpWallet(): void { PaymentsController::topUpWallet($this->wallet); } } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php index 009c22e..e7dd89e 100644 --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -1,111 +1,108 @@ currency = \config('app.currency'); } /** * Handle the wallet "deleting" event. * * Ensures that a wallet with a non-zero balance can not be deleted. * * Ensures that the wallet being deleted is not the last wallet for the user. * * Ensures that no entitlements are being billed to the wallet currently. * * @param Wallet $wallet The wallet being deleted. * * @return bool */ public function deleting(Wallet $wallet): bool { // can't delete a wallet that has any balance on it (positive and negative). if ($wallet->balance != 0.00) { return false; } if (!$wallet->owner) { throw new \Exception("Wallet: " . var_export($wallet, true)); } // can't remove the last wallet for the owner. if ($wallet->owner->wallets()->count() <= 1) { return false; } // can't remove a wallet that has billable entitlements attached. if ($wallet->entitlements()->count() > 0) { return false; } /* // can't remove a wallet that has payments attached. if ($wallet->payments()->count() > 0) { return false; } */ return true; } /** * Handle the wallet "updated" event. * * @param \App\Wallet $wallet The wallet. * * @return void */ public function updated(Wallet $wallet) { $negative_since = $wallet->getSetting('balance_negative_since'); if ($wallet->balance < 0) { if (!$negative_since) { $now = \Carbon\Carbon::now()->toDateTimeString(); $wallet->setSetting('balance_negative_since', $now); } } elseif ($negative_since) { $wallet->setSettings([ 'balance_negative_since' => null, 'balance_warning_initial' => null, 'balance_warning_reminder' => null, 'balance_warning_suspended' => null, 'balance_warning_before_delete' => null, ]); // FIXME: Since we use account degradation, should we leave suspended state untouched? // Un-suspend and un-degrade the account owner if ($wallet->owner) { $wallet->owner->unsuspend(); $wallet->owner->undegrade(); } // Un-suspend domains/users foreach ($wallet->entitlements as $entitlement) { - if ( - $entitlement->entitleable_type == \App\Domain::class - || $entitlement->entitleable_type == \App\User::class - ) { + if (method_exists($entitlement->entitleable_type, 'unsuspend')) { $entitlement->entitleable->unsuspend(); } } } } } diff --git a/src/app/Resource.php b/src/app/Resource.php index e0f4cbf..de89324 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,176 +1,97 @@ 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 a8d68d8..d17ac9c 100644 --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -1,196 +1,117 @@ 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/StatusPropertyTrait.php b/src/app/Traits/StatusPropertyTrait.php new file mode 100644 index 0000000..5c0eb3c --- /dev/null +++ b/src/app/Traits/StatusPropertyTrait.php @@ -0,0 +1,134 @@ +status & static::STATUS_ACTIVE) > 0; + } + + /** + * Returns whether this object is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return defined('static::STATUS_DELETED') && ($this->status & static::STATUS_DELETED) > 0; + } + + /** + * Returns whether this object is registered in IMAP. + * + * @return bool + */ + public function isImapReady(): bool + { + return defined('static::STATUS_IMAP_READY') && ($this->status & static::STATUS_IMAP_READY) > 0; + } + + /** + * Returns whether this object is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return defined('static::STATUS_LDAP_READY') && ($this->status & static::STATUS_LDAP_READY) > 0; + } + + /** + * Returns whether this object is new. + * + * @return bool + */ + public function isNew(): bool + { + return defined('static::STATUS_NEW') && ($this->status & static::STATUS_NEW) > 0; + } + + /** + * Returns whether this object is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return defined('static::STATUS_SUSPENDED') && ($this->status & static::STATUS_SUSPENDED) > 0; + } + + /** + * Suspend this object. + * + * @return void + */ + public function suspend(): void + { + if (!defined('static::STATUS_SUSPENDED') || $this->isSuspended()) { + return; + } + + $this->status |= static::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Unsuspend this object. + * + * @return void + */ + public function unsuspend(): void + { + if (!defined('static::STATUS_SUSPENDED') || !$this->isSuspended()) { + return; + } + + $this->status ^= static::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Status property mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_states = [ + 'STATUS_NEW', + 'STATUS_ACTIVE', + 'STATUS_SUSPENDED', + 'STATUS_DELETED', + 'STATUS_LDAP_READY', + 'STATUS_IMAP_READY', + ]; + + foreach ($allowed_states as $const) { + if (!defined("static::$const")) { + continue; + } + + $value = constant("static::$const"); + + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } +} diff --git a/src/app/User.php b/src/app/User.php index 5d934d4..61777b6 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,835 +1,747 @@ 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; } 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; } /** * 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); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } /** * 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 \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->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE)); }); } 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; } /** * 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; } /** * 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 is active. - * - * @return bool - */ - public function isActive(): bool - { - return ($this->status & self::STATUS_ACTIVE) > 0; - } - - /** * 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 deleted. - * - * @return bool - */ - public function isDeleted(): bool - { - return ($this->status & self::STATUS_DELETED) > 0; - } - - /** - * Returns whether this user is registered in IMAP. - * - * @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 user 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; } /** * 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(\App\Resource::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(\App\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; } /** - * Suspend this user. - * - * @return void - */ - public function suspend(): void - { - if ($this->isSuspended()) { - return; - } - - $this->status |= User::STATUS_SUSPENDED; - $this->save(); - } - - /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** - * Unsuspend this user. - * - * @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) { return $this->entitleables(User::class, $with_accounts); } /** * 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, self::STATUS_DEGRADED, ]; 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']; } } diff --git a/src/database/migrations/2022_01_03_120000_signup_codes_indices.php b/src/database/migrations/2022_01_03_120000_signup_codes_indices.php index 0e45729..895d841 100644 --- a/src/database/migrations/2022_01_03_120000_signup_codes_indices.php +++ b/src/database/migrations/2022_01_03_120000_signup_codes_indices.php @@ -1,43 +1,43 @@ index('email'); $table->index('ip_address'); $table->index('expires_at'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table( 'signup_codes', function (Blueprint $table) { - $table->dropIndex('email'); - $table->dropIndex('ip_address'); - $table->dropIndex('expires_at'); + $table->dropIndex('signup_codes_email_index'); + $table->dropIndex('signup_codes_ip_address_index'); + $table->dropIndex('signup_codes_expires_at_index'); } ); } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 539c683..3e78fa3 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,16 +1,17 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' + - '#Access to undefined constant static\(App\\[a-zA-Z]+\)::STATUS_[A-Z_]+#' level: 4 parallel: processTimeout: 300.0 paths: - app/ - config/ - tests/