diff --git a/src/app/Domain.php b/src/app/Domain.php index 8abb89d6..a97e681b 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,420 +1,420 @@ The attributes that should be cast */ protected $casts = [ 'created_at' => 'datetime:Y-m-d H:i:s', 'deleted_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; /** @var array The attributes that are mass assignable */ protected $fillable = ['namespace', 'status', 'type']; /** * Assign a package to a domain. The domain should not belong to any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User $user The wallet owner. * * @return \App\Domain Self */ public function assignPackage($package, $user) { // If this domain is public it can not be assigned to a user. if ($this->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(); + ->where('type', '&', Domain::TYPE_PUBLIC) + ->pluck('namespace')->all(); } /** * 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 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 public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 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 !( User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() || Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() || SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } /** * 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 = Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } return $wallet->entitlements() ->where('entitleable_type', 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/User.php b/src/app/User.php index f3dd4019..d7f5dfe7 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,709 +1,709 @@ The attributes that are mass assignable */ protected $fillable = [ 'id', 'email', 'password', 'password_ldap', 'status', ]; /** @var array The attributes that should be hidden for arrays */ protected $hidden = [ 'password', 'password_ldap', 'role' ]; /** @var array The attributes that can be null */ protected $nullable = [ 'password', 'password_ldap' ]; /** @var array The attributes that should be cast */ protected $casts = [ 'created_at' => 'datetime:Y-m-d H:i:s', 'deleted_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( Wallet::class, // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $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(); } /** * 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)); + $query->where('domains.type', '&', Domain::TYPE_PUBLIC) + ->where('domains.status', '&', Domain::STATUS_ACTIVE); }); } return $domains; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { return $this->hasMany(UserPassword::class); } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { return $this->entitleables(Resource::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { return $this->entitleables(SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { return $this->entitleables(User::class, $with_accounts); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany(VerificationCode::class, 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany(Wallet::class); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = Hash::make($password); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User 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/composer.json b/src/composer.json index 79e9764c..11449f4e 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,96 +1,86 @@ { "name": "kolab/kolab4", "type": "project", "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^8.0", "barryvdh/laravel-dompdf": "^1.0.0", "doctrine/dbal": "^3.3.2", "dyrynda/laravel-nullable-fields": "^4.2.0", "guzzlehttp/guzzle": "^7.4.1", "kolab/net_ldap3": "dev-master", - "laravel/framework": "^9.0", + "laravel/framework": "^9.2", "laravel/horizon": "^5.9", "laravel/octane": "^1.2", "laravel/passport": "^10.3", "laravel/tinker": "^2.7", "mlocati/spf-lib": "^3.1", "mollie/laravel-mollie": "^2.19", "moontoast/math": "^1.2", "pear/crypt_gpg": "^1.6.6", "predis/predis": "^1.1.10", "spatie/laravel-translatable": "^5.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29" }, "require-dev": { "code-lts/doctum": "^5.5.1", "laravel/dusk": "~6.22.0", "nunomaduro/larastan": "^2.0", "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", "squizlabs/php_codesniffer": "^3.6" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Seeds\\": "database/seeds/" + "App\\": "app/" }, "classmap": [ + "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true } }