diff --git a/.env.example b/.env.example index 23f6c3cf..d27f9197 100644 --- a/.env.example +++ b/.env.example @@ -107,4 +107,6 @@ AUTH_ALLOWS_NATIVE_AUTH=1 AUTH_ALLOWS_OTP=1 AUTH_ALLOWS_NATIVE_AUTH_CONFIG=1 MAIL_SEND_WELCOME_EMAIL=1 -DEFAULT_PROFILE_IMAGE= \ No newline at end of file +DEFAULT_PROFILE_IMAGE= + +AUTH_PASSWORD_RESET_LIFETIME=1800 \ No newline at end of file diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index e9e754d6..b4ba998a 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -15,10 +15,7 @@ use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository; use App\Services\Auth\IUserService; use Auth\Exceptions\UserPasswordResetRequestVoidException; -use Auth\Repositories\IUserRepository; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Validator; use Illuminate\Http\Request as LaravelRequest; use models\exceptions\EntityNotFoundException; diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index a334a371..db929f23 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -255,7 +255,8 @@ public function getAccount() return $this->ok( [ 'pic' => $user->getPic(), - 'full_name' => $user->getFullName() + 'full_name' => $user->getFullName(), + 'has_password_set' => $user->hasPasswordSet() ] ); } catch (ValidationException $ex) { diff --git a/app/Jobs/GenerateOTPRegistrationReminder.php b/app/Jobs/GenerateOTPRegistrationReminder.php new file mode 100644 index 00000000..64886f65 --- /dev/null +++ b/app/Jobs/GenerateOTPRegistrationReminder.php @@ -0,0 +1,55 @@ +user_id = $user->getId(); + Log::debug(sprintf("GenerateOTPRegistrationReminder::GenerateOTPRegistrationReminder user %s", $user->getEmail())); + } + + /** + * @param IAuthUserService $service + * @return void + * @throws \Exception + */ + public function handle(IAuthUserService $service) + { + Log::debug(sprintf("GenerateOTPRegistrationReminder::handle user %s", $this->user_id)); + $service->sendOTPRegistrationReminder($this->user_id); + } + + public function failed(\Throwable $exception) + { + Log::error($exception); + } +} \ No newline at end of file diff --git a/app/Mail/OTPRegistrationReminderEmail.php b/app/Mail/OTPRegistrationReminderEmail.php new file mode 100644 index 00000000..240ed8f6 --- /dev/null +++ b/app/Mail/OTPRegistrationReminderEmail.php @@ -0,0 +1,44 @@ +subject = sprintf('[%1$s] Remember to set your password', Config::get('app.app_name')); + $view = 'emails.oauth2_passwordless_otp_reg_reminder'; + + if (Config::get("app.tenant_name") == 'FNTECH') { + $view = 'emails.oauth2_passwordless_otp_reg_reminder_fn'; + } + + Log::debug(sprintf("OTPRegistrationReminderEmail::build to %s", $this->user_email)); + return $this->from(Config::get("mail.from")) + ->to($this->user_email) + ->subject($this->subject) + ->view($view); + } +} diff --git a/app/Mail/WelcomeNewUserEmail.php b/app/Mail/WelcomeNewUserEmail.php index 6b84be6b..7d255b04 100644 --- a/app/Mail/WelcomeNewUserEmail.php +++ b/app/Mail/WelcomeNewUserEmail.php @@ -15,7 +15,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\URL; @@ -24,7 +23,7 @@ * Class WelcomeNewUserEmail * @package App\Mail */ -final class WelcomeNewUserEmail extends Mailable +class WelcomeNewUserEmail extends Mailable { use Queueable, SerializesModels; @@ -101,8 +100,10 @@ public function __construct if($user->createdByOTP()){ $this->user_created_by_otp = true; $otp = $user->getCreatedByOtp(); - $otp_redirect_url = $otp->getRedirectUrl(); - $this->site_base_url = !empty($otp_redirect_url) ? parse_url($otp_redirect_url)['host'] : null; + if(!is_null($otp)) { + $otp_redirect_url = $otp->getRedirectUrl(); + $this->site_base_url = !empty($otp_redirect_url) ? parse_url($otp_redirect_url)['host'] : null; + } } $this->reset_password_link = $reset_password_link; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 95d1438b..be0eea2d 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -75,7 +75,7 @@ public function boot() Event::listen(UserEmailVerified::class, function($event) { $service = App::make(IUserService::class); - if(is_null($service) || !$service instanceof IUserService) return; + if(!$service instanceof IUserService) return; $service->sendSuccessfulVerificationEmail($event->getUserId()); }); @@ -83,7 +83,7 @@ public function boot() { // new user created $service = App::make(IUserService::class); - if(is_null($service) || !$service instanceof IUserService) return; + if(!$service instanceof IUserService) return; $service->initializeUser($event->getUserId()); }); diff --git a/app/Services/Auth/IUserService.php b/app/Services/Auth/IUserService.php index 7a9a0d85..81b8d209 100644 --- a/app/Services/Auth/IUserService.php +++ b/app/Services/Auth/IUserService.php @@ -129,4 +129,11 @@ public function initializeUser(int $user_id):?User; * @throws \Exception */ public function updateRegistrationRequest(int $id, array $payload):UserRegistrationRequest; + + /** + * @param int $user_id + * @return void + * @throws \Exception + */ + public function sendOTPRegistrationReminder(int $user_id); } \ No newline at end of file diff --git a/app/Services/Auth/UserService.php b/app/Services/Auth/UserService.php index 7490cc10..cb78e80e 100644 --- a/app/Services/Auth/UserService.php +++ b/app/Services/Auth/UserService.php @@ -23,6 +23,7 @@ use App\libs\Auth\Repositories\ISpamEstimatorFeedRepository; use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository; use App\libs\Auth\Repositories\IUserRegistrationRequestRepository; +use App\Mail\OTPRegistrationReminderEmail; use App\Mail\UserEmailVerificationRequest; use App\Mail\UserEmailVerificationSuccess; use App\Mail\UserPasswordResetRequestMail; @@ -499,7 +500,7 @@ public function sendSuccessfulVerificationEmail(int $user_id): ?User return $this->tx_service->transaction(function() use($user_id){ $user = $this->user_repository->getById($user_id); - if(is_null($user) || !$user instanceof User) return null; + if(!$user instanceof User) return null; $reset_password_link = null; @@ -527,7 +528,7 @@ public function initializeUser(int $user_id): ?User return $this->tx_service->transaction(function() use($user_id) { Log::debug(sprintf("UserService::initializeUser %s", $user_id)); $user = $this->user_repository->getById($user_id); - if(is_null($user) || !$user instanceof User) return null; + if(!$user instanceof User) return null; if(!$user->isEmailVerified()) { Log::debug(sprintf("UserService::initializeUser %s email not verified", $user_id)); @@ -560,4 +561,24 @@ public function initializeUser(int $user_id): ?User return $user; }); } + + /** + * @param int $user_id + * @return void + * @throws \Exception + */ + public function sendOTPRegistrationReminder(int $user_id){ + $this->tx_service->transaction(function() use($user_id) { + Log::debug(sprintf("UserService::sendOTPRegistrationReminder %s", $user_id)); + $user = $this->user_repository->getById($user_id); + if( !$user instanceof User) + throw new EntityNotFoundException(sprintf("User %s not found.", $user_id)); + + if ($user->hasPasswordSet()) + throw new ValidationException(sprintf("User %s already has password set.", $user->getId())); + + $request = $this->generatePasswordResetRequest($user->getEmail()); + Mail::queue(new OTPRegistrationReminderEmail($user, $request->getResetLink())); + }); + } } \ No newline at end of file diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 21b2653a..de7d1198 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -12,6 +12,7 @@ * limitations under the License. **/ +use App\Jobs\GenerateOTPRegistrationReminder; use App\libs\OAuth2\Exceptions\ReloadSessionException; use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; use App\Services\AbstractService; @@ -206,7 +207,7 @@ public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null, bool $ ) ); - throw new AuthenticationException("Non existent OTP."); + throw new AuthenticationException("Non existent single-use code."); } $otp->logRedeemAttempt(); @@ -216,28 +217,28 @@ public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null, bool $ return $this->tx_service->transaction(function () use ($otp, $otpClaim, $client, $remember) { if (!$otp->isAlive()) { - throw new AuthenticationException("OTP is expired."); + throw new AuthenticationException("Single-use code is expired."); } if (!$otp->isValid()) { - throw new AuthenticationException("OTP is not valid."); + throw new AuthenticationException("Single-use code is not valid."); } if ($otp->getValue() != $otpClaim->getValue()) { - throw new AuthenticationException("OTP mismatch."); + throw new AuthenticationException("Single-use code mismatch."); } if(!empty($otpClaim->getScope()) && !$otp->allowScope($otpClaim->getScope())) - throw new InvalidOTPException("OTP Requested scopes escalates former scopes."); + throw new InvalidOTPException("Single-use code requested scopes escalates former scopes."); if (($otp->hasClient() && is_null($client)) || ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) { - throw new AuthenticationException("OTP audience mismatch."); + throw new AuthenticationException("Single-use code audience mismatch."); } $user = $this->getUserByUsername($otp->getUserName()); - - if (is_null($user)) { + $new_user = is_null($user); + if ($new_user) { // we need to create a new one ( auto register) Log::debug(sprintf("AuthService::loginWithOTP user %s does not exists ...", $otp->getUserName())); @@ -268,16 +269,20 @@ public function loginWithOTP(OAuth2OTP $otpClaim, ?Client $client = null, bool $ foreach ($grants2Revoke as $otp2Revoke){ try { Log::debug(sprintf("AuthService::loginWithOTP revoking otp %s ", $otp2Revoke->getValue())); - if($otp2Revoke->getValue() !== $otpClaim->getValue()) + if ($otp2Revoke->getValue() !== $otpClaim->getValue()) $otp2Revoke->redeem(); - } - catch (Exception $ex){ + } catch (Exception $ex) { Log::warning($ex); } } Auth::login($user, $remember); + if (!$user->hasPasswordSet() && !$new_user) { + // trigger background job + GenerateOTPRegistrationReminder::dispatch($user); + } + return $otp; }); } diff --git a/config/auth.php b/config/auth.php index 632af3eb..61f48a36 100644 --- a/config/auth.php +++ b/config/auth.php @@ -99,10 +99,10 @@ ], // in seconds - 'password_reset_lifetime' => env('AUTH_PASSWORD_RESET_LIFETIME', 600), + 'password_reset_lifetime' => env('AUTH_PASSWORD_RESET_LIFETIME', 1800), 'password_min_length' => env('AUTH_PASSWORD_MIN_LENGTH', 8), 'password_max_length' => env('AUTH_PASSWORD_MAX_LENGTH', 30), - 'verification_email_lifetime' => env ("AUTH_VERIFICATION_EMAIL_LIFETIME", 600), + 'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600), 'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1), 'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1), 'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1), diff --git a/package.json b/package.json index 5cf25369..49b31238 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "pure": "^2.85.0", "pwstrength-bootstrap": "^3.0.10", "react-google-recaptcha": "^2.1.0", + "react-otp-input": "^3.1.1", "react-password-strength-bar": "^0.4.0", "react-simplemde-editor": "^5.2.0", "simplemde": "^1.11.2", diff --git a/resources/js/login/login.js b/resources/js/login/login.js index 7b0eb15f..7bdd4270 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -23,7 +23,8 @@ import { emailValidator } from '../validator'; import Grid from '@material-ui/core/Grid'; import Swal from 'sweetalert2' import Banner from '../components/banner/banner'; -import { handleThirdPartyProvidersVerbiage } from '../utils'; +import OtpInput from 'react-otp-input'; +import {handleThirdPartyProvidersVerbiage} from '../utils'; import styles from './login.module.scss' import "./third_party_identity_providers.scss"; @@ -155,82 +156,76 @@ const PasswordInputForm = ({ } const OTPInputForm = ({ - formAction, - onAuthenticate, - disableInput, - showPassword, - passwordValue, - passwordError, - onUserPasswordChange, - handleClickShowPassword, - handleMouseDownPassword, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeRecaptcha -}) => { + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeRecaptcha, + onReset + }) => { return ( -
+ <> + + > ); } @@ -375,11 +370,15 @@ const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableI ); }) } -If you have a login, you may still choose to use a social login with the same email address to access your account.
+If you have a login, you may still choose to use a social login with the same email address to + access your account.
> ); } +const otp_flow = 'otp'; +const password_flow = 'password'; + class LoginPage extends React.Component { constructor(props) { @@ -387,12 +386,14 @@ class LoginPage extends React.Component { this.state = { user_name: props.userName, user_password: '', + otpCode: '', user_pic: props.hasOwnProperty('user_pic') ? props.user_pic : null, user_fullname: props.hasOwnProperty('user_fullname') ? props.user_fullname : null, user_verified: props.hasOwnProperty('user_verified') ? props.user_verified : false, errors: { - email: "", - password: props.authError != "" ? props.authError : "", + email: '', + otp: props.authError != '' ? props.authError : '', + password: props.authError != '' ? props.authError : '', }, captcha_value: '', showPassword: false, @@ -403,6 +404,10 @@ class LoginPage extends React.Component { infoBannerContent: props.infoBannerContent, } + if (props.authError != '' && !this.state.user_fullname) { + this.state.user_fullname = props.userName; + } + if (this.state.errors.password && this.state.errors.password.includes("is not yet verified")) { this.state.errors.password = this.state.errors.password + `Or have another verification email sent to you.`; } @@ -413,35 +418,41 @@ class LoginPage extends React.Component { this.onAuthenticate = this.onAuthenticate.bind(this); this.onChangeRecaptcha = this.onChangeRecaptcha.bind(this); this.onUserPasswordChange = this.onUserPasswordChange.bind(this); + this.onOTPCodeChange = this.onOTPCodeChange.bind(this); this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); this.handleClickShowPassword = this.handleClickShowPassword.bind(this); this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); } - handleEmitOtpAction(ev) { - ev.preventDefault(); + emitOtpAction() { let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; emitOTP(this.state.user_name, this.props.token).then((payload) => { - let { response } = payload; + let {response} = payload; this.setState({ ...this.state, - authFlow: "otp", + authFlow: otp_flow, errors: { - email: "", - password: "", + email: '', + otp: '', + password: '' }, user_verified: true, user_fullname: user_fullname, }); }, (error) => { - let { response, status, message } = error; + let {response, status, message} = error; Swal('Oops...', 'Something went wrong!', 'error') }); return false; } + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } + shouldShowCaptcha() { return ( this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && @@ -451,17 +462,20 @@ class LoginPage extends React.Component { } onAuthenticate(ev) { - if (this.state.user_password == '') { - let error = 'Password is empty'; - if (this.state.authFlow == 'OTP') { - error = 'Single-use code is empty'; + if (this.state.authFlow === otp_flow) { + if (this.state.otpCode == '') { + this.setState({...this.state, errors: {...this.state.errors, otp: 'Single-use code is empty'}}); + ev.preventDefault(); + return false; } - this.setState({ ...this.state, errors: { ...this.state.errors, password: error } }); + } else if (this.state.user_password == '') { + this.setState({...this.state, errors: {...this.state.errors, password: 'Password is empty'}}); ev.preventDefault(); return false; } + if (this.state.captcha_value == '' && this.shouldShowCaptcha()) { - this.setState({ ...this.state, errors: { ...this.state.errors, password: 'you must check CAPTCHA' } }); + this.setState({...this.state, errors: {...this.state.errors, password: 'you must check CAPTCHA'}}); ev.preventDefault(); return false; } @@ -478,17 +492,21 @@ class LoginPage extends React.Component { } onUserPasswordChange(ev) { - let { errors } = this.state; - let { value, id } = ev.target; + let {errors} = this.state; + let {value, id} = ev.target; if (value == "") // clean error errors[id] = ''; - this.setState({ ...this.state, user_password: value, errors: { ...errors } }); + this.setState({...this.state, user_password: value, errors: {...errors}}); + } + + onOTPCodeChange(value) { + this.setState({...this.state, otpCode: value}); } onValidateEmail(ev) { ev.preventDefault(); - let { user_name } = this.state; + let {user_name} = this.state; user_name = user_name.trim(); if (user_name == '') { @@ -508,12 +526,20 @@ class LoginPage extends React.Component { user_pic: response.pic, user_fullname: response.full_name, user_verified: true, + authFlow: response.has_password_set ? password_flow : otp_flow, errors: { email: '', + otp: '', password: '' }, disableInput: false - }) + }, function () { + //Once the state is updated, it's now possible to trigger emitOtpAction. + //No need to wait for the component to update. + if (!response.has_password_set) { + this.emitOtpAction(); + } + }); }, (error) => { let { response, status, message } = error; @@ -542,8 +568,9 @@ class LoginPage extends React.Component { handleDelete() { this.setState({ ...this.state, user_name: null, user_pic: null, user_fullname: null, user_verified: false, authFlow: "password", errors: { - email: "", - password: "", + email: '', + otp: '', + password: '' }, }); } @@ -564,8 +591,9 @@ class LoginPage extends React.Component {|
+
+ Thank you for using a single-use code to verify your email address on
+ {!! $site_base_url !!}. You now have an {!! Config::get('app.app_name') !!} using the email
+ address {!!$user_email!!}.
+
+ |
+ @else
+
+
+ Thank you for using a single-use code to verify your email address. You now have
+ an {!! Config::get('app.app_name') !!} using the email address {!!$user_email!!}.
+
+ |
+ @endif
+
|
+
+ In order to login more quickly in the future you can set a password (this
+ link expires in {!! $reset_password_link_lifetime !!} min but you can always use the reset your password option to get a new one).
+
+ |
+ |
|
+
+ You will use this account to access all {!! Config::get('app.tenant_name') !!} community apps and websites that require an {!! Config::get('app.app_name') !!},
+ including the virtual Open Infrastructure Summit. Your user details are associated with your {!! Config::get('app.app_name') !!} and you
+ are able to grant access to that information to each app at your discretion.
+
+ |
+
|
+
+ Thank you for using a single-use code to verify your email address on
+ {!! $site_base_url !!}. You now have an {!! Config::get('app.app_name') !!} using the email
+ address {!!$user_email!!}.
+
+ |
+ @else
+
+
+ Thank you for using a single-use code to verify your email address. You now have
+ an {!! Config::get('app.app_name') !!} using the email address {!!$user_email!!}.
+
+ |
+ @endif
+
|
+
+ In order to login more quickly in the future you can set a password (this
+ link expires in {!! $reset_password_link_lifetime !!} min but you can always use the reset your password option to get a new one).
+
+ |
+ |
|
+
+ An {!! Config::get('app.app_name') !!} is a login you can use to access
+ all {!! Config::get('app.tenant_name') !!} apps associated with this event and any other event
+ produced by
+ {!! Config::get('app.tenant_name') !!}. The apps will ask for your permission to access information
+ contained in your profile when you login.
+ Should you have any questions, please email {!! Config::get('app.help_email') !!}
+ for assistance.
+
+ |
+