diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -17,12 +17,12 @@ use NullableFields; use UuidStrKeyTrait; - // No specific reason public const REASON_NONE = ''; - // Password mismatch public const REASON_PASSWORD = 'password'; - // Geolocation whitelist mismatch public const REASON_GEOLOCATION = 'geolocation'; + public const REASON_NOTFOUND = 'notfound'; + public const REASON_2FA = '2fa'; + public const REASON_2FA_GENERIC = '2fa-generic'; private const STATUS_ACCEPTED = 'ACCEPTED'; private const STATUS_DENIED = 'DENIED'; diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php --- a/src/app/Http/Controllers/API/V4/NGINXController.php +++ b/src/app/Http/Controllers/API/V4/NGINXController.php @@ -12,7 +12,7 @@ /** * Authorize with the provided credentials. * - * @param string $login The login name + * @param string $login The login name * @param string $password The password * * @return \App\User The user @@ -67,47 +67,18 @@ throw new \Exception("No client ip"); } - $user = \App\User::where('email', $login)->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. + // 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"); - } - } - - return $user; + return $result['user']; } @@ -338,7 +309,7 @@ * 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 diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -2,6 +2,7 @@ namespace App; +use App\AuthAttempt; use App\Traits\AliasesTrait; use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; @@ -657,16 +658,11 @@ } 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; @@ -693,43 +689,73 @@ /** * 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]; } @@ -740,18 +766,18 @@ * * @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(); } diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -18,4 +18,10 @@ '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", + ];