diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index d40af7ac..5b52d7aa 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -21,7 +21,9 @@ use App\ModelSerializers\SerializerRegistry; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\UnverifiedEmailMemberException; +use App\Services\Auth\IUserService as AuthUserService; use Exception; +use Illuminate\Http\Request as LaravelRequest; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redirect; @@ -86,6 +88,10 @@ final class UserController extends OpenIdController * @var IUserService */ private $user_service; + /** + * @var AuthUserService + */ + private $auth_user_service; /** * @var IUserActionService */ @@ -132,6 +138,7 @@ final class UserController extends OpenIdController * @param ITrustedSitesService $trusted_sites_service * @param DiscoveryController $discovery * @param IUserService $user_service + * @param AuthUserService $auth_user_service * @param IUserActionService $user_action_service * @param IClientRepository $client_repository * @param IApiScopeRepository $scope_repository @@ -150,6 +157,7 @@ public function __construct ITrustedSitesService $trusted_sites_service, DiscoveryController $discovery, IUserService $user_service, + AuthUserService $auth_user_service, IUserActionService $user_action_service, IClientRepository $client_repository, IApiScopeRepository $scope_repository, @@ -160,8 +168,6 @@ public function __construct LoginHintProcessStrategy $login_hint_process_strategy ) { - - $this->openid_memento_service = $openid_memento_service; $this->oauth2_memento_service = $oauth2_memento_service; $this->auth_service = $auth_service; @@ -169,6 +175,7 @@ public function __construct $this->trusted_sites_service = $trusted_sites_service; $this->discovery = $discovery; $this->user_service = $user_service; + $this->auth_user_service = $auth_user_service; $this->user_action_service = $user_action_service; $this->client_repository = $client_repository; $this->scope_repository = $scope_repository; @@ -258,14 +265,16 @@ public function getAccount() $user = $this->auth_service->getUserByUsername($email); - if (is_null($user) || !$user->canLogin()) + if (is_null($user)) throw new EntityNotFoundException(); return $this->ok( [ + 'is_active' => $user->isActive(), + 'is_verified' => $user->isEmailVerified(), 'pic' => $user->getPic(), 'full_name' => $user->getFullName(), - 'has_password_set' => $user->hasPasswordSet() + 'has_password_set' => $user->hasPasswordSet(), ] ); } catch (ValidationException $ex) { @@ -350,9 +359,41 @@ public function emitOTP() } } + /** + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function resendVerificationEmail(LaravelRequest $request) + { + try { + $payload = $request->all(); + $validator = Validator::make($payload, [ + 'email' => 'required|string|email|max:255' + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + $this->auth_user_service->resendVerificationEmail($payload); + return $this->ok(); + } + catch (ValidationException $ex) { + Log::warning($ex); + return $this->error412($ex->getMessages()); + } + catch (EntityNotFoundException $ex) { + Log::warning($ex); + return $this->error404(); + } + catch (Exception $ex) { + Log::error($ex); + return $this->error500($ex); + } + } + public function postLogin() { $max_login_attempts_2_show_captcha = $this->server_configuration_service->getConfigValue("MaxFailed.LoginAttempts.2ShowCaptcha"); + $max_login_failed_attempts = intval($this->server_configuration_service->getConfigValue("MaxFailed.Login.Attempts")); $login_attempts = 0; $username = ''; $user = null; @@ -439,13 +480,15 @@ public function postLogin() ( [ 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, + 'max_login_failed_attempts' => $max_login_failed_attempts, 'login_attempts' => $login_attempts, 'error_message' => $ex->getMessage(), 'user_fullname' => !is_null($user) ? $user->getFullName() : "", 'user_pic' => !is_null($user) ? $user->getPic(): "", 'user_verified' => true, 'username' => $username, - 'flow' => $flow + 'flow' => $flow, + 'user_is_active' => !is_null($user) ? ($user->isActive() ? 1 : 0) : 0 ] ); } @@ -455,6 +498,7 @@ public function postLogin() // validator errors $response_data = [ 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, + 'max_login_failed_attempts' => $max_login_failed_attempts, 'login_attempts' => $login_attempts, 'validator' => $validator, ]; @@ -466,7 +510,8 @@ public function postLogin() if(!is_null($user)){ $response_data['user_fullname'] = $user->getFullName(); $response_data['user_pic'] = $user->getPic(); - $response_data['user_verified'] = true; + $response_data['user_verified'] = 1; + $response_data['user_is_active'] = $user->isActive() ? 1 : 0; } return $this->login_strategy->errorLogin @@ -481,9 +526,10 @@ public function postLogin() $response_data = [ 'max_login_attempts_2_show_captcha' => $max_login_attempts_2_show_captcha, + 'max_login_failed_attempts' => $max_login_failed_attempts, 'login_attempts' => $login_attempts, 'username' => $username, - 'error_message' => $ex1->getMessage() + 'error_message' => $ex1->getMessage(), ]; if (is_null($user) && isset($data['username'])) { @@ -493,7 +539,8 @@ public function postLogin() if(!is_null($user)){ $response_data['user_fullname'] = $user->getFullName(); $response_data['user_pic'] = $user->getPic(); - $response_data['user_verified'] = true; + $response_data['user_verified'] = 1; + $response_data['user_is_active'] = $user->isActive() ? 1 : 0; } return $this->login_strategy->errorLogin diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index 5e4dc60a..4910a878 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -218,14 +218,18 @@ public function registerUser(array $payload, ?OAuth2OTP $otp = null):User * @param string $token * @return User * @throws EntityNotFoundException - * @throws ValidationException + * @throws ValidationException|\Exception */ public function verifyEmail(string $token): User { return $this->tx_service->transaction(function () use ($token) { $user = $this->user_repository->getByVerificationEmailToken($token); - if (is_null($user)) + + if (is_null($user) || !$user->isActive()) { + Log::warning(sprintf("UserService::verifyEmail user with id %s is not active", $user->getId())); throw new EntityNotFoundException(); + } + $user->verifyEmail(); try { diff --git a/app/Services/SecurityPolicies/LockUserCounterMeasure.php b/app/Services/SecurityPolicies/LockUserCounterMeasure.php index fc310094..b38df7b3 100644 --- a/app/Services/SecurityPolicies/LockUserCounterMeasure.php +++ b/app/Services/SecurityPolicies/LockUserCounterMeasure.php @@ -77,7 +77,7 @@ public function trigger(array $params = []) $user_id = $params["user_id"]; $user = $this->repository->getById($user_id); $max_login_failed_attempts = intval($this->server_configuration->getConfigValue("MaxFailed.Login.Attempts")); - if (!is_null($user) && $user instanceof User) { + if ($user instanceof User) { //apply lock policy if (intval($user->getLoginFailedAttempt()) < $max_login_failed_attempts) { $this->user_service->updateFailedLoginAttempts($user->getId()); diff --git a/app/libs/Auth/Factories/UserFactory.php b/app/libs/Auth/Factories/UserFactory.php index 5602116c..b0ca3027 100644 --- a/app/libs/Auth/Factories/UserFactory.php +++ b/app/libs/Auth/Factories/UserFactory.php @@ -11,7 +11,6 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -use Auth\Group; use Auth\User; use Illuminate\Support\Facades\Auth; diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index b04b5d13..23f98918 100644 --- a/app/libs/Auth/Models/User.php +++ b/app/libs/Auth/Models/User.php @@ -1854,6 +1854,8 @@ public function activate():void { if(!$this->active) { $this->active = true; $this->spam_type = self::SpamTypeHam; + // reset it + $this->login_failed_attempt = 0; Event::dispatch(new UserSpamStateUpdated( $this->getId() ) @@ -1886,6 +1888,7 @@ public function verifyEmail(bool $send_email_verified_notice = true) $this->spam_type = self::SpamTypeHam; $this->active = true; $this->lock = false; + $this->login_failed_attempt = 0; $this->email_verified_date = new \DateTime('now', new \DateTimeZone('UTC')); if($send_email_verified_notice) diff --git a/resources/js/components/custom_snackbar.js b/resources/js/components/custom_snackbar.js new file mode 100644 index 00000000..ff8d9bf0 --- /dev/null +++ b/resources/js/components/custom_snackbar.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { Snackbar } from '@material-ui/core'; +import MuiAlert from '@material-ui/lab/Alert'; + +function Alert(props) { + return ; +} + +const CustomSnackbar = ({ message, severity = 'info', onClose }) => { + return ( + + + {message} + + + ); +}; +export default CustomSnackbar; \ No newline at end of file diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index 5eeb3d1f..d0d20ad9 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -19,3 +19,11 @@ export const emitOTP = (email, token, connection = 'email', send='code') => { return postRawRequest(window.EMIT_OTP_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } + +export const resendVerificationEmail = (email, token) => { + const params = { + email: email + }; + + return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ca9ee367..00e4cb45 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -12,7 +12,7 @@ import Container from '@material-ui/core/Container'; import Chip from '@material-ui/core/Chip'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount, emitOTP} from './actions'; +import {verifyAccount, emitOTP, resendVerificationEmail} from './actions'; import {MuiThemeProvider, createTheme} from '@material-ui/core/styles'; import DividerWithText from '../components/divider_with_text'; import Visibility from '@material-ui/icons/Visibility'; @@ -21,15 +21,15 @@ import InputAdornment from '@material-ui/core/InputAdornment'; import IconButton from '@material-ui/core/IconButton'; import { emailValidator } from '../validator'; import Grid from '@material-ui/core/Grid'; -import Swal from 'sweetalert2' +import CustomSnackbar from "../components/custom_snackbar"; import Banner from '../components/banner/banner'; import OtpInput from 'react-otp-input'; -import {handleThirdPartyProvidersVerbiage} from '../utils'; +import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; import styles from './login.module.scss' import "./third_party_identity_providers.scss"; -const EmailInputForm = ({ onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { +const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { return ( { return ( @@ -113,9 +117,46 @@ const PasswordInputForm = ({ ) }} /> - {passwordError && - - } + {(() => { + const attempts = parseInt(loginAttempts, 10); + const maxAttempts = parseInt(maxLoginFailedAttempts, 10); + const attemptsLeft = maxAttempts - attempts; + + if (!passwordError) return null; + + if (attempts > 0 && attempts < maxAttempts && userIsActive) { + return ( + <> + + Incorrect password. You have {attemptsLeft} more attempt{attemptsLeft !== 1 ? 's' : ''} before your account is locked. + + > + ); + } + + if (attempts > 0 && attempts === maxAttempts && userIsActive) { + return ( + <> + + Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. + + > + ); + } + + if (attempts > 0 && attempts === maxAttempts && !userIsActive) { + return ( + <> + + Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. + + > + ); + } + + return ; + })()} + { let { response } = payload; + let error = ''; + if (response.is_active === false) { + error = 'Your user account is currently inactive. Please contact support for further assistance.'; + } else if (response.is_active === true && response.is_verified === false) { + error = 'Your email has not been verified. Please check your inbox or resend the verification email.'; + } + this.setState({ ...this.state, user_pic: response.pic, user_fullname: response.full_name, user_verified: true, + user_active: response.is_active, + email_verified: response.is_verified, authFlow: response.has_password_set ? password_flow : otp_flow, errors: { - email: '', + email: error, otp: '', password: '' }, @@ -573,14 +640,46 @@ class LoginPage extends React.Component { return true; } + resendVerificationEmail(ev) { + ev.preventDefault(); + let {user_name} = this.state; + user_name = user_name?.trim(); + + if (!user_name) { + this.showAlert( + 'Something went wrong while trying to resend the verification email. Please try again later.', + 'error'); + return; + } + + resendVerificationEmail(user_name, this.props.token).then((payload) => { + this.showAlert( + 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', + 'success'); + }, (error) => { + handleErrorResponse(error, (title, messageLines, type) => { + const message = (messageLines ?? []).join(', ') + this.showAlert(`${title}: ${message}`, type); + }); + }); + } + handleDelete(ev) { ev.preventDefault(); this.setState({ - ...this.state, user_name: null, user_pic: null, user_fullname: null, user_verified: false, authFlow: "password", errors: { + ...this.state, + user_name: null, + user_pic: null, + user_fullname: null, + user_verified: false, + user_active: null, + email_verified: null, + authFlow: "password", + errors: { email: '', otp: '', password: '' - }, + } }); return false; } @@ -594,6 +693,30 @@ class LoginPage extends React.Component { ev.preventDefault(); } + existingUserCanContinue() { + const { user_active, email_verified } = this.state; + return user_active !== false && email_verified !== false; + } + + getSignUpSignInTitle() { + const { errors, user_active } = this.state; + + if (errors.email && this.existingUserCanContinue()) { + return 'Create an account for:'; + } + return 'Sign in'; + } + + handleSnackbarClose() { + this.setState({ + ...this.state, + notification: { + message: null, + severity: 'info' + } + }); + }; + render() { return ( @@ -607,7 +730,7 @@ class LoginPage extends React.Component { src={this.props.appLogo}/> - {this.state.errors.email ? 'Create an account for:' : 'Sign in'} + {this.getSignUpSignInTitle()} {this.state.user_fullname && } @@ -617,16 +740,17 @@ class LoginPage extends React.Component { onDelete={this.handleDelete}/> } - {!this.state.user_verified && + {(!this.state.user_verified || !this.existingUserCanContinue()) && <> {this.state.allowNativeAuth && } - {this.state.errors.email == '' && + {this.state.errors.email === '' && this.props.thirdPartyProviders.length > 0 && - + {this.existingUserCanContinue() && + + } + { + this.state.email_verified === false && + + Resend verification email + + } } - {this.state.user_verified && this.state.authFlow == password_flow && + {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === password_flow && // proceed to ask for password ( 2nd step ) <> > } - {this.state.user_verified && this.state.authFlow == otp_flow && + {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === otp_flow && // proceed to ask for password ( 2nd step ) <> > } + diff --git a/resources/js/utils.js b/resources/js/utils.js index 916f9695..3a45f2e9 100644 --- a/resources/js/utils.js +++ b/resources/js/utils.js @@ -1,20 +1,36 @@ import Swal from "sweetalert2"; -export const handleErrorResponse = (err) => { - if(err.status === 412){ - // validation error - let msg= ''; - for (let [key, value] of Object.entries(err.response.body.errors)) { - if (isNaN(key)) { - msg += key + ': '; - } +const createErrorHandler = (customHandler = null) => { + const showMessage = (title, message, type) => { + if (customHandler && typeof customHandler === 'function') { + return customHandler(title, message, type); + } + const formattedMessage = Array.isArray(message) ? message.join('') : message; + return Swal(title, formattedMessage, type); + }; - msg += value + ''; + return (err) => { + if (err.status === 412) { + // validation error + const messageLines = []; + for (let [key, value] of Object.entries(err.response.body.errors)) { + let line = ''; + if (isNaN(key)) { + line += key + ': '; + } + line += value; + messageLines.push(line); + } + return showMessage("Validation error", messageLines, "warning"); } - return Swal("Validation error", msg, "warning"); - } - return Swal("Something went wrong!", null, "error"); -} + return showMessage("Something went wrong!", null, "error"); + }; +}; + +export const handleErrorResponse = (err, customHandler = null) => { + const errorHandler = createErrorHandler(customHandler); + return errorHandler(err); +}; /** * diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 12ae4b17..2b361847 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -33,6 +33,7 @@ formAction: '{{ URL::action("UserController@postLogin") }}', accountVerifyAction : '{{URL::action("UserController@getAccount")}}', emitOtpAction : '{{URL::action("UserController@emitOTP")}}', + resendVerificationEmailAction: '{{ URL::action("UserController@resendVerificationEmail") }}', authError: authError, captchaPublicKey: '{{ Config::get("recaptcha.public_key") }}', flow: 'password', @@ -57,10 +58,18 @@ config.maxLoginAttempts2ShowCaptcha = {{Session::get("max_login_attempts_2_show_captcha")}}; @endif + @if(Session::has('max_login_failed_attempts')) + config.maxLoginFailedAttempts = {{Session::get("max_login_failed_attempts")}}; + @endif + @if(Session::has('login_attempts')) config.loginAttempts = {{Session::get("login_attempts")}}; @endif + @if(Session::has('user_is_active')) + config.user_is_active = {{Session::get("user_is_active")}}; + @endif + @if(Session::has('user_fullname')) config.user_fullname = '{{Session::get("user_fullname")}}'; @endif @@ -68,15 +77,16 @@ @if(Session::has('user_pic')) config.user_pic = '{{Session::get("user_pic")}}'; @endif - @if(Session::has('user_verified')) + @if(Session::has('user_verified')) config.user_verified = {{Session::get('user_verified')}}; @endif - @if(Session::has('flow')) + @if(Session::has('flow')) config.flow = '{{Session::get('flow')}}'; @endif - window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; + window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; window.EMIT_OTP_ENDPOINT = config.emitOtpAction; + window.RESEND_VERIFICATION_EMAIL_ENDPOINT = config.resendVerificationEmailAction; {!! script_to('assets/login.js') !!} @append \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index eeaee609..f8473c4c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,9 @@ Route::get('', "UserController@getLogin"); Route::post('account-verify', [ 'middleware' => ['csrf'], 'uses' => 'UserController@getAccount']); Route::post('otp', ['middleware' => ['csrf'], 'uses' => 'UserController@emitOTP']); + Route::group(array('prefix' => 'verification'), function () { + Route::post('resend', ['middleware' => ['csrf'], 'uses' => 'UserController@resendVerificationEmail']); + }); Route::post('', ['middleware' => 'csrf', 'uses' => 'UserController@postLogin']); Route::get('cancel', "UserController@cancelLogin"); Route::group(array('prefix' => '{provider}'), function () {
+ Incorrect password. You have {attemptsLeft} more attempt{attemptsLeft !== 1 ? 's' : ''} before your account is locked. +
+ Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. +
+ Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. +