From 7a7864580681737305580f30ea66b065eea6f9d3 Mon Sep 17 00:00:00 2001 From: Samuel Georges Date: Thu, 10 Sep 2026 22:04:24 +1000 Subject: [PATCH 1/3] Refactor actions to service pattern --- Plugin.php | 1 + classes/ActionManager.php | 130 +++++++++++++ .../actionmanager}/ActionBrowserSessions.php | 14 +- .../actionmanager}/ActionChangePassword.php | 13 +- classes/actionmanager/ActionDeleteUser.php | 55 ++++++ classes/actionmanager/ActionLogin.php | 179 ++++++++++++++++++ classes/actionmanager/ActionLogout.php | 57 ++++++ .../actionmanager/ActionRecoverPassword.php | 52 +++++ classes/actionmanager/ActionRegisterUser.php | 149 +++++++++++++++ .../actionmanager}/ActionResetPassword.php | 26 +-- .../actionmanager}/ActionTwoFactor.php | 32 ++-- .../actionmanager}/ActionTwoFactorLogin.php | 96 +++++----- classes/actionmanager/ActionUpdateProfile.php | 100 ++++++++++ .../actionmanager}/ActionVerifyEmail.php | 54 ++---- components/Account.php | 148 ++++++--------- components/Authentication.php | 106 ++--------- components/Registration.php | 137 ++------------ components/ResetPassword.php | 24 +-- components/Session.php | 43 ++--- components/account/ActionDeleteUser.php | 46 ----- components/authentication/ActionLogin.php | 122 ------------ .../authentication/ActionRecoverPassword.php | 49 ----- docs/action-manager.md | 122 ++++++++++++ docs/docs-lock.json | 22 ++- docs/docs.yaml | 4 + tests/ActionManagerTest.php | 126 ++++++++++++ tests/AuthenticationComponentTest.php | 9 +- tests/RegistrationComponentTest.php | 16 +- tests/ResetPasswordComponentTest.php | 8 +- 29 files changed, 1226 insertions(+), 714 deletions(-) create mode 100644 classes/ActionManager.php rename {components/account => classes/actionmanager}/ActionBrowserSessions.php (83%) rename {components/resetpassword => classes/actionmanager}/ActionChangePassword.php (80%) create mode 100644 classes/actionmanager/ActionDeleteUser.php create mode 100644 classes/actionmanager/ActionLogin.php create mode 100644 classes/actionmanager/ActionLogout.php create mode 100644 classes/actionmanager/ActionRecoverPassword.php create mode 100644 classes/actionmanager/ActionRegisterUser.php rename {components/resetpassword => classes/actionmanager}/ActionResetPassword.php (82%) rename {components/account => classes/actionmanager}/ActionTwoFactor.php (67%) rename {components/authentication => classes/actionmanager}/ActionTwoFactorLogin.php (66%) create mode 100644 classes/actionmanager/ActionUpdateProfile.php rename {components/account => classes/actionmanager}/ActionVerifyEmail.php (64%) delete mode 100644 components/account/ActionDeleteUser.php delete mode 100644 components/authentication/ActionLogin.php delete mode 100644 components/authentication/ActionRecoverPassword.php create mode 100644 docs/action-manager.md create mode 100644 tests/ActionManagerTest.php diff --git a/Plugin.php b/Plugin.php index 9dfaff06..d4e0a3ef 100644 --- a/Plugin.php +++ b/Plugin.php @@ -68,6 +68,7 @@ protected function registerAuthConfiguration() */ protected function registerSingletons() { + $this->app->singleton('user.actions', \RainLab\User\Classes\ActionManager::class); $this->app->singleton('user.twofactor', \RainLab\User\Classes\TwoFactorManager::class); // Laravel services diff --git a/classes/ActionManager.php b/classes/ActionManager.php new file mode 100644 index 00000000..9cf1f970 --- /dev/null +++ b/classes/ActionManager.php @@ -0,0 +1,130 @@ +context = $context; + + return $manager; + } + + /** + * user returns the currently authenticated user + */ + protected function user(): ?User + { + return Auth::user(); + } + + /** + * isUserPasswordValid checks a supplied password against the current user + */ + protected function isUserPasswordValid(string $password): bool + { + $user = $this->user(); + $username = UserHelper::username(); + + if (!$user || !$password) { + return false; + } + + return Auth::validate([ + $username => $user->{$username}, + 'password' => $password + ]); + } + + /** + * makePasswordBroker to be used during password reset + */ + protected function makePasswordBroker(): PasswordBroker + { + return App::make('auth.password')->broker('users'); + } + + /** + * prepareAuthenticatedSession protects against session fixation + */ + protected function prepareAuthenticatedSession() + { + if (Request::hasSession()) { + Request::session()->regenerate(); + } + } + + /** + * recordUserLogAuthenticated + */ + protected function recordUserLogAuthenticated($user, $twoFactor = false) + { + UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_LOGIN, [ + 'user_full_name' => $user->full_name, + 'is_two_factor' => $twoFactor + ]); + } + + /** + * fireSystemEvent fires through the context host when one is set, otherwise the + * global event fires with this manager as the emitting object + */ + protected function fireSystemEvent(string $event, array $params = [], bool $halt = true) + { + if ($this->context) { + return $this->context->fireSystemEvent($event, $params, $halt); + } + + return Event::fire($event, array_merge([$this], $params), $halt); + } +} diff --git a/components/account/ActionBrowserSessions.php b/classes/actionmanager/ActionBrowserSessions.php similarity index 83% rename from components/account/ActionBrowserSessions.php rename to classes/actionmanager/ActionBrowserSessions.php index 614d42db..681168b8 100644 --- a/components/account/ActionBrowserSessions.php +++ b/classes/actionmanager/ActionBrowserSessions.php @@ -1,4 +1,4 @@ -isUserPasswordValid($password)) { throw new ValidationException([ diff --git a/components/resetpassword/ActionChangePassword.php b/classes/actionmanager/ActionChangePassword.php similarity index 80% rename from components/resetpassword/ActionChangePassword.php rename to classes/actionmanager/ActionChangePassword.php index 5fd161f8..9a2b3bc3 100644 --- a/components/resetpassword/ActionChangePassword.php +++ b/classes/actionmanager/ActionChangePassword.php @@ -1,12 +1,12 @@ -user(); if (!$user) { throw new ForbiddenException; } - $this->updateUserPassword($user, post()); + $this->updateUserPassword($user, $input); UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_PASSWORD_CHANGE); diff --git a/classes/actionmanager/ActionDeleteUser.php b/classes/actionmanager/ActionDeleteUser.php new file mode 100644 index 00000000..2292e7b7 --- /dev/null +++ b/classes/actionmanager/ActionDeleteUser.php @@ -0,0 +1,55 @@ +user()) { + throw new ForbiddenException; + } + + if (!$this->isUserPasswordValid((string) array_get($input, 'password'))) { + throw new ValidationException([ + 'password' => __('This password does not match our records.'), + ]); + } + + $this->deleteUserRecord($this->user()->fresh()); + + Auth::logout(); + + if (Request::hasSession()) { + Request::session()->invalidate(); + Request::session()->regenerateToken(); + } + } + + /** + * deleteUserRecord + */ + protected function deleteUserRecord(User $user) + { + UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_DELETE, [ + 'user_full_name' => $user->full_name, + ]); + + $user->smartDelete(); + } +} diff --git a/classes/actionmanager/ActionLogin.php b/classes/actionmanager/ActionLogin.php new file mode 100644 index 00000000..6de35f70 --- /dev/null +++ b/classes/actionmanager/ActionLogin.php @@ -0,0 +1,179 @@ +ensureLoginIsNotThrottled($input); + + if (($event = $this->fireBeforeAuthenticateEvent($input)) !== null) { + if ($event === false || !$event instanceof Authenticatable) { + $this->throwFailedAuthenticationException($input); + } + + Auth::login($event, $remember); + } + elseif (!$this->attemptAuthentication($input, $remember)) { + $this->throwFailedAuthenticationException($input); + } + + $this->prepareAuthenticatedSession(); + + // Trigger login event + if ($user = Auth::user()) { + Event::fire('rainlab.user.login', [$user]); + + $this->recordUserLogAuthenticated($user); + } + + if ($event = $this->fireAuthenticateEvent()) { + return $event; + } + } + + /** + * ensureLoginIsNotThrottled + */ + protected function ensureLoginIsNotThrottled(array $input) + { + $limiter = $this->makeLoginRateLimiter($input); + + if (!$limiter->tooManyAttempts()) { + return; + } + + /** + * @event rainlab.user.lockout + * Provides custom logic when a login attempt has been rate limited. + * + * Example usage: + * + * Event::listen('rainlab.user.lockout', function () { + * // ... + * }); + * + * Or + * + * $component->bindEvent('user.lockout', function () { + * // ... + * }); + * + */ + $this->fireSystemEvent('rainlab.user.lockout'); + + $seconds = $limiter->availableIn(); + + $message = __("Too many login attempts. Please try again in :seconds seconds.", [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]); + + throw new ValidationException([UserHelper::username() => $message]); + } + + /** + * attemptAuthentication + */ + protected function attemptAuthentication(array $input, bool $remember): bool + { + $credentials = array_only($input, [UserHelper::username(), 'password']); + + Validator::make($input, [ + UserHelper::username() => 'required|string', + 'password' => 'required|string', + ])->validate(); + + return Auth::attempt($credentials, $remember); + } + + /** + * throwFailedAuthenticationException + */ + protected function throwFailedAuthenticationException(array $input) + { + $this->makeLoginRateLimiter($input)->increment(); + + throw new ValidationException([UserHelper::username() => __("These credentials do not match our records.")]); + } + + /** + * makeLoginRateLimiter + */ + protected function makeLoginRateLimiter(array $input) + { + return new \System\Classes\RateLimiter('login:'.array_get($input, UserHelper::username())); + } + + /** + * fireBeforeAuthenticateEvent returns false if the authentication failed, a user object + * if the authentication was successful (override), or null to do nothing. + */ + protected function fireBeforeAuthenticateEvent(array $input) + { + /** + * @event rainlab.user.beforeAuthenticate + * Provides custom logic for logging in a user during authentication. + * + * Example usage: + * + * Event::listen('rainlab.user.beforeAuthenticate', function ($component, $input) { + * return User::find(...); + * }); + * + * Or + * + * $component->bindEvent('user.beforeAuthenticate', function ($input) { + * return User::find(...); + * }); + * + */ + return $this->fireSystemEvent('rainlab.user.beforeAuthenticate', [&$input]); + } + + /** + * fireAuthenticateEvent can return a custom response, or null to do nothing. + */ + protected function fireAuthenticateEvent() + { + /** + * @event rainlab.user.authenticate + * Provides custom response logic after authentication + * + * Example usage: + * + * Event::listen('rainlab.user.authenticate', function ($component) { + * // Fire logic + * }); + * + * Or + * + * $component->bindEvent('user.authenticate', function () { + * // Fire logic + * }); + * + */ + return $this->fireSystemEvent('rainlab.user.authenticate'); + } +} diff --git a/classes/actionmanager/ActionLogout.php b/classes/actionmanager/ActionLogout.php new file mode 100644 index 00000000..745510e0 --- /dev/null +++ b/classes/actionmanager/ActionLogout.php @@ -0,0 +1,57 @@ +invalidate(); + Request::session()->regenerateToken(); + } + } + + if ($user) { + /** + * @event rainlab.user.logout + * Provides custom response logic for logging out a user. + * + * Example usage: + * + * Event::listen('rainlab.user.logout', function ($component, $user) { + * // Fire logic + * }); + * + * Or + * + * $component->bindEvent('user.logout', function ($user) { + * // Fire logic + * }); + * + */ + if ($event = $this->fireSystemEvent('rainlab.user.logout', [$user])) { + return $event; + } + } + } +} diff --git a/classes/actionmanager/ActionRecoverPassword.php b/classes/actionmanager/ActionRecoverPassword.php new file mode 100644 index 00000000..e516f184 --- /dev/null +++ b/classes/actionmanager/ActionRecoverPassword.php @@ -0,0 +1,52 @@ + 'required|email' + ])->validate(); + + $callback = null; + if ($resetUrl = array_get($options, 'resetUrl')) { + $callback = function($user, $token) use ($resetUrl) { + $user->setUrlForPasswordReset($resetUrl); + $user->sendPasswordResetNotification($token); + }; + } + + $status = $this->makePasswordBroker()->sendResetLink( + array_only($input, ['email']), + $callback + ); + + if ($status === PasswordBroker::RESET_THROTTLED) { + throw new ValidationException([UserHelper::username() => __("Please wait before retrying.")]); + } + + if ($status !== PasswordBroker::RESET_LINK_SENT) { + throw new ValidationException([UserHelper::username() => __("We can't find a user with that email address.")]); + } + } +} diff --git a/classes/actionmanager/ActionRegisterUser.php b/classes/actionmanager/ActionRegisterUser.php new file mode 100644 index 00000000..3e6c71e9 --- /dev/null +++ b/classes/actionmanager/ActionRegisterUser.php @@ -0,0 +1,149 @@ +bindEvent('user.beforeRegister', function (&$input) { + * return User::create(...); + * }); + * + */ + if ($event = $this->fireSystemEvent('rainlab.user.beforeRegister', [&$input])) { + $user = $event; + } + else { + $user = $this->createNewUser($input); + } + + // Approval requires an administrator to approve the user + if (Setting::get('require_approval', false)) { + $user->unapprove(); + } + + // Email verification sends a link to confirm the email address + if (Setting::get('activation_email', false) && !$user->hasVerifiedEmail()) { + if ($verifyUrl = array_get($options, 'verifyUrl')) { + $user->setUrlForEmailVerification($verifyUrl); + } + + $user->sendEmailVerificationNotification(); + } + + // Sign the user in immediately, unless an activation policy defers it + if ($this->canSignInAfterRegister($user)) { + Auth::login($user); + } + + /** + * @event rainlab.user.register + * Modify the return response after registration. + * + * Example usage: + * + * Event::listen('rainlab.user.register', function ($component, $user) { + * // Fire logic + * }); + * + * Or + * + * $component->bindEvent('user.register', function ($user) { + * // Fire logic + * }); + * + */ + if ($event = $this->fireSystemEvent('rainlab.user.register', [$user])) { + return $event; + } + + return $user; + } + + /** + * canSignInAfterRegister returns true when the activation policy allows the + * user to be signed in immediately after registering. + */ + public function canSignInAfterRegister(User $user): bool + { + if (Setting::get('require_activation', false) && !$user->hasVerifiedEmail()) { + return false; + } + + if (Setting::get('require_approval', false) && $user->isPendingApproval()) { + return false; + } + + return true; + } + + /** + * createNewUser implements the logic for creating a new user + */ + protected function createNewUser(array $input): User + { + // If the password confirmation field is absent from the request payload, + // skip it here for a smoother registration process. Every second counts! + if (!array_key_exists('password_confirmation', $input)) { + $input['password_confirmation'] = $input['password'] ?? ''; + } + + Validator::make($input, [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,NULL,id,is_guest,!1'], + 'password' => UserHelper::passwordRules(), + ])->validate(); + + $user = User::create([ + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'] ?? null, + 'email' => $input['email'], + 'password' => $input['password'], + 'password_confirmation' => $input['password_confirmation'], + ]); + + UserLog::createRecord($user->getKey(), UserLog::TYPE_NEW_USER, [ + 'user_full_name' => $user->full_name, + ]); + + return $user; + } +} diff --git a/components/resetpassword/ActionResetPassword.php b/classes/actionmanager/ActionResetPassword.php similarity index 82% rename from components/resetpassword/ActionResetPassword.php rename to classes/actionmanager/ActionResetPassword.php index fa197691..c037a486 100644 --- a/components/resetpassword/ActionResetPassword.php +++ b/classes/actionmanager/ActionResetPassword.php @@ -1,8 +1,6 @@ - 'required', 'email' => ['required', 'email'], 'password' => 'required', - ]); + ])->validate(); - $status = $this->makePasswordBroker()->reset(array_only(post(), [ + $status = $this->makePasswordBroker()->reset(array_only($input, [ 'email', 'password', 'password_confirmation', 'token' - ]), function($user) { - $this->resetUserPassword($user, post()); + ]), function($user) use ($input) { + $this->resetUserPassword($user, $input); $this->completePasswordReset($user); }); @@ -93,12 +91,4 @@ protected function completePasswordReset(User $user) UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_PASSWORD_RESET); } - - /** - * makePasswordBroker to be used during password reset. - */ - protected function makePasswordBroker(): PasswordBroker - { - return App::make('auth.password')->broker('users'); - } } diff --git a/components/account/ActionTwoFactor.php b/classes/actionmanager/ActionTwoFactor.php similarity index 67% rename from components/account/ActionTwoFactor.php rename to classes/actionmanager/ActionTwoFactor.php index f19c1e68..6c4992c6 100644 --- a/components/account/ActionTwoFactor.php +++ b/classes/actionmanager/ActionTwoFactor.php @@ -1,14 +1,13 @@ -user(); @@ -26,9 +25,9 @@ protected function fetchTwoFactorEnabled(): bool } /** - * fetchTwoFactorRecoveryCodes + * getTwoFactorRecoveryCodes returns the recovery codes for the authenticated user */ - protected function fetchTwoFactorRecoveryCodes(): array + public function getTwoFactorRecoveryCodes(): array { $user = $this->user(); @@ -40,9 +39,10 @@ protected function fetchTwoFactorRecoveryCodes(): array } /** - * actionEnableTwoFactor + * enableTwoFactor generates a two factor secret for the authenticated user, + * pending confirmation */ - protected function actionEnableTwoFactor() + public function enableTwoFactor(): void { $user = $this->user(); @@ -54,9 +54,9 @@ protected function actionEnableTwoFactor() } /** - * actionRegenerateTwoFactorRecoveryCodes + * regenerateTwoFactorRecoveryCodes for the authenticated user */ - protected function actionRegenerateTwoFactorRecoveryCodes() + public function regenerateTwoFactorRecoveryCodes(): void { $user = $this->user(); @@ -68,12 +68,12 @@ protected function actionRegenerateTwoFactorRecoveryCodes() } /** - * actionConfirmTwoFactor + * confirmTwoFactor verifies a two factor code to complete the set up */ - protected function actionConfirmTwoFactor() + public function confirmTwoFactor(array $input): void { $user = $this->user(); - $code = post('code'); + $code = array_get($input, 'code'); if ( !$user || @@ -99,9 +99,9 @@ protected function actionConfirmTwoFactor() } /** - * actionDisableTwoFactor + * disableTwoFactor for the authenticated user */ - protected function actionDisableTwoFactor() + public function disableTwoFactor(): void { $user = $this->user(); diff --git a/components/authentication/ActionTwoFactorLogin.php b/classes/actionmanager/ActionTwoFactorLogin.php similarity index 66% rename from components/authentication/ActionTwoFactorLogin.php rename to classes/actionmanager/ActionTwoFactorLogin.php index ff4afd0f..af3762a0 100644 --- a/components/authentication/ActionTwoFactorLogin.php +++ b/classes/actionmanager/ActionTwoFactorLogin.php @@ -1,11 +1,9 @@ -ensureLoginIsNotThrottled(); + $this->ensureLoginIsNotThrottled($input); - if (($event = $this->fireBeforeAuthenticateEvent()) !== null) { + if (($event = $this->fireBeforeAuthenticateEvent($input)) !== null) { if ($event === false || !$event instanceof Authenticatable) { - $this->throwFailedAuthenticationException(); + $this->throwFailedAuthenticationException($input); } $user = $event; } else { - $user = $this->attemptTwoFactorAuthentication(post()); + $user = $this->attemptTwoFactorAuthentication($input); if (!$user) { - $this->throwFailedAuthenticationException(); + $this->throwFailedAuthenticationException($input); } } // User does not have 2FA set up if (!$user->two_factor_secret || $user->two_factor_confirmed_at === null) { - return $this->actionLogin(); + return $this->login($input, $options); } Session::put('login.id', $user->getKey()); - Session::put('login.remember', $this->useRememberMe()); + Session::put('login.remember', (bool) array_get($options, 'remember', false)); - return Redirect::to(Request::fullUrlWithQuery([ - 'two-factor' => 'challenge' - ])); + return static::TWO_FACTOR_CHALLENGE; } /** - * actionTwoFactorChallenge + * twoFactorChallenge completes a login using a two factor or recovery code. Supported options: + * + * - remember: persist the user session with a cookie. Default: false. + * + * Returns a custom event response, or null. */ - protected function actionTwoFactorChallenge() + public function twoFactorChallenge(array $input, array $options = []) { $user = $this->getChallengedUser(); - if ($code = $this->getValidRecoveryCode()) { + if ($code = $this->getValidRecoveryCode($input)) { $user->replaceRecoveryCode($code); } - elseif (!$this->hasValidCode()) { - $this->throwFailedTwoFactorException(); + elseif (!$this->hasValidCode($input)) { + $this->throwFailedTwoFactorException($input); } - Auth::login($user, $this->useRememberMe()); + Auth::login($user, (bool) array_get($options, 'remember', false)); $this->prepareAuthenticatedSession(); @@ -90,46 +96,46 @@ protected function actionTwoFactorChallenge() } /** - * getChallengedUser gets the user that is attempting the two factor challenge. + * hasChallengedUser determines if there is a challenged user in the current session. */ - protected function getChallengedUser() + public function hasChallengedUser(): bool { if ($this->challengedUser) { - return $this->challengedUser; + return true; } $model = $this->getUserModel(); - if ( - !Session::has('login.id') || - !($user = $model->find(Session::get('login.id'))) - ) { - $this->throwFailedTwoFactorException(); - } - - return $this->challengedUser = $user; + return Session::has('login.id') && $model->find(Session::get('login.id')); } /** - * hasChallengedUser determines if there is a challenged user in the current session. + * getChallengedUser gets the user that is attempting the two factor challenge. */ - protected function hasChallengedUser(): bool + protected function getChallengedUser() { if ($this->challengedUser) { - return true; + return $this->challengedUser; } $model = $this->getUserModel(); - return Session::has('login.id') && $model->find(Session::get('login.id')); + if ( + !Session::has('login.id') || + !($user = $model->find(Session::get('login.id'))) + ) { + $this->throwFailedTwoFactorException([]); + } + + return $this->challengedUser = $user; } /** - * getValidRecoveryCode if one exists on the request. + * getValidRecoveryCode if one exists on the input. */ - protected function getValidRecoveryCode(): ?string + protected function getValidRecoveryCode(array $input): ?string { - $recoveryCode = post('recovery_code'); + $recoveryCode = array_get($input, 'recovery_code'); if (!$recoveryCode || !is_string($recoveryCode)) { return null; } @@ -148,11 +154,11 @@ protected function getValidRecoveryCode(): ?string } /** - * hasValidCode determines if the request has a valid two factor code. + * hasValidCode determines if the input has a valid two factor code. */ - protected function hasValidCode(): bool + protected function hasValidCode(array $input): bool { - $code = post('code'); + $code = array_get($input, 'code'); if (!$code || !is_string($code)) { return false; } @@ -212,9 +218,9 @@ protected function attemptTwoFactorAuthentication(array $input): ?Authenticatabl /** * throwFailedTwoFactorException */ - protected function throwFailedTwoFactorException() + protected function throwFailedTwoFactorException(array $input) { - if (post('recovery_code')) { + if (array_get($input, 'recovery_code')) { throw new ValidationException(['recovery_code' => __("The provided two factor recovery code was invalid.")]); } diff --git a/classes/actionmanager/ActionUpdateProfile.php b/classes/actionmanager/ActionUpdateProfile.php new file mode 100644 index 00000000..823c26e6 --- /dev/null +++ b/classes/actionmanager/ActionUpdateProfile.php @@ -0,0 +1,100 @@ +user(); + if (!$user) { + throw new ForbiddenException; + } + + // Password update requires old password, use the changePassword action instead + $input = array_except($input, ['password', 'remove_avatar']); + + /** + * @event rainlab.user.beforeUpdate + * Provides custom logic for updating a user profile. + * + * Example usage: + * + * Event::listen('rainlab.user.beforeUpdate', function ($component, $user, &$input) { + * $input['some_field'] = post('to_save'); + * }); + * + * Or + * + * $component->bindEvent('user.beforeUpdate', function ($user, &$input) { + * $input['some_field'] = post('to_save'); + * }); + * + */ + $this->fireSystemEvent('rainlab.user.beforeUpdate', [$user, &$input]); + + // Avatar upload + if ($avatarFile = array_get($options, 'avatar')) { + $user->avatar = $avatarFile; + } + elseif (array_get($options, 'removeAvatar')) { + $user->avatar = null; + } + + // Preference upload + if (($preferences = array_get($input, 'Preference')) && is_array($preferences)) { + UserPreference::setPreferencesSafe($user->id, $preferences); + } + + // Email changed + if (isset($input['email']) && $user->email !== trim($input['email'])) { + $user->forceFill(['activated_at' => null]); + + UserLog::createRecord($user->getKey(), UserLog::TYPE_SET_EMAIL, [ + 'user_full_name' => $user->full_name, + 'old_value' => $user->email, + 'new_value' => $input['email'] + ]); + } + + $user->fill($input); + $user->save(); + + /** + * @event rainlab.user.update + * Provides custom response logic after a user profile is updated. + * + * Example usage: + * + * Event::listen('rainlab.user.update', function ($component, $user, $input) { + * // ... + * }); + * + * Or + * + * $component->bindEvent('user.update', function ($user, $input) { + * // ... + * }); + * + */ + if ($event = $this->fireSystemEvent('rainlab.user.update', [$user, $input])) { + return $event; + } + } +} diff --git a/components/account/ActionVerifyEmail.php b/classes/actionmanager/ActionVerifyEmail.php similarity index 64% rename from components/account/ActionVerifyEmail.php rename to classes/actionmanager/ActionVerifyEmail.php index f9a29b41..d0c3b5cc 100644 --- a/components/account/ActionVerifyEmail.php +++ b/classes/actionmanager/ActionVerifyEmail.php @@ -1,10 +1,6 @@ -user(); @@ -42,18 +40,19 @@ protected function actionVerifyEmail() $limiter->increment(); + if ($verifyUrl = array_get($options, 'verifyUrl')) { + $user->setUrlForEmailVerification($verifyUrl); + } + $user->sendEmailVerificationNotification(); } /** - * actionConfirmEmail + * confirmVerifiedEmail marks the user email address as verified using an + * emailed verification code */ - protected function actionConfirmEmail($verifyCode = null) + public function confirmVerifiedEmail($verifyCode): void { - if ($verifyCode === null) { - $verifyCode = post('verify'); - } - // Locate user from bearer code $user = User::findUserForEmailVerification($verifyCode); if (!$user) { @@ -80,35 +79,6 @@ protected function actionConfirmEmail($verifyCode = null) } } - /** - * checkVerifyEmailRedirect - */ - protected function checkVerifyEmailRedirect() - { - $verifyCode = get('verify'); - if (!$verifyCode) { - return; - } - - try { - $this->actionConfirmEmail($verifyCode); - - if ($flash = Cms::flashFromPost(__("Thank you for verifying your email."))) { - Flash::success($flash); - } - } - catch (ApplicationException $ex) { - Flash::error($ex->getMessage()); - } - - if (in_array(get('redirect'), ['0', 'false'])) { - return; - } - - $redirectUrl = rtrim(Request::fullUrlWithQuery(['verify' => null]), '?'); - return Redirect::to($redirectUrl); - } - /** * makeVerifyRateLimiter */ diff --git a/components/Account.php b/components/Account.php index 121f1b89..56f623a6 100644 --- a/components/Account.php +++ b/components/Account.php @@ -3,13 +3,13 @@ use Cms; use Auth; use Flash; +use Request; +use Redirect; use RainLab\User\Models\User; -use RainLab\User\Models\UserLog; -use RainLab\User\Models\UserPreference; +use RainLab\User\Classes\ActionManager; use Cms\Classes\ComponentBase; use ApplicationException; use ValidationException; -use ForbiddenException; /** * Account component @@ -23,10 +23,6 @@ class Account extends ComponentBase { use \RainLab\User\Traits\ConfirmsPassword; - use \RainLab\User\Components\Account\ActionTwoFactor; - use \RainLab\User\Components\Account\ActionDeleteUser; - use \RainLab\User\Components\Account\ActionVerifyEmail; - use \RainLab\User\Components\Account\ActionBrowserSessions; /** * componentDetails @@ -69,79 +65,13 @@ public function onRun() */ public function onUpdateProfile() { - $user = $this->user(); - if (!$user) { - throw new ForbiddenException; - } - - // Password update requires old password, use RainLab\User\Components\ResetPassword instead - $input = array_except((array) post(), ['password', 'remove_avatar']); - - /** - * @event rainlab.user.beforeUpdate - * Provides custom logic for updating a user profile. - * - * Example usage: - * - * Event::listen('rainlab.user.beforeUpdate', function ($component, $user, &$input) { - * $input['some_field'] = post('to_save'); - * }); - * - * Or - * - * $component->bindEvent('user.beforeUpdate', function ($user, &$input) { - * $input['some_field'] = post('to_save'); - * }); - * - */ - $this->fireSystemEvent('rainlab.user.beforeUpdate', [$user, &$input]); - - // Avatar upload - if ($avatarFile = files('avatar')) { - $user->avatar = $avatarFile; - } - elseif (post('remove_avatar')) { - $user->avatar = null; - } + $response = $this->actions()->updateProfile((array) post(), [ + 'avatar' => files('avatar'), + 'removeAvatar' => post('remove_avatar'), + ]); - // Preference upload - if (($preferences = post('Preference')) && is_array($preferences)) { - UserPreference::setPreferencesSafe($user->id, $preferences); - } - - // Email changed - if (isset($input['email']) && $user->email !== trim($input['email'])) { - $user->forceFill(['activated_at' => null]); - - UserLog::createRecord($user->getKey(), UserLog::TYPE_SET_EMAIL, [ - 'user_full_name' => $user->full_name, - 'old_value' => $user->email, - 'new_value' => $input['email'] - ]); - } - - $user->fill($input); - $user->save(); - - /** - * @event rainlab.user.update - * Provides custom logic when a login attempt has been rate limited. - * - * Example usage: - * - * Event::listen('rainlab.user.update', function ($component, $user, $input) { - * // ... - * }); - * - * Or - * - * $component->bindEvent('user.update', function ($user, $input) { - * // ... - * }); - * - */ - if ($event = $this->fireSystemEvent('rainlab.user.update', [$user, $input])) { - return $event; + if ($response) { + return $response; } if ($flash = Cms::flashFromPost(__("Your profile has been updated."))) { @@ -158,7 +88,7 @@ public function onUpdateProfile() */ public function onVerifyEmail() { - $this->actionVerifyEmail(); + $this->actions()->sendVerifyEmail(); if ($flash = Cms::flashFromPost(__("Please check your email for instructions."))) { Flash::success($flash); @@ -173,7 +103,7 @@ public function onVerifyEmail() protected function onConfirmEmail() { try { - $this->actionConfirmEmail(post('verify')); + $this->actions()->confirmVerifiedEmail(post('verify')); } catch (ApplicationException $ex) { throw new ValidationException([ @@ -193,7 +123,7 @@ public function onEnableTwoFactor() return $result; } - $this->actionEnableTwoFactor(); + $this->actions()->enableTwoFactor(); $this->page['showConfirmation'] = true; } @@ -203,7 +133,7 @@ public function onEnableTwoFactor() */ public function onConfirmTwoFactor() { - $this->actionConfirmTwoFactor(); + $this->actions()->confirmTwoFactor(post()); $this->page['showRecoveryCodes'] = true; } @@ -225,7 +155,7 @@ public function onShowTwoFactorRecoveryCodes() */ public function onRegenerateTwoFactorRecoveryCodes() { - $this->actionRegenerateTwoFactorRecoveryCodes(); + $this->actions()->regenerateTwoFactorRecoveryCodes(); $this->page['showRecoveryCodes'] = true; } @@ -239,7 +169,7 @@ public function onDisableTwoFactor() return $result; } - $this->actionDisableTwoFactor(); + $this->actions()->disableTwoFactor(); } /** @@ -247,7 +177,7 @@ public function onDisableTwoFactor() */ protected function onDeleteOtherSessions() { - $this->actionDeleteOtherSessions(); + $this->actions()->deleteOtherSessions(post()); if ($flash = Cms::flashFromPost(__("Your other browser sessions have been logged out."))) { Flash::success($flash); @@ -263,7 +193,7 @@ protected function onDeleteOtherSessions() */ protected function onDeleteUser() { - $this->actionDeleteUser(); + $this->actions()->deleteUser(post()); if ($flash = Cms::flashFromPost(__("Your account has been removed from our system."))) { Flash::success($flash); @@ -274,6 +204,36 @@ protected function onDeleteUser() } } + /** + * checkVerifyEmailRedirect verifies the email address using a code found in + * the page URL, then redirects to remove the code + */ + protected function checkVerifyEmailRedirect() + { + $verifyCode = get('verify'); + if (!$verifyCode) { + return; + } + + try { + $this->actions()->confirmVerifiedEmail($verifyCode); + + if ($flash = Cms::flashFromPost(__("Thank you for verifying your email."))) { + Flash::success($flash); + } + } + catch (ApplicationException $ex) { + Flash::error($ex->getMessage()); + } + + if (in_array(get('redirect'), ['0', 'false'])) { + return; + } + + $redirectUrl = rtrim(Request::fullUrlWithQuery(['verify' => null]), '?'); + return Redirect::to($redirectUrl); + } + /** * user returns the logged in user */ @@ -287,7 +247,7 @@ public function user(): ?User */ public function sessions(): array { - return $this->fetchSessions(); + return $this->actions()->getBrowserSessions(); } /** @@ -295,7 +255,7 @@ public function sessions(): array */ public function twoFactorEnabled(): bool { - return $this->fetchTwoFactorEnabled(); + return $this->actions()->hasTwoFactorEnabled(); } /** @@ -303,6 +263,14 @@ public function twoFactorEnabled(): bool */ public function twoFactorRecoveryCodes(): array { - return $this->fetchTwoFactorRecoveryCodes(); + return $this->actions()->getTwoFactorRecoveryCodes(); + } + + /** + * actions returns user workflow services hosted by this component + */ + protected function actions(): ActionManager + { + return ActionManager::instance()->withContext($this); } } diff --git a/components/Authentication.php b/components/Authentication.php index 5791ee0a..d6dcfd5a 100644 --- a/components/Authentication.php +++ b/components/Authentication.php @@ -4,9 +4,10 @@ use Flash; use Config; use Request; +use Redirect; use Cms\Classes\ComponentBase; -use RainLab\User\Models\UserLog; use RainLab\User\Models\Setting; +use RainLab\User\Classes\ActionManager; use RainLab\User\Helpers\User as UserHelper; use NotFoundException; @@ -15,10 +16,6 @@ */ class Authentication extends ComponentBase { - use \RainLab\User\Components\Authentication\ActionLogin; - use \RainLab\User\Components\Authentication\ActionTwoFactorLogin; - use \RainLab\User\Components\Authentication\ActionRecoverPassword; - const REMEMBER_ALWAYS = 'always'; const REMEMBER_NEVER = 'never'; const REMEMBER_ASK = 'ask'; @@ -85,13 +82,20 @@ public function getRedirectOptions() */ public function onLogin() { - if ($this->useTwoFactorAuth()) { - if ($response = $this->actionLoginWithTwoFactor()) { - return $response; - } + $options = ['remember' => $this->useRememberMe()]; + + $result = $this->useTwoFactorAuth() + ? $this->actions()->loginWithTwoFactor(post(), $options) + : $this->actions()->login(post(), $options); + + if ($result === ActionManager::TWO_FACTOR_CHALLENGE) { + return Redirect::to(Request::fullUrlWithQuery([ + 'two-factor' => 'challenge' + ])); } - elseif ($response = $this->actionLogin()) { - return $response; + + if ($result) { + return $result; } if ($redirect = Cms::redirectIntendedFromPost($this->makeRedirectUrl())) { @@ -120,7 +124,7 @@ public function onTwoFactorChallenge() throw new NotFoundException; } - if ($response = $this->actionTwoFactorChallenge()) { + if ($response = $this->actions()->twoFactorChallenge(post(), ['remember' => $this->useRememberMe()])) { return $response; } @@ -138,9 +142,7 @@ public function onRecoverPassword() throw new NotFoundException; } - if ($response = $this->actionRecoverPassword()) { - return $response; - } + $this->actions()->recoverPassword(post()); if ($flash = Cms::flashFromPost(__("Please check your email. We have sent instructions to reset your password."))) { Flash::success($flash); @@ -164,7 +166,7 @@ public function showLoginForm(): bool */ public function showTwoFactorChallenge(): bool { - return $this->useTwoFactorAuth() && get('two-factor') === 'challenge' && $this->hasChallengedUser(); + return $this->useTwoFactorAuth() && get('two-factor') === 'challenge' && $this->actions()->hasChallengedUser(); } /** @@ -224,77 +226,11 @@ public function canRegister(): bool } /** - * recordUserLogAuthenticated - */ - protected function recordUserLogAuthenticated($user, $twoFactor = false) - { - UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_LOGIN, [ - 'user_full_name' => $user->full_name, - 'is_two_factor' => $twoFactor - ]); - } - - /** - * prepareAuthenticatedSession - */ - protected function prepareAuthenticatedSession() - { - if (Request::hasSession()) { - Request::session()->regenerate(); - } - } - - /** - * fireBeforeAuthenticateEvent returns false if the authentication failed, a user object - * if the authentication was successful (override), or null to do nothing. - */ - protected function fireBeforeAuthenticateEvent() - { - $input = post(); - - /** - * @event rainlab.user.beforeAuthenticate - * Provides custom logic for logging in a user during authentication. - * - * Example usage: - * - * Event::listen('rainlab.user.beforeAuthenticate', function ($component, $input) { - * return User::find(...); - * }); - * - * Or - * - * $component->bindEvent('user.beforeAuthenticate', function ($input) { - * return User::find(...); - * }); - * - */ - return $this->fireSystemEvent('rainlab.user.beforeAuthenticate', [&$input]); - } - - /** - * fireAuthenticateEvent can return a custom response, or null to do nothing. + * actions returns user workflow services hosted by this component */ - protected function fireAuthenticateEvent() + protected function actions(): ActionManager { - /** - * @event rainlab.user.authenticate - * Provides custom response logic after authentication - * - * Example usage: - * - * Event::listen('rainlab.user.authenticate', function ($component) { - * // Fire logic - * }); - * - * Or - * - * $component->bindEvent('user.authenticate', function () { - * // Fire logic - * }); - * - */ - return $this->fireSystemEvent('rainlab.user.authenticate'); + return ActionManager::instance()->withContext($this); } /** diff --git a/components/Registration.php b/components/Registration.php index 6b03ed65..e45cc864 100644 --- a/components/Registration.php +++ b/components/Registration.php @@ -1,13 +1,9 @@ bindEvent('user.beforeRegister', function (&$input) { - * return User::create(...); - * }); - * - */ - if ($event = $this->fireSystemEvent('rainlab.user.beforeRegister', [&$input])) { - $user = $event; - } - else { - $user = $this->createNewUser($input); - } - - $requireActivation = Setting::get('require_activation', false); - $requireApproval = Setting::get('require_approval', false); - - // Approval requires an administrator to approve the user - if ($requireApproval) { - $user->unapprove(); + $result = $this->actions()->registerUser(post()); + if (!$result instanceof User) { + return $result; } - // Email verification sends a link to confirm the email address - if (Setting::get('activation_email', false) && !$user->hasVerifiedEmail()) { - $user->sendEmailVerificationNotification(); - } + $user = $result; - // Sign the user in immediately, unless an activation policy defers it - $canSignIn = $this->canSignInAfterRegister($user); - if ($canSignIn) { - Auth::login($user); - } - else { - // Inform the markup why the user is not signed in, based on which activation policies are active - $this->page['awaitingActivation'] = $requireActivation && !$user->hasVerifiedEmail(); - $this->page['awaitingApproval'] = $requireApproval && $user->isPendingApproval(); - } - - /** - * @event rainlab.user.register - * Modify the return response after registration. - * - * Example usage: - * - * Event::listen('rainlab.user.register', function ($component, $user) { - * // Fire logic - * }); - * - * Or - * - * $component->bindEvent('user.register', function ($user) { - * // Fire logic - * }); - * - */ - if ($event = $this->fireSystemEvent('rainlab.user.register', [$user])) { - return $event; + // Sign in deferred by an activation policy, inform the markup why + if (!$this->actions()->canSignInAfterRegister($user)) { + $this->page['awaitingActivation'] = Setting::get('require_activation', false) && !$user->hasVerifiedEmail(); + $this->page['awaitingApproval'] = Setting::get('require_approval', false) && $user->isPendingApproval(); + return; } // Redirect to the intended page after successful registration, // falling back to the component's redirect property - if ($canSignIn && ($redirect = Cms::redirectIntendedFromPost($this->makeRedirectUrl()))) { + if ($redirect = Cms::redirectIntendedFromPost($this->makeRedirectUrl())) { return $redirect; } } - /** - * canSignInAfterRegister returns true when the activation policy allows the - * user to be signed in immediately after registering. - */ - protected function canSignInAfterRegister(User $user): bool - { - if (Setting::get('require_activation', false) && !$user->hasVerifiedEmail()) { - return false; - } - - if (Setting::get('require_approval', false) && $user->isPendingApproval()) { - return false; - } - - return true; - } - /** * makeRedirectUrl resolves the redirect property to a URL, or null when unset */ @@ -168,43 +90,18 @@ protected function makeRedirectUrl(): ?string } /** - * createNewUser implements the logic for creating a new user + * canRegister checks if the registration is allowed */ - protected function createNewUser(array $input): User + public function canRegister(): bool { - // If the password confirmation field is absent from the request payload, - // skip it here for a smoother registration process. Every second counts! - if (!array_key_exists('password_confirmation', $input)) { - $input['password_confirmation'] = $input['password'] ?? ''; - } - - Validator::make($input, [ - 'first_name' => ['required', 'string', 'max:255'], - 'last_name' => ['string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,NULL,id,is_guest,!1'], - 'password' => UserHelper::passwordRules(), - ])->validate(); - - $user = User::create([ - 'first_name' => $input['first_name'], - 'last_name' => $input['last_name'] ?? null, - 'email' => $input['email'], - 'password' => $input['password'], - 'password_confirmation' => $input['password_confirmation'], - ]); - - UserLog::createRecord($user->getKey(), UserLog::TYPE_NEW_USER, [ - 'user_full_name' => $user->full_name, - ]); - - return $user; + return Setting::get('allow_registration'); } /** - * canRegister checks if the registration is allowed + * actions returns user workflow services hosted by this component */ - public function canRegister(): bool + protected function actions(): ActionManager { - return Setting::get('allow_registration'); + return ActionManager::instance()->withContext($this); } } diff --git a/components/ResetPassword.php b/components/ResetPassword.php index de0c8fae..b049f46d 100644 --- a/components/ResetPassword.php +++ b/components/ResetPassword.php @@ -4,6 +4,7 @@ use Auth; use Flash; use RainLab\User\Models\User; +use RainLab\User\Classes\ActionManager; use Cms\Classes\ComponentBase; /** @@ -18,9 +19,6 @@ */ class ResetPassword extends ComponentBase { - use \RainLab\User\Components\ResetPassword\ActionResetPassword; - use \RainLab\User\Components\ResetPassword\ActionChangePassword; - /** * componentDetails */ @@ -52,9 +50,7 @@ public function defineProperties() */ public function onConfirmPassword() { - if ($response = $this->actionResetPassword()) { - return $response; - } + $this->actions()->resetPassword(post()); if ($flash = Cms::flashFromPost(__("Your password has been created and you may now sign in to your account"))) { Flash::success($flash); @@ -70,9 +66,7 @@ public function onConfirmPassword() */ public function onResetPassword() { - if ($response = $this->actionResetPassword()) { - return $response; - } + $this->actions()->resetPassword(post()); if ($flash = Cms::flashFromPost(__("Your password has been reset"))) { Flash::success($flash); @@ -88,9 +82,7 @@ public function onResetPassword() */ public function onChangePassword() { - if ($response = $this->actionChangePassword()) { - return $response; - } + $this->actions()->changePassword(post()); if ($flash = Cms::flashFromPost(__("Your password has been changed"))) { Flash::success($flash); @@ -148,4 +140,12 @@ public function token() { return get('reset'); } + + /** + * actions returns user workflow services hosted by this component + */ + protected function actions(): ActionManager + { + return ActionManager::instance()->withContext($this); + } } diff --git a/components/Session.php b/components/Session.php index e637cb32..a5b4e531 100644 --- a/components/Session.php +++ b/components/Session.php @@ -11,6 +11,7 @@ use Cms\Classes\ComponentBase; use RainLab\User\Models\User; use RainLab\User\Models\UserGroup; +use RainLab\User\Classes\ActionManager; use SystemException; /** @@ -156,38 +157,8 @@ public function onRun() */ public function onLogout() { - $user = Auth::user(); - - if (Auth::isImpersonator()) { - Auth::stopImpersonate(); - } - else { - Auth::logout(); - Request::session()->invalidate(); - Request::session()->regenerateToken(); - } - - if ($user) { - /** - * @event rainlab.user.logout - * Provides custom response logic for logging out a user. - * - * Example usage: - * - * Event::listen('rainlab.user.logout', function ($component, $user) { - * // Fire logic - * }); - * - * Or - * - * $component->bindEvent('user.logout', function ($user) { - * // Fire logic - * }); - * - */ - if ($event = $this->fireSystemEvent('rainlab.user.logout', [$user])) { - return $event; - } + if ($event = $this->actions()->logout()) { + return $event; } if ($flash = Cms::flashFromPost(__("You have been successfully logged out!"))) { @@ -199,6 +170,14 @@ public function onLogout() } } + /** + * actions returns user workflow services hosted by this component + */ + protected function actions(): ActionManager + { + return ActionManager::instance()->withContext($this); + } + /** * user returns the logged in user */ diff --git a/components/account/ActionDeleteUser.php b/components/account/ActionDeleteUser.php deleted file mode 100644 index 046e01e9..00000000 --- a/components/account/ActionDeleteUser.php +++ /dev/null @@ -1,46 +0,0 @@ -isUserPasswordValid(post('password'))) { - throw new ValidationException([ - 'password' => __('This password does not match our records.'), - ]); - } - - $this->deleteUser($this->user()->fresh()); - - Auth::logout(); - Request::session()->invalidate(); - Request::session()->regenerateToken(); - } - - /** - * deleteUser - */ - protected function deleteUser(User $user) - { - UserLog::createRecord($user->getKey(), UserLog::TYPE_SELF_DELETE, [ - 'user_full_name' => $user->full_name, - ]); - - $user->smartDelete(); - } -} diff --git a/components/authentication/ActionLogin.php b/components/authentication/ActionLogin.php deleted file mode 100644 index dfd6a87f..00000000 --- a/components/authentication/ActionLogin.php +++ /dev/null @@ -1,122 +0,0 @@ -ensureLoginIsNotThrottled(); - - if (($event = $this->fireBeforeAuthenticateEvent()) !== null) { - if ($event === false || !$event instanceof Authenticatable) { - $this->throwFailedAuthenticationException(); - } - - Auth::login($event, $this->useRememberMe()); - } - elseif (!$this->attemptAuthentication(post())) { - $this->throwFailedAuthenticationException(); - } - - $this->prepareAuthenticatedSession(); - - // Trigger login event - if ($user = Auth::user()) { - Event::fire('rainlab.user.login', [$user]); - - $this->recordUserLogAuthenticated($user); - } - - if ($event = $this->fireAuthenticateEvent()) { - return $event; - } - } - - /** - * ensureLoginIsNotThrottled - */ - protected function ensureLoginIsNotThrottled() - { - $limiter = $this->makeLoginRateLimiter(); - - if (!$limiter->tooManyAttempts()) { - return; - } - - /** - * @event rainlab.user.lockout - * Provides custom logic when a login attempt has been rate limited. - * - * Example usage: - * - * Event::listen('rainlab.user.lockout', function () { - * // ... - * }); - * - * Or - * - * $component->bindEvent('user.lockout', function () { - * // ... - * }); - * - */ - $this->fireSystemEvent('rainlab.user.lockout'); - - $seconds = $limiter->availableIn(); - - $message = __("Too many login attempts. Please try again in :seconds seconds.", [ - 'seconds' => $seconds, - 'minutes' => ceil($seconds / 60), - ]); - - throw new ValidationException([UserHelper::username() => $message]); - } - - /** - * attemptAuthentication - */ - protected function attemptAuthentication(array $input): bool - { - $credentials = array_only($input, [UserHelper::username(), 'password']); - - Validator::make($input, [ - UserHelper::username() => 'required|string', - 'password' => 'required|string', - ])->validate(); - - return Auth::attempt($credentials, $this->useRememberMe()); - } - - /** - * throwFailedAuthenticationException - */ - protected function throwFailedAuthenticationException() - { - $this->makeLoginRateLimiter()->increment(); - - throw new ValidationException([UserHelper::username() => __("These credentials do not match our records.")]); - } - - /** - * makeLoginRateLimiter - */ - protected function makeLoginRateLimiter() - { - return new \System\Classes\RateLimiter('login:'.post(UserHelper::username())); - } -} diff --git a/components/authentication/ActionRecoverPassword.php b/components/authentication/ActionRecoverPassword.php deleted file mode 100644 index b08b180c..00000000 --- a/components/authentication/ActionRecoverPassword.php +++ /dev/null @@ -1,49 +0,0 @@ - 'required|email']); - - $status = $this->makePasswordBroker()->sendResetLink([ - 'email' => post('email') - ]); - - if ($status === PasswordBroker::RESET_THROTTLED) { - throw new ValidationException([UserHelper::username() => __("Please wait before retrying.")]); - } - - if ($status !== PasswordBroker::RESET_LINK_SENT) { - throw new ValidationException([UserHelper::username() => __("We can't find a user with that email address.")]); - } - } - - /** - * makePasswordBroker to be used during password reset. - */ - protected function makePasswordBroker(): PasswordBroker - { - return App::make('auth.password')->broker('users'); - } -} diff --git a/docs/action-manager.md b/docs/action-manager.md new file mode 100644 index 00000000..4a2341d7 --- /dev/null +++ b/docs/action-manager.md @@ -0,0 +1,122 @@ +# Action Manager + +The `RainLab\User\Classes\ActionManager` class implements the user workflows shared by the CMS components, such as registration, authentication and password recovery. The same workflows can be called directly, making them available to headless integrations such as REST or GraphQL endpoints, without duplicating any logic. + +Access the manager using the `instance` method. + +```php +$actions = \RainLab\User\Classes\ActionManager::instance(); +``` + +Every workflow enforces the same policies as the CMS components, including registration availability, login throttling, banned user checks and the activation settings. + +## Available Actions + +Method | Description +------------- | ------------- +`registerUser($input, $options)` | creates a new user, returns the user or a custom event response. +`login($input, $options)` | authenticates a user from credentials. +`loginWithTwoFactor($input, $options)` | authenticates and defers to a two factor challenge when set up. +`twoFactorChallenge($input, $options)` | completes a login using a two factor or recovery code. +`logout()` | signs out the user, or reverts an impersonation. +`recoverPassword($input, $options)` | sends a password reset link by email. +`resetPassword($input)` | sets a new password using a reset token. +`changePassword($input)` | updates the password of the signed in user. +`updateProfile($input, $options)` | updates the profile of the signed in user. +`sendVerifyEmail($options)` | sends an email verification link to the signed in user. +`confirmVerifiedEmail($code)` | marks an email address as verified using an emailed code. +`deleteUser($input)` | removes the signed in user, requires their password. +`enableTwoFactor()` | generates a two factor secret, pending confirmation. +`confirmTwoFactor($input)` | verifies a two factor code to complete the set up. +`disableTwoFactor()` | disables two factor authentication. +`regenerateTwoFactorRecoveryCodes()` | issues a new set of recovery codes. +`getTwoFactorRecoveryCodes()` | returns the recovery codes. +`hasTwoFactorEnabled()` | returns true when two factor is set up. +`getBrowserSessions()` | returns the browser sessions, requires the database session driver. +`deleteOtherSessions($input)` | logs out other browser sessions, requires the user password. + +Workflows throw a `ValidationException` when the input is invalid, making them compatible with standard AJAX and API error responses. + +## Registering a User + +The `registerUser` method creates a new user from an input array and signs them in, unless an activation policy defers the sign in. + +```php +$user = $actions->registerUser([ + 'first_name' => 'Some', + 'email' => 'some@website.tld', + 'password' => 'ChangeMe888', +]); +``` + +Pass a `verifyUrl` option to override the CMS entry point in the verification email, useful when the confirmation page lives in an external application. + +```php +$user = $actions->registerUser($input, [ + 'verifyUrl' => 'https://my.app.tld/verify-email' +]); +``` + +The `canSignInAfterRegister` method checks if the activation policy allows the user to sign in, for example, to inform the response that the account is awaiting approval. + +```php +if (!$actions->canSignInAfterRegister($user)) { + // Inform the user to check their email or wait for approval +} +``` + +## Authentication + +The `login` method authenticates the user from credentials and starts a session. The `remember` option persists the session with a cookie. + +```php +$actions->login([ + 'email' => 'some@website.tld', + 'password' => 'ChangeMe888', +], [ + 'remember' => true +]); +``` + +Use `loginWithTwoFactor` to respect two factor authentication, it returns the `ActionManager::TWO_FACTOR_CHALLENGE` constant when the user must complete a challenge, submitted with the `twoFactorChallenge` method. + +```php +$result = $actions->loginWithTwoFactor($input); + +if ($result === \RainLab\User\Classes\ActionManager::TWO_FACTOR_CHALLENGE) { + // Ask for the two factor code, then complete the login + $actions->twoFactorChallenge(['code' => '123456']); +} +``` + +## Password Recovery + +The `recoverPassword` method sends a reset link to the supplied email address. Pass a `resetUrl` option to override the CMS entry point in the email, the reset token and email address are appended as query parameters. + +```php +$actions->recoverPassword([ + 'email' => 'some@website.tld' +], [ + 'resetUrl' => 'https://my.app.tld/reset-password' +]); +``` + +The `resetPassword` method completes the process using the token from the email. + +```php +$actions->resetPassword([ + 'email' => 'some@website.tld', + 'token' => $token, + 'password' => 'NewPassword888', +]); +``` + +## Events + +All events fired by the workflows, such as `rainlab.user.beforeRegister` and `rainlab.user.login`, continue to fire when called directly. When a workflow runs inside a CMS component, the component is passed as the first event argument, otherwise the manager instance takes its place. + +The `withContext` method returns a copy of the manager that fires events through a host object, this is how the CMS components invoke their workflows. + +```php +$actions = \RainLab\User\Classes\ActionManager::instance()->withContext($this); +``` diff --git a/docs/docs-lock.json b/docs/docs-lock.json index fb9009c8..aa87b8ff 100644 --- a/docs/docs-lock.json +++ b/docs/docs-lock.json @@ -14,9 +14,14 @@ "description": "logging and extending user activity events", "slug": "activity-log" }, + { + "title": "Merge Users", + "description": "merging duplicate users into a single account", + "slug": "merge-users" + }, { "title": "Tailor Integration", - "description": "guide on integration with TAilor", + "description": "guide on integration with Tailor", "slug": "tailor" } ], @@ -56,6 +61,11 @@ { "title": "Services", "children": [ + { + "title": "Action Manager", + "description": "user workflows for components and headless integrations", + "slug": "action-manager" + }, { "title": "Auth Manager", "description": "services for managing the user session", @@ -76,14 +86,16 @@ ], "content": { "introduction": "# Introduction\n\nThe User plugin brings frontend users to the CMS, allowing your users to register and sign in to their account.\n\nTo get started, we recommend installing this plugin with the `RainLab.Vanilla` theme to demonstrate its functionality.\n\n- https:\/\/github.com\/rainlab\/vanilla-theme\n", - "events": "# Events\n\nThis plugin will fire some global events that can be useful for interacting with other plugins.\n\nEvents | Description\n------ | ---------------\n**rainlab.user.beforeAuthenticate** | Before the user is attempting to authenticate using the Authentication component.\n**rainlab.user.authenticate** | Provides custom response logic after authentication.\n**rainlab.user.login** | The user has successfully signed in.\n**rainlab.user.logout** | The user has successfully signed out.\n**rainlab.user.lockout** | Provides custom logic when a login attempt has been rate limited.\n**rainlab.user.activate** | The user has verified their email address.\n**rainlab.user.deactivate** | The user has opted-out of the site by deactivating their account. This should be used to disable any content the user may want removed.\n**rainlab.user.beforeRegister** | Before the user's registration is processed. Passed the `$input` variable by reference to enable direct modifications to the user input.\n**rainlab.user.register** | Provides custom response logic after registration.\n**rainlab.user.passwordReset** | Provides custom logic for resetting a user password.\n**rainlab.user.beforeUpdate** | Before the user updates their profile from the Account component.\n**rainlab.user.update** | The user has updated their profile information.\n**rainlab.user.canDeleteUser** | Triggered before a user is deleted. This event should return true if the user has dependencies and should be soft deleted to retain those relationships and allow the user to be restored. Otherwise, it will be deleted forever.\n**rainlab.user.getNotificationVars** | Fires when sending a user notification to enable passing more variables to the email templates. Passes the `$user` model the template will be for.\n**rainlab.user.view.extendListToolbar** | Fires when the user listing page's toolbar is rendered.\n**rainlab.user.view.extendPreviewToolbar** | Fires when the user preview page's toolbar is rendered.\n**rainlab.user.view.extendPreviewTabs** | Provides an opportunity to add tabs to the user preview page in the admin panel. The event should return an array of `[Tab Name => ~\/path\/to\/partial.php]`\n**rainlab.user.extendLogDetailViewPath** | Return a custom partial path for rendering a log type's detail text. Receives the `$record` model and `$type` string. See the [Activity Log](.\/activity-log.md) article.\n**rainlab.user.extendLogTypeOptions** | Return an array of `['type-slug' => 'Label']` to add custom log types to the activity type filter. See the [Activity Log](.\/activity-log.md) article.\n\nHere is an example of hooking an event:\n\n```php\nEvent::listen('rainlab.user.deactivate', function($user) {\n \/\/ Hide all posts by the user\n});\n```\n\nA common requirement is to adapt another to a legacy authentication system. In the example below, the `WordPressLogin::check` method would check the user password using an alternative hashing method, and if successful, update to the new one used by October.\n\n```php\nEvent::listen('rainlab.user.beforeAuthenticate', function($component, $credentials) {\n $email = $credentials['email'] ?? null;\n $password = $credentials['password'] ?? null;\n\n \/\/ Check that the user exists with the provided email\n $user = Auth::getProvider()->retrieveByCredentials(['email' => $email]);\n if (!$user) {\n return;\n }\n\n \/\/ The user is logging in with their old WordPress account\n \/\/ for the first time. Rehash their password using the new\n \/\/ October system.\n if (WordPressLogin::check($user->password, $password)) {\n $user->password = $user->password_confirmation = $password;\n $user->forceSave();\n }\n});\n```\n", + "events": "# Events\n\nThis plugin will fire some global events that can be useful for interacting with other plugins.\n\nEvents | Description\n------ | ---------------\n**rainlab.user.beforeAuthenticate** | Before the user is attempting to authenticate using the Authentication component.\n**rainlab.user.authenticate** | Provides custom response logic after authentication.\n**rainlab.user.login** | The user has successfully signed in.\n**rainlab.user.logout** | The user has successfully signed out.\n**rainlab.user.lockout** | Provides custom logic when a login attempt has been rate limited.\n**rainlab.user.activate** | The user has verified their email address.\n**rainlab.user.deactivate** | The user has opted-out of the site by deactivating their account. This should be used to disable any content the user may want removed.\n**rainlab.user.beforeRegister** | Before the user's registration is processed. Passed the `$input` variable by reference to enable direct modifications to the user input.\n**rainlab.user.register** | Provides custom response logic after registration.\n**rainlab.user.passwordReset** | Provides custom logic for resetting a user password.\n**rainlab.user.beforeUpdate** | Before the user updates their profile from the Account component.\n**rainlab.user.update** | The user has updated their profile information.\n**rainlab.user.canDeleteUser** | Triggered before a user is deleted. This event should return true if the user has dependencies and should be soft deleted to retain those relationships and allow the user to be restored. Otherwise, it will be deleted forever.\n**rainlab.user.getNotificationVars** | Fires when sending a user notification to enable passing more variables to the email templates. Passes the `$user` model the template will be for.\n**rainlab.user.mergeUser** | Fires when a user is being merged into another. Plugins should reassign any records owned by the merged user. See the [Merge Users](.\/merge-users.md) article.\n**rainlab.user.view.extendListToolbar** | Fires when the user listing page's toolbar is rendered.\n**rainlab.user.view.extendPreviewToolbar** | Fires when the user preview page's toolbar is rendered.\n**rainlab.user.view.extendPreviewTabs** | Provides an opportunity to add tabs to the user preview page in the admin panel. The event should return an array of `[Tab Name => ~\/path\/to\/partial.php]`\n**rainlab.user.extendLogDetailViewPath** | Return a custom partial path for rendering a log type's detail text. Receives the `$record` model and `$type` string. See the [Activity Log](.\/activity-log.md) article.\n**rainlab.user.extendLogTypeOptions** | Return an array of `['type-slug' => 'Label']` to add custom log types to the activity type filter. See the [Activity Log](.\/activity-log.md) article.\n\nHere is an example of hooking an event:\n\n```php\nEvent::listen('rainlab.user.deactivate', function($user) {\n \/\/ Hide all posts by the user\n});\n```\n\nA common requirement is to adapt another to a legacy authentication system. In the example below, the `WordPressLogin::check` method would check the user password using an alternative hashing method, and if successful, update to the new one used by October.\n\n```php\nEvent::listen('rainlab.user.beforeAuthenticate', function($component, $credentials) {\n $email = $credentials['email'] ?? null;\n $password = $credentials['password'] ?? null;\n\n \/\/ Check that the user exists with the provided email\n $user = Auth::getProvider()->retrieveByCredentials(['email' => $email]);\n if (!$user) {\n return;\n }\n\n \/\/ The user is logging in with their old WordPress account\n \/\/ for the first time. Rehash their password using the new\n \/\/ October system.\n if (WordPressLogin::check($user->password, $password)) {\n $user->password = $user->password_confirmation = $password;\n $user->forceSave();\n }\n});\n```\n", "activity-log": "# Activity Log\n\nThe User plugin includes an activity log that records key events in a user's lifecycle, such as signing in, changing their password, or being banned by an administrator. These events are displayed in a timeline view accessible from the backend.\n\n## Logging Custom Events\n\nOther plugins can log custom events to a user's activity timeline using the `UserLog` model.\n\n```php\nuse RainLab\\User\\Models\\UserLog;\n\nUserLog::createRecord($userId, 'acme-order-placed', [\n 'order_id' => $order->id,\n 'order_total' => $order->total,\n]);\n```\n\nUse the `createRecord` method for user-initiated events and the `createSystemRecord` method for events initiated by an administrator.\n\n```php\nUserLog::createSystemRecord($userId, 'acme-order-refunded', [\n 'order_id' => $order->id,\n]);\n```\n\nBoth methods accept the following arguments.\n\nArgument | Description\n-------- | -----------\n**$userId** | the user ID the log entry belongs to\n**$type** | a unique string identifier for the event type\n**$data** | an optional array of extra data stored as JSON\n\nThe IP address is captured automatically with each log entry.\n\n## Registering a Detail Partial\n\nEach log type renders its detail text using a partial file. Listen to the `rainlab.user.extendLogDetailViewPath` event to return a path to your custom partial.\n\n```php\nEvent::listen('rainlab.user.extendLogDetailViewPath', function($record, $type) {\n if ($type === 'acme-order-placed') {\n return plugins_path('acme\/shop\/models\/userlog\/_detail_order_placed.php');\n }\n});\n```\n\nInside the partial, the `$record` variable is the `UserLog` model instance. You can access any data stored in the `$data` array as attributes on the record, along with the following built-in attributes.\n\nAttribute | Description\n--------- | -----------\n**$record->actor_user_name** | the name of the affected user\n**$record->actor_user_name_linked** | the user name as an HTML link to their profile\n**$record->actor_admin_name** | the name of the admin who performed the action\n**$record->actor_admin_name_linked** | the admin name as an HTML link to their profile\n**$record->is_system** | true if the event was initiated by an admin\n\nHere is an example partial.\n\n```php\n $record->actor_user_name_linked,\n 'order_id' => $record->order_id,\n]) ?>\n```\n\nIf no partial is found for a log type, the timeline will display the type string followed by \"event\" as a fallback.\n\n## Registering Filter Options\n\nLog types registered by your plugin can appear in the activity type filter on the Timelines page. Listen to the `rainlab.user.extendLogTypeOptions` event and return an array of type identifiers and their labels.\n\n```php\nEvent::listen('rainlab.user.extendLogTypeOptions', function() {\n return [\n 'acme-order-placed' => __(\"Order Placed\"),\n 'acme-order-refunded' => __(\"Order Refunded\"),\n ];\n});\n```\n\n## Available Log Types\n\nThe following log types are included with the plugin.\n\nType | Constant | Description\n---- | -------- | -----------\nnew-user | `TYPE_NEW_USER` | a new user was created\nset-email | `TYPE_SET_EMAIL` | a user's email address was changed\nset-password | `TYPE_SET_PASSWORD` | a user's password was changed by an admin\nset-two-factor | `TYPE_SET_TWO_FACTOR` | two-factor authentication was enabled or disabled\nself-verify | `TYPE_SELF_VERIFY` | a user verified their email address\nself-login | `TYPE_SELF_LOGIN` | a user signed in\nself-delete | `TYPE_SELF_DELETE` | a user deleted their account\nself-password-reset | `TYPE_SELF_PASSWORD_RESET` | a user reset their password via the forgot password flow\nself-password-change | `TYPE_SELF_PASSWORD_CHANGE` | a user changed their password from their account\nadmin-impersonate | `TYPE_ADMIN_IMPERSONATE` | an admin impersonated a user\nadmin-ban | `TYPE_ADMIN_BAN` | an admin banned a user\nadmin-unban | `TYPE_ADMIN_UNBAN` | an admin unbanned a user\nadmin-delete | `TYPE_ADMIN_DELETE` | an admin deleted a user\nadmin-restore | `TYPE_ADMIN_RESTORE` | an admin restored a soft-deleted user\nadmin-convert-guest | `TYPE_ADMIN_CONVERT_GUEST` | an admin converted a guest to a registered user\ninternal-comment | `TYPE_INTERNAL_COMMENT` | an internal comment added by an admin\n", + "merge-users": "# Merge Users\n\nWhen users check out as guests, the system creates a new guest user record each time. This can result in multiple user records for the same person. The merge feature lets an administrator consolidate these duplicate users into a single account.\n\n## How It Works\n\nMerging transfers all owned records (orders, invoices, etc.) from one or more users into a single **leading user**. The leading user keeps all of its own attributes (name, email, groups, etc.) and absorbs the relational records from the merged users. The merged users are then permanently deleted.\n\n### What Gets Merged\n\n- **Relational records** with a `user_id` foreign key are reassigned to the leading user (e.g. orders, invoices, credit notes)\n- **Activity log** entries are reassigned so the leading user retains a complete history\n\n### What Does Not Get Merged\n\n- **User attributes** like name, email, and password are kept from the leading user only\n- **Groups** (primary and secondary) are not combined \u2014 the leading user keeps its own group memberships\n\n## Backend Usage\n\nTo merge users from the admin panel:\n\n1. Navigate to the **Users** list\n2. Select two or more users using the checkboxes\n3. Click **More Actions** \u2192 **Merge Users**\n4. A popup will display the selected users \u2014 choose which one should be the **leading user**\n5. Click **Merge & Delete Users** to confirm\n\nThe non-leading users will be permanently deleted and all of their records will be transferred to the leading user. An activity log entry is recorded for each merge.\n\n## Programmatic Usage\n\nYou can merge users in code using the `mergeUser` method on the User model.\n\n```php\nuse RainLab\\User\\Models\\User;\n\n$leadingUser = User::find(1);\n$duplicateUser = User::find(2);\n\n$leadingUser->mergeUser($duplicateUser);\n```\n\nThis will fire the `rainlab.user.mergeUser` event, reassign core relations, and permanently delete the duplicate user.\n\n## Extending with Events\n\nWhen users are merged, the `rainlab.user.mergeUser` event is fired. Plugins that store records with a `user_id` foreign key should listen for this event and reassign their records.\n\n```php\nEvent::listen('rainlab.user.mergeUser', function($leadingUser, $mergedUser) {\n \\Acme\\Blog\\Models\\Post::where('user_id', $mergedUser->id)\n ->update(['user_id' => $leadingUser->id]);\n\n \\Acme\\Blog\\Models\\Comment::where('user_id', $mergedUser->id)\n ->update(['user_id' => $leadingUser->id]);\n});\n```\n\nThe event receives two arguments:\n\nArgument | Description\n-------- | -----------\n**$leadingUser** | The user that will retain all records (the surviving account).\n**$mergedUser** | The user being absorbed. This user will be permanently deleted after the event fires.", "tailor": "# Tailor Integration\n\nThis plugin includes integration with Tailor by providing content fields.\n\n## Users Field\n\nThe `users` field type allows association to one or more users via a Tailor Blueprint. The functionality is introduced by the `RainLab\\User\\ContentFields\\UsersField` PHP class.\n\nThe simplest example is to associate to a single user (belongs to relationship).\n\n```yaml\nusers:\n label: Users\n type: users\n maxItems: 1\n```\n\nSet the `maxItems` to **0** to associate to an unlimited number of users (belongs to many relationship).\n\n```yaml\nusers:\n label: Users\n type: users\n maxItems: 0\n```\n\nSet the `displayMode` to **controller** to show a more advanced user interface for user management.\n\n```yaml\nusers:\n label: Users\n type: users\n maxItems: 0\n displayMode: controller\n```\n\nYou may also set the `displayMode` to **taglist** to select users by their email address.\n\n```yaml\nusers:\n label: Users\n type: users\n maxItems: 0\n displayMode: taglist\n```\n", - "component-session": "# Session Component\n\nThe session component should be added to a layout that has registered users. It has no default markup.\n\n## User Variable\n\nYou can check the logged in user by accessing the **{{ user }}** Twig variable:\n\n```twig\n{% if user %}\n

Hello {{ user.first_name }}<\/p>\n{% else %}\n

Nobody is logged in<\/p>\n{% endif %}\n```\n\n## Signing Out\n\nThe Session component allows a user to sign out of their session.\n\n```html\nSign out<\/a>\n```\n\n## Page Restriction\n\nThe Session component allows the restriction of a page or layout by allowing only signed in users, only guests or no restriction. This example shows how to restrict a page to users only:\n\n```ini\ntitle = \"Restricted page\"\nurl = \"\/users-only\"\n\n[session]\nsecurity = \"user\"\nredirect = \"home\"\n```\n\nThe `security` property can be user, guest or all. The `redirect` property refers to a page name to redirect to when access is restricted.\n\n## Route Restriction\n\nAccess to routes can be restricted by applying the `AuthMiddleware`.\n\n```php\nRoute::group(['middleware' => \\RainLab\\User\\Classes\\AuthMiddleware::class], function () {\n \/\/ All routes here will require authentication\n});\n```\n\n## Token Variable\n\nThe `token` Twig variable can be used for generating a new bearer token for the signed in user.\n\n```twig\n{% do response(\n ajaxHandler('onLogin').withVars({\n token: session.token\n })\n) %}\n```\n\nThe `checkToken` property of the component is used to verify a supplied token in the request headers `(Authorization: Bearer TOKEN)`.\n\n```ini\n[session]\ncheckToken = 1\n```\n", + "component-session": "# Session Component\n\nThe session component should be added to a layout that has registered users. It has no default markup.\n\n## User Variable\n\nYou can check the logged in user by accessing the **{{ user }}** Twig variable:\n\n```twig\n{% if user %}\n

Hello {{ user.first_name }}<\/p>\n{% else %}\n

Nobody is logged in<\/p>\n{% endif %}\n```\n\n## Signing Out\n\nThe Session component allows a user to sign out of their session.\n\n```html\nSign out<\/a>\n```\n\n## Page Restriction\n\nThe Session component allows the restriction of a page or layout by allowing only signed in users, only guests or no restriction. This example shows how to restrict a page to users only:\n\n```ini\ntitle = \"Restricted page\"\nurl = \"\/users-only\"\n\n[session]\nsecurity = \"user\"\nredirect = \"home\"\n```\n\nThe `security` property can be user, guest or all. The `redirect` property refers to a page name to redirect to when access is restricted.\n\nActivation and admin approval are enforced at sign in, based on the User Settings, so no page-level property is needed for them. See the [Registration component](.\/component-registration.md) article.\n\n## Group Restriction\n\nThe `allowUserGroups` property restricts access to users belonging to the specified group codes, including their primary group. Leave it empty to allow all groups. The optional `redirectGroup` property is used when a signed in user is not in an allowed group, falling back to the `redirect` property when unset.\n\n```ini\ntitle = \"Premium page\"\nurl = \"\/premium-only\"\n\n[session]\nsecurity = \"user\"\nredirect = \"home\"\nallowUserGroups[] = \"premium\"\nredirectGroup = \"upgrade\"\n```\n\nGuests are not checked by this property, use the `security` property to restrict guests.\n\n## Route Restriction\n\nAccess to routes can be restricted by applying the `AuthMiddleware`.\n\n```php\nRoute::group(['middleware' => \\RainLab\\User\\Classes\\AuthMiddleware::class], function () {\n \/\/ All routes here will require authentication\n});\n```\n\n## Token Variable\n\nThe `token` Twig variable can be used for generating a new bearer token for the signed in user.\n\n```twig\n{% do response(\n ajaxHandler('onLogin').withVars({\n token: session.token\n })\n) %}\n```\n\nThe `checkToken` property of the component is used to verify a supplied token in the request headers `(Authorization: Bearer TOKEN)`.\n\n```ini\n[session]\ncheckToken = 1\n```\n", "component-account": "# Account Component\n\nThe account component provides a method to update the logged in user profile, verify email address, enable two-factor authentication, clear browser sessions and delete their account.\n\n```ini\ntitle = \"Account\"\nurl = \"\/account\/:code?\"\n\n[account]\nisDefault = 1\n==\n...\n```\n\nFor displaying and clearing other browser sessions for the user, the session driver must be set to `database`. Open the **config\/session.php** file and change the driver, this can also be set in the **.env** file with the `SESSION_DRIVER` variable.\n\n```php\n'driver' => env('SESSION_DRIVER', 'database'),\n```\n\n## API\n\nThese AJAX handlers are available.\n\nHandler | Description\n------- | -------------\n**onUpdateProfile** | Updates the user profile\n**onVerifyEmail** | Verifies the user email address\n**onEnableTwoFactor** | Enables two-factor authentication\n**onConfirmTwoFactor** | Confirms two-factor authentication using a valid code\n**onShowTwoFactorRecoveryCodes** | Displays the two-factor recovery codes\n**onRegenerateTwoFactorRecoveryCodes** | Deletes and recreates the recovery codes\n**onDisableTwoFactor** | Disables two-factor authentication\n**onDeleteOtherSessions** | Logs out other user sessions\n**onDeleteUser** | Deletes the user account\n\nThese variables are available on the component object.\n\nVariable | Description\n-------- | -------------\n`user` | returns the logged in user\n`sessions` | returns browser sessions for the user\n`twoFactorEnabled` | returns true if the user has two factor enabled\n`twoFactorRecoveryCodes` | returns an array of recovery codes, if available\n\n## Examples\n\nThe following example shows how to update the user profile using the `onUpdateProfile` handler.\n\n```html\n\n\n \n\n \n\n \n Save\n <\/button>\n<\/form>\n```\n", - "component-authentication": "# Authentication Component\n\n## Overriding Functionality\n\nHere is how you would override the `onLogin()` handler to log any error messages. Inside the page code, define this method:\n\n```php\nfunction onLogin()\n{\n try {\n return $this->account->onLogin();\n }\n catch (Exception $ex) {\n Log::error($ex);\n }\n}\n```\n\nHere the local handler method will take priority over the **account** component's event handler. Then we simply inherit the logic by calling the parent handler manually, via the component object (`$this->account`).\n", - "component-registration": "# Registration Component\n\n## Using a Login Name\n\nBy default the User plugin will use the email address as the login name. To switch to using a user defined login name, navigate to the backend under System > Users > User Settings and change the Login attribute under the Sign in tab to be **Username**. Then simply ask for a username upon registration by adding the username field:\n\n```twig\n

\n