diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php index 954198f2..23de2bbe 100644 --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -1,195 +1,195 @@ The attributes that can be not set */ protected $nullable = ['reason']; /** @var array The attributes that are mass assignable */ protected $fillable = [ 'ip', 'user_id', 'status', 'reason', 'expires_at', 'last_seen', ]; /** @var array The attributes that should be cast */ protected $casts = [ 'expires_at' => '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 { 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; } /** * 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 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(User $user, $clientIP) { $authAttempt = AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); if (!$authAttempt) { $authAttempt = new 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/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php index 3a247992..1861cd6d 100644 --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -1,331 +1,302 @@ first(); - if (!$user) { - throw new \Exception("User not found"); + $result = \App\User::findAndAuthenticate($login, $password, $clientIP); + + if (empty($result['user'])) { + throw new \Exception($result['errorMessage'] ?? "Unknown error"); } // TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready) // TODO: validate the user is A-OK (active, not suspended, ldapready, imapready) - // TODO: we could use User::findAndAuthenticate() with some modifications here - - if (!Hash::check($password, $user->password)) { - $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); - // Avoid setting a password failure reason if we previously accepted the location. - if (!$attempt->isAccepted()) { - $attempt->reason = \App\AuthAttempt::REASON_PASSWORD; - $attempt->save(); - $attempt->notify(); - } - throw new \Exception("Password mismatch"); - } - - // validate country of origin against restrictions, otherwise bye bye - if (!$user->validateLocation($clientIP)) { - \Log::info("Failed authentication attempt due to country code mismatch for user: {$login}"); - $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); - $attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION); - $attempt->notify(); - throw new \Exception("Country code mismatch"); - } - // TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of - // attempts over the same authAttempt. - - // Check 2fa - if (\App\CompanionApp::where('user_id', $user->id)->exists()) { - $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); - if (!$authAttempt->waitFor2FA()) { - throw new \Exception("2fa failed"); - } - } + // attempts over the same authAttempt. - return $user; + return $result['user']; } /** * Convert domain.tld\username into username@domain for activesync * * @param string $username The original username. * * @return string The username in canonical form */ private function normalizeUsername($username) { $usernameParts = explode("\\", $username); if (count($usernameParts) == 2) { $username = $usernameParts[1]; if (!strpos($username, '@') && !empty($usernameParts[0])) { $username .= '@' . $usernameParts[0]; } } return $username; } /** * Authentication request from the ngx_http_auth_request_module * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function httpauth(Request $request) { /** Php-Auth-Pw: simple123 Php-Auth-User: john@kolab.org Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: cross-site Sec-Gpc: 1 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0 X-Forwarded-For: 31.10.153.58 X-Forwarded-Proto: https X-Original-Uri: /iRony/ X-Real-Ip: 31.10.153.58 */ $username = $this->normalizeUsername($request->headers->get('Php-Auth-User', "")); $password = $request->headers->get('Php-Auth-Pw', null); if (empty($username)) { //Allow unauthenticated requests return response(""); } if (empty($password)) { \Log::debug("Authentication attempt failed: Empty password provided."); return response("", 401); } try { $this->authorizeRequest( $username, $password, $request->headers->get('X-Real-Ip', null), ); } catch (\Exception $e) { \Log::debug("Authentication attempt failed: {$e->getMessage()}"); return response("", 403); } \Log::debug("Authentication attempt succeeded"); return response(""); } /** * Authentication request. * * @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. => * I suppose that's not necessary given that we have the information avialable in the headers? * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function authenticate(Request $request) { /** * Auth-Login-Attempt: 1 * Auth-Method: plain * Auth-Pass: simple123 * Auth-Protocol: imap * Auth-Ssl: on * Auth-User: john@kolab.org * Client-Ip: 127.0.0.1 * Host: 127.0.0.1 * * Auth-SSL: on * Auth-SSL-Verify: SUCCESS * Auth-SSL-Subject: /CN=example.com * Auth-SSL-Issuer: /CN=example.com * Auth-SSL-Serial: C07AD56B846B5BFF * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Client-Ip', null); try { $user = $this->authorizeRequest( $username, $password, $ip, ); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case "imap": return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password); case "smtp": return $this->authenticateSMTP($request, $password); default: return $this->byebye($request, "unknown protocol in request"); } } /** * Authentication request for roundcube imap. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function authenticateRoundcube(Request $request) { /** * Auth-Login-Attempt: 1 * Auth-Method: plain * Auth-Pass: simple123 * Auth-Protocol: imap * Auth-Ssl: on * Auth-User: john@kolab.org * Client-Ip: 127.0.0.1 * Host: 127.0.0.1 * * Auth-SSL: on * Auth-SSL-Verify: SUCCESS * Auth-SSL-Subject: /CN=example.com * Auth-SSL-Issuer: /CN=example.com * Auth-SSL-Serial: C07AD56B846B5BFF * Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad */ $password = $request->headers->get('Auth-Pass', null); $username = $request->headers->get('Auth-User', null); $ip = $request->headers->get('Proxy-Protocol-Addr', null); try { $user = $this->authorizeRequest( $username, $password, $ip, ); } catch (\Exception $e) { return $this->byebye($request, $e->getMessage()); } // All checks passed switch ($request->headers->get('Auth-Protocol')) { case "imap": return $this->authenticateIMAP($request, false, $password); default: return $this->byebye($request, "unknown protocol in request"); } } /** * Create an imap authentication response. * * @param \Illuminate\Http\Request $request The API request. - * @param bool $prefGuam Wether or not guam is enabled. + * @param bool $prefGuam Whether or not Guam is enabled. * @param string $password The password to include in the response. * * @return \Illuminate\Http\Response The response */ private function authenticateIMAP(Request $request, $prefGuam, $password) { if ($prefGuam) { $port = \config('imap.guam_port'); } else { $port = \config('imap.imap_port'); } $response = response("")->withHeaders( [ "Auth-Status" => "OK", "Auth-Server" => \config('imap.host'), "Auth-Port" => $port, "Auth-Pass" => $password ] ); return $response; } /** * Create an smtp authentication response. * * @param \Illuminate\Http\Request $request The API request. * @param string $password The password to include in the response. * * @return \Illuminate\Http\Response The response */ private function authenticateSMTP(Request $request, $password) { $response = response("")->withHeaders( [ "Auth-Status" => "OK", "Auth-Server" => \config('smtp.host'), "Auth-Port" => \config('smtp.port'), "Auth-Pass" => $password ] ); return $response; } /** * Create a failed-authentication response. * * @param \Illuminate\Http\Request $request The API request. * @param string $reason The reason for the failure. * * @return \Illuminate\Http\Response The response */ private function byebye(Request $request, $reason = null) { \Log::debug("Byebye: {$reason}"); $response = response("")->withHeaders( [ "Auth-Status" => "authentication failure", "Auth-Wait" => 3 ] ); return $response; } } diff --git a/src/app/User.php b/src/app/User.php index f6038ee3..f20346d5 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,760 +1,786 @@ 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->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; } /** * 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 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(); } /** * 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; } /** * 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); } /** * 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). + * @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, $secondFactor = null): ?array + public static function findAndAuthenticate($username, $password, $clientIP = null): array { - $user = User::where('email', $username)->first(); + $error = null; - // TODO: 'reason' below could be AuthAttempt::REASON_* - // TODO: $secondFactor argument is not used anywhere + if (!$clientIP) { + $clientIP = request()->ip(); + } + + $user = User::where('email', $username)->first(); if (!$user) { - return ['reason' => 'notfound', 'errorMessage' => "User not found."]; + $error = AuthAttempt::REASON_NOTFOUND; } - if (!$user->validateCredentials($username, $password)) { - return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; + // Check user password + if (!$error && !$user->validateCredentials($username, $password)) { + $error = AuthAttempt::REASON_PASSWORD; } - if (!$user->validateLocation(request()->ip())) { - return ['reason' => 'geolocation', 'errorMessage' => "Country code mismatch."]; + // Check user (request) location + if (!$error && !$user->validateLocation($clientIP)) { + $error = AuthAttempt::REASON_GEOLOCATION; } - if (!$secondFactor) { - // Check the request if there is a second factor provided - // as fallback. - $secondFactor = request()->secondfactor; + // Check 2FA + if (!$error) { + try { + (new \App\Auth\SecondFactor($user))->validate(request()->secondfactor); + } catch (\Exception $e) { + $error = AuthAttempt::REASON_2FA_GENERIC; + $message = $e->getMessage(); + } } - try { - (new \App\Auth\SecondFactor($user))->validate($secondFactor); - } catch (\Exception $e) { - return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; + // Check 2FA - Companion App + if (!$error && \App\CompanionApp::where('user_id', $user->id)->exists()) { + $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP); + if (!$attempt->waitFor2FA()) { + $error = AuthAttempt::REASON_2FA; + } } + if ($error) { + if ($user && empty($attempt)) { + $attempt = \App\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 function findAndValidateForPassport($username, $password): User + public static function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { - // TODO: Shouldn't we create AuthAttempt record here? - - if ($result['reason'] == 'secondfactor') { + if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } + // TODO: Display specific error message if 2FA via Companion App was expected? + throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index eb23c93f..3418d0d6 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,21 +1,27 @@ 'Invalid username or password.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', + 'error.password' => "Invalid password", + 'error.geolocation' => "Country code mismatch", + 'error.nofound' => "User not found", + 'error.2fa' => "Second factor failure", + 'error.2fa-generic' => "Second factor failure", + ];