diff --git a/src/app/Domain.php b/src/app/Domain.php index 3fca1923..791f3b87 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,430 +1,423 @@ 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() ->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) { + // Detect invalid flags + if ($status & ~$this->allowed_states) { throw new \Exception("Invalid domain status: {$status}"); } + $new_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() ); } /** * Returns domain's namespace (required by the EntitleableTrait). * * @return string|null Domain namespace */ public function toString(): ?string { return $this->namespace; } /** * 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/Group.php b/src/app/Group.php index 7450c1a8..e90afa1b 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,88 +1,95 @@ 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 = [ 'email', 'members', 'name', 'status', ]; /** * 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) : []; } /** * 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); } } diff --git a/src/app/Resource.php b/src/app/Resource.php index 9a0316ce..05c9a6bd 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,60 +1,67 @@ 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 = ['email', 'name', 'status']; } diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php index 4a1cfa21..c933eb72 100644 --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -1,85 +1,92 @@ 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 = [ 'email', 'name', 'status', 'type', ]; /** * 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 index 5c0eb3cc..f4ecda6a 100644 --- a/src/app/Traits/StatusPropertyTrait.php +++ b/src/app/Traits/StatusPropertyTrait.php @@ -1,134 +1,110 @@ 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) { + if ($status & ~$this->allowed_states) { throw new \Exception("Invalid status: {$status}"); } - $this->attributes['status'] = $new_status; + $this->attributes['status'] = $status; } } diff --git a/src/app/User.php b/src/app/User.php index f13d03f4..b58b9547 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,850 +1,826 @@ 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 (!is_object($object) || !method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } $query->where('domains.type', '&', Domain::TYPE_PUBLIC) ->where('domains.status', '&', Domain::STATUS_ACTIVE); }); } return $domains; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Storage items for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function fsItems() { return $this->hasMany(Fs\Item::class); } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * Returns whether this user is restricted. * * @return bool */ public function isRestricted(): bool { return ($this->status & self::STATUS_RESTRICTED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { return $this->hasMany(UserPassword::class); } /** * Restrict this user. * * @return void */ public function restrict(): void { if ($this->isRestricted()) { return; } $this->status |= User::STATUS_RESTRICTED; $this->save(); } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { return $this->entitleables(Resource::class, $with_accounts); } /** * Return rooms controlled by the current user. * * @param bool $with_accounts Include rooms assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function rooms($with_accounts = true) { return $this->entitleables(Meet\Room::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { return $this->entitleables(SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** * Un-restrict this user. * * @return void */ public function unrestrict(): void { if (!$this->isRestricted()) { return; } $this->status ^= User::STATUS_RESTRICTED; $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, - self::STATUS_RESTRICTED, - ]; - - 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) { // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } return $authenticated; } /** * Validate request location regarding geo-lockin * * @param string $ip IP address to check, usually request()->ip() * * @return bool */ public function validateLocation($ip): bool { $countryCodes = json_decode($this->getSetting('limit_geo', "[]")); if (empty($countryCodes)) { return true; } return in_array(\App\Utils::countryForIP($ip), $countryCodes); } /** * Check if multi factor verification is enabled * * @return bool */ public function mfaEnabled(): bool { return \App\CompanionApp::where('user_id', $this->id) ->where('mfa_enabled', true) ->exists(); } /** * Retrieve and authenticate a user * * @param string $username The username * @param string $password The password in plain text * @param ?string $clientIP The IP address of the client * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $clientIP = null, $verifyMFA = true): array { $error = null; if (!$clientIP) { $clientIP = request()->ip(); } $user = User::where('email', $username)->first(); if (!$user) { $error = AuthAttempt::REASON_NOTFOUND; } // Check user password if (!$error && !$user->validateCredentials($username, $password)) { $error = AuthAttempt::REASON_PASSWORD; } if ($verifyMFA) { // Check user (request) location if (!$error && !$user->validateLocation($clientIP)) { $error = AuthAttempt::REASON_GEOLOCATION; } // Check 2FA if (!$error) { try { (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor); } catch (\Exception $e) { $error = AuthAttempt::REASON_2FA_GENERIC; $message = $e->getMessage(); } } // Check 2FA - Companion App if (!$error && $user->mfaEnabled()) { $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$attempt->waitFor2FA()) { $error = AuthAttempt::REASON_2FA; } } } if ($error) { if ($user && empty($attempt)) { $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP); if (!$attempt->isAccepted()) { $attempt->deny($error); $attempt->save(); $attempt->notify(); } } if ($user) { \Log::info("Authentication failed for {$user->email}"); } return ['reason' => $error, 'errorMessage' => $message ?? \trans("auth.error.{$error}")]; } \Log::info("Successful authentication for {$user->email}"); return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public static function findAndValidateForPassport($username, $password): User { $verifyMFA = true; if (request()->scope == "mfa") { \Log::info("Not validating MFA because this is a request for an mfa scope."); // Don't verify MFA if this is only an mfa token. // If we didn't do this, we couldn't pair backup devices. $verifyMFA = false; } $result = self::findAndAuthenticate($username, $password, null, $verifyMFA); if (isset($result['reason'])) { if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } // TODO: Display specific error message if 2FA via Companion App was expected? throw OAuthServerException::invalidCredentials(); } return $result['user']; } }