diff --git a/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php new file mode 100644 index 000000000..d0b6299c6 --- /dev/null +++ b/demo/app/Sharp/Profile/Commands/ChangePasswordCommand.php @@ -0,0 +1,35 @@ +configureConfirmPassword() + ->configurePasswordRule( + Password::min(8) + ->numbers() + ->symbols() + ->uncompromised() + ); + } + + protected function executeSingle(array $data): array + { + // We do not really update the password in the context of the demo + // auth()->user()->update([ + // 'password' => $data['new_password'], + // ]); + + $this->notify('Password updated!'); + + return $this->reload(); + } +} diff --git a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php b/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php deleted file mode 100644 index 8600280ad..000000000 --- a/demo/app/Sharp/Profile/Commands/UpdateProfilePasswordCommand.php +++ /dev/null @@ -1,57 +0,0 @@ -addField( - SharpFormTextField::make('password') - ->setLabel('Current password') - ->setInputTypePassword() - ) - ->addField( - SharpFormTextField::make('new_password') - ->setLabel('New password') - ->setInputTypePassword() - ) - ->addField( - SharpFormTextField::make('new_password_confirmation') - ->setLabel('Confirm new password') - ->setInputTypePassword() - ); - } - - protected function executeSingle(array $data): array - { - $this->validate($data, [ - 'password' => 'required', - 'new_password' => ['required', 'confirmed', 'string', 'min:8'], - ]); - - $granted = auth()->validate([ - 'email' => auth()->user()->email, - 'password' => $data['password'], - ]); - - if (! $granted) { - throw new SharpApplicativeException('Your current password is invalid.'); - } - - $this->notify('Password updated!'); - - return $this->reload(); - } -} diff --git a/demo/app/Sharp/Profile/ProfileSingleShow.php b/demo/app/Sharp/Profile/ProfileSingleShow.php index 0471a5810..07f51acd6 100644 --- a/demo/app/Sharp/Profile/ProfileSingleShow.php +++ b/demo/app/Sharp/Profile/ProfileSingleShow.php @@ -3,8 +3,8 @@ namespace App\Sharp\Profile; use App\Sharp\Profile\Commands\Activate2faCommand; +use App\Sharp\Profile\Commands\ChangePasswordCommand; use App\Sharp\Profile\Commands\Deactivate2faCommand; -use App\Sharp\Profile\Commands\UpdateProfilePasswordCommand; use Code16\Sharp\Show\Fields\SharpShowPictureField; use Code16\Sharp\Show\Fields\SharpShowTextField; use Code16\Sharp\Show\Layout\ShowLayout; @@ -46,12 +46,12 @@ public function buildShowConfig(): void public function getInstanceCommands(): ?array { - return array_merge( - [UpdateProfilePasswordCommand::class], - config('sharp.auth.2fa.handler') === 'totp' + return [ + ChangePasswordCommand::class, + ...sharp()->config()->get('auth.2fa.handler') === 'totp' ? [Activate2faCommand::class, Deactivate2faCommand::class] - : [] - ); + : [], + ]; } public function findSingle(): array diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index f152aaa84..4b253298a 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -243,7 +243,7 @@ class My2faNotificationHandler extends Sharp2faNotificationHandler // or Sharp2f ## Forgotten password -You can activate the classic Laravel Breeze workflow of forgotten password with a simple config: +You can activate the classic Laravel workflow of forgotten password with a simple config: ```php class SharpServiceProvider extends SharpAppServiceProvider @@ -306,9 +306,66 @@ class SharpServiceProvider extends SharpAppServiceProvider These customizations will not interfere with any default behavior that you may have implemented for your app, outside Sharp. +## Allow the current user to change his password + +Sharp provides a helper trait to quickly build a command that lets the currently authenticated user change his password: `Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait`. Using this trait, you can quickly build a Sharp command, with a few configuration options. + +The trait will take care of the form, validation and rate-limiting. Note that: + +- This helper is designed for the “current user changes his own password” scenario. If you need admin-managed password resets for other users, implement a different command with the proper authorization checks. +- Persisting the new password is up to you (see example below). + +### Configuration and behavior + +You can configure the behavior of the command with the following methods (should be called in your `buildCommandConfig()` method): + +- `configureConfirmPassword(?bool $confirm = true)`: (false by default) enable password confirmation. +- `configurePasswordRule(Password $rule)`: (default: `Password::min(8)`) change the default password validation rule. +- `configureValidateCurrentPassword(?bool $validate = true)`: (true by default) if true, a `password` field that uses Laravel’s `current_password` rule (which compares against the currently authenticated user’s stored password) is added. Make sure you use Eloquent, and that your `User` model stores a hashed password as usual. + +### Full example + +```php +use Code16\Sharp\Auth\Password\Command\IsChangePasswordCommandTrait; +// ... + +class ChangePasswordCommand extends SingleInstanceCommand +{ + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureConfirmPassword() + ->configurePasswordRule( + Password::min(8) + ->numbers() + ->symbols() + ->uncompromised() + ); + } + + protected function executeSingle(array $data): array + { + // The trait handles validation and rate limiting. + + auth()->user()->update([ + 'password' => $data['new_password'], // Considering hashing is done by the model (cast) + ]); + + $this->notify('Password updated!'); + + return $this->reload(); + } +} +``` + +::: info +In this example we chose to create a `SingleInstanceCommand`, since it’s a common use-case to attach such a command to a "Profile" single Show Page that could be [placed in the user menu](building-menu.md#add-links-in-the-user-profile-menu), but you can decide to create an `EntityCommand` or even an `InstanceCommand` instead. +::: + ## User impersonation (dev only) -At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows to do that out of the box: +At the development stage, it can be useful to replace the login form by a user impersonation. Sharp allows you to do that out of the box: ```php class SharpServiceProvider extends SharpAppServiceProvider @@ -392,3 +449,4 @@ class SharpServiceProvider extends SharpAppServiceProvider } } ``` + diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 2163822b9..aff0899c3 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -28,4 +28,15 @@ ], ], ], + 'password_change' => [ + 'command' => [ + 'label' => 'Change password...', + 'fields' => [ + 'current_password' => 'Current password', + 'new_password' => 'New password', + 'new_password_confirm' => 'Confirm new password', + ], + 'rate_limit_exceeded' => 'You have made too many attempts. Please try again in :seconds seconds.', + ], + ], ]; diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php index c6ad424ed..7c1e7b02e 100644 --- a/resources/lang/fr/auth.php +++ b/resources/lang/fr/auth.php @@ -28,4 +28,15 @@ ], ], ], + 'password_change' => [ + 'command' => [ + 'label' => 'Modifier le mot de passe...', + 'fields' => [ + 'current_password' => 'Mot de passe actuel', + 'new_password' => 'Nouveau mot de passe', + 'new_password_confirm' => 'Confirmer le nouveau mot de passe', + ], + 'rate_limit_exceeded' => 'Vous avez effectué trop de tentatives. Veuillez réessayer dans :seconds secondes.', + ], + ], ]; diff --git a/src/Auth/Password/Command/IsChangePasswordCommandTrait.php b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php new file mode 100644 index 000000000..460e716f2 --- /dev/null +++ b/src/Auth/Password/Command/IsChangePasswordCommandTrait.php @@ -0,0 +1,99 @@ +when( + $this->validateCurrentPassword, + fn (FieldsContainer $formFields) => $formFields->addField( + SharpFormTextField::make('password') + ->setLabel(trans('sharp::auth.password_change.command.fields.current_password')) + ->setInputTypePassword() + ) + ) + ->addField( + SharpFormTextField::make('new_password') + ->setLabel(trans('sharp::auth.password_change.command.fields.new_password')) + ->setInputTypePassword() + ) + ->when( + $this->confirmPassword, + fn (FieldsContainer $formFields) => $formFields->addField( + SharpFormTextField::make('new_password_confirmation') + ->setLabel(trans('sharp::auth.password_change.command.fields.new_password_confirm')) + ->setInputTypePassword() + ) + ); + } + + public function rules(): array + { + $rules = RateLimiter::attempt( + 'sharp-password-change-'.auth()->id(), + 3, + fn () => [ + ...$this->validateCurrentPassword + ? ['password' => ['required', 'current_password']] + : [], + 'new_password' => [ + 'required', + 'string', + $this->passwordRule ?? Password::min(8), + ...$this->confirmPassword ? ['confirmed'] : [], + ], + ], + ); + + if (! $rules) { + throw new SharpApplicativeException( + trans( + 'sharp::auth.password_change.command.rate_limit_exceeded', [ + 'seconds' => RateLimiter::availableIn('sharp-password-change-'.auth()->id()), + ] + ) + ); + } + + return $rules; + } + + protected function configureConfirmPassword(?bool $confirmPassword = true): self + { + $this->confirmPassword = $confirmPassword; + + return $this; + } + + protected function configureValidateCurrentPassword(?bool $validateCurrentPassword = true): self + { + $this->validateCurrentPassword = $validateCurrentPassword; + + return $this; + } + + protected function configurePasswordRule(Password $passwordRule): self + { + $this->passwordRule = $passwordRule; + + return $this; + } +} diff --git a/tests/Http/Auth/ChangePasswordCommandTraitTest.php b/tests/Http/Auth/ChangePasswordCommandTraitTest.php new file mode 100644 index 000000000..1653bc36b --- /dev/null +++ b/tests/Http/Auth/ChangePasswordCommandTraitTest.php @@ -0,0 +1,225 @@ +config()->declareEntity(SinglePersonEntity::class); + + login(new User([ + 'id' => 123, // ensure RateLimiter key is unique in tests + 'password' => Hash::make('secret'), + ])); +}); + +it('exposes proper form fields and label (without confirmation) for change password command', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + protected function executeSingle(array $data): array + { + // no-op in tests + return $this->reload(); + } + }, + ]; + } + }); + + // Fetch the command form (single show variant) + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->where('config.title', trans('sharp::auth.password_change.command.label')) + ->where('fields.password.key', 'password') + ->where('fields.new_password.key', 'new_password') + ->missing('fields.new_password_confirmation') + ->etc(); + }); +}); + +it('shows confirmation field when enabled and enforces custom password rule and confirmation', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + // enable confirmation + a stronger rule + 'change_password_confirm' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureConfirmPassword() + ->configurePasswordRule(Password::min(8)->numbers()); + } + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Form contains the confirmation field + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password_confirm', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->where('fields.password.key', 'password') + ->where('fields.new_password.key', 'new_password') + ->where('fields.new_password_confirmation.key', 'new_password_confirmation') + ->etc(); + }); + + // Fails when confirmation is missing/mismatch + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password1', // missing confirmation + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['new_password']); + + // Fails when password rule is not satisfied (requires number) + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password!', + 'new_password_confirmation' => 'Password!', + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['new_password']); + + // Succeeds with valid data + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'password' => 'secret', + 'new_password' => 'Password1!', + 'new_password_confirmation' => 'Password1!', + ], + ]) + ->assertOk(); +}); + +it('allows to hide the current password field', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password_confirm' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + public function buildCommandConfig(): void + { + $this->configureValidateCurrentPassword(false); + } + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Form does not contain the current password field + $this + ->getJson(route('code16.sharp.api.show.command.singleInstance.form', [ + 'entityKey' => 'single-person', + 'commandKey' => 'change_password_confirm', + ])) + ->assertOk() + ->assertJson(function (Assert $json) { + $json + ->missing('fields.password') + ->where('fields.new_password.key', 'new_password') + ->etc(); + }); + + // Succeeds with valid data + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_confirm']), [ + 'data' => [ + 'new_password' => 'Password1!', + ], + ]) + ->assertOk(); +}); + +it('rate limits after too many attempts and returns a helpful message', function () { + fakeShowFor(SinglePersonEntity::class, new class() extends SinglePersonShow + { + public function getInstanceCommands(): ?array + { + return [ + 'change_password_rl' => new class() extends SingleInstanceCommand + { + use IsChangePasswordCommandTrait; + + protected function executeSingle(array $data): array + { + return $this->reload(); + } + }, + ]; + } + }); + + // Trigger 3 attempts (invalid to keep trying) + for ($i = 0; $i < 3; $i++) { + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_rl']), [ + 'data' => [ + // missing fields triggers validation and consumes an attempt + ], + ]) + ->assertUnprocessable(); + } + + // 4th attempt should be blocked by rate limiter with SharpApplicativeException (417) + $this + ->postJson(route('code16.sharp.api.show.command.instance', ['single-person', 'change_password_rl']), [ + 'data' => [ + // still invalid + ], + ]) + ->assertStatus(417) + ->assertJson(function (Assert $json) { + $json->where('message', function ($message) { + return is_string($message) && str_starts_with($message, 'You have made too many attempts.'); + }); + }); +});