diff --git a/app/Containers/AppSection/Authentication/UI/API/Requests/RegisterUserRequest.php b/app/Containers/AppSection/Authentication/UI/API/Requests/RegisterUserRequest.php index 96da64e2b..3e396610d 100644 --- a/app/Containers/AppSection/Authentication/UI/API/Requests/RegisterUserRequest.php +++ b/app/Containers/AppSection/Authentication/UI/API/Requests/RegisterUserRequest.php @@ -2,9 +2,9 @@ namespace App\Containers\AppSection\Authentication\UI\API\Requests; +use App\Containers\AppSection\User\Models\User; use App\Ship\Parents\Requests\Request; use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\Password; class RegisterUserRequest extends Request { @@ -37,11 +37,7 @@ public function rules(): array 'email' => 'required|email|unique:users,email', 'password' => [ 'required', - Password::min(8) - ->letters() - ->mixedCase() - ->numbers() - ->symbols(), + User::getPasswordValidationRules(), ], 'name' => 'min:2|max:50', 'gender' => 'in:male,female,unspecified', diff --git a/app/Containers/AppSection/User/Actions/UpdateUserPasswordAction.php b/app/Containers/AppSection/User/Actions/UpdateUserPasswordAction.php new file mode 100644 index 000000000..e90e46358 --- /dev/null +++ b/app/Containers/AppSection/User/Actions/UpdateUserPasswordAction.php @@ -0,0 +1,31 @@ +sanitizeInput([ + 'current_password', + 'new_password', + ]); + + return app(UpdateUserTask::class)->run(['password' => $sanitizedData['new_password']], $request->id); + } +} diff --git a/app/Containers/AppSection/User/Models/User.php b/app/Containers/AppSection/User/Models/User.php index 8c8fc1af9..ee8620627 100644 --- a/app/Containers/AppSection/User/Models/User.php +++ b/app/Containers/AppSection/User/Models/User.php @@ -9,6 +9,7 @@ use App\Ship\Parents\Models\UserModel; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Notifications\Notifiable; +use Illuminate\Validation\Rules\Password; class User extends UserModel implements MustVerifyEmail { @@ -34,6 +35,15 @@ class User extends UserModel implements MustVerifyEmail 'birth' => 'date', ]; + public static function getPasswordValidationRules(): Password + { + return Password::min(8) + ->letters() + ->mixedCase() + ->numbers() + ->symbols(); + } + public function sendEmailVerificationNotificationWithVerificationUrl(string $verificationUrl) { $this->notify(new VerifyEmail($verificationUrl)); diff --git a/app/Containers/AppSection/User/UI/API/Controllers/UpdateUserPasswordController.php b/app/Containers/AppSection/User/UI/API/Controllers/UpdateUserPasswordController.php new file mode 100644 index 000000000..a62dd37f5 --- /dev/null +++ b/app/Containers/AppSection/User/UI/API/Controllers/UpdateUserPasswordController.php @@ -0,0 +1,30 @@ +run($request); + + return $this->transform($user, UserTransformer::class); + } +} diff --git a/app/Containers/AppSection/User/UI/API/Requests/UpdateUserPasswordRequest.php b/app/Containers/AppSection/User/UI/API/Requests/UpdateUserPasswordRequest.php new file mode 100644 index 000000000..3d4e9e1ee --- /dev/null +++ b/app/Containers/AppSection/User/UI/API/Requests/UpdateUserPasswordRequest.php @@ -0,0 +1,56 @@ + '', + 'roles' => '', + ]; + + /** + * Id's that needs decoding before applying the validation rules. + */ + protected array $decode = [ + 'id', + ]; + + /** + * Defining the URL parameters (`/stores/999/items`) allows applying + * validation rules on them and allows accessing them like request data. + */ + protected array $urlParameters = [ + 'id', + ]; + + public function rules(): array + { + return [ + 'current_password' => [Rule::requiredIf( + fn (): bool => !is_null($this->user()->password) + ), 'current_password:api'], + 'new_password' => [ + 'required', + User::getPasswordValidationRules(), + ], + ]; + } + + public function authorize(): bool + { + return $this->check([ + 'hasAccess|isResourceOwner', + ]); + } +} diff --git a/app/Containers/AppSection/User/UI/API/Requests/UpdateUserRequest.php b/app/Containers/AppSection/User/UI/API/Requests/UpdateUserRequest.php index 1f1d42ea3..8913a1088 100644 --- a/app/Containers/AppSection/User/UI/API/Requests/UpdateUserRequest.php +++ b/app/Containers/AppSection/User/UI/API/Requests/UpdateUserRequest.php @@ -2,8 +2,8 @@ namespace App\Containers\AppSection\User\UI\API\Requests; -use App\Ship\Parents\Requests\Request; use App\Containers\AppSection\Authorization\Traits\IsResourceOwnerTrait; +use App\Ship\Parents\Requests\Request; class UpdateUserRequest extends Request { @@ -35,7 +35,6 @@ class UpdateUserRequest extends Request public function rules(): array { return [ - 'password' => 'min:6|max:40', 'name' => 'min:2|max:50', 'gender' => 'in:male,female,unspecified', 'birth' => 'date', diff --git a/app/Containers/AppSection/User/UI/API/Routes/UpdateUser.v1.private.php b/app/Containers/AppSection/User/UI/API/Routes/UpdateUser.v1.private.php index daf219c24..ee2269855 100644 --- a/app/Containers/AppSection/User/UI/API/Routes/UpdateUser.v1.private.php +++ b/app/Containers/AppSection/User/UI/API/Routes/UpdateUser.v1.private.php @@ -8,7 +8,6 @@ * @apiVersion 1.0.0 * @apiPermission Authenticated ['permissions' => 'update-users', 'roles' => ''] | Resource Owner * - * @apiParam {String} [password] min:6|max:40 * @apiParam {String} [name] min:2|max:50 * @apiParam {String="male","female","unspecified"} [gender] * @apiParam {Date} [birth] format: Y-m-d / e.g. 2015-10-15 diff --git a/app/Containers/AppSection/User/UI/API/Routes/UpdateUserPassword.v1.private.php b/app/Containers/AppSection/User/UI/API/Routes/UpdateUserPassword.v1.private.php new file mode 100644 index 000000000..a67e0ebac --- /dev/null +++ b/app/Containers/AppSection/User/UI/API/Routes/UpdateUserPassword.v1.private.php @@ -0,0 +1,31 @@ + 'update-users', 'roles' => ''] | Resource Owner + * + * @apiParam {String} current_password + * @apiParam {String} new_password min: 8 + * + * at least one character of the following: + * + * upper case letter + * + * lower case letter + * + * number + * + * special character + * + * @apiUse UserSuccessSingleResponse + */ + +use App\Containers\AppSection\User\UI\API\Controllers\UpdateUserPasswordController; +use Illuminate\Support\Facades\Route; + +Route::patch('users/{id}/password', [UpdateUserPasswordController::class, 'updateUserPassword']) + ->middleware(['auth:api']); diff --git a/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserPasswordTest.php b/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserPasswordTest.php new file mode 100644 index 000000000..a0eaf6cf6 --- /dev/null +++ b/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserPasswordTest.php @@ -0,0 +1,120 @@ + '', + 'roles' => '', + ]; + + public function testGivenUserAlreadyHaveAPassword_UpdateUserPassword(): void + { + $user = $this->getTestingUser([ + 'password' => 'Av@dakedavra!', + ]); + $data = [ + 'current_password' => 'Av@dakedavra!', + 'new_password' => 'updated#Password111', + ]; + + $response = $this->injectId($user->id)->makeCall($data); + + $response->assertStatus(200); + $response->assertJson( + fn (AssertableJson $json) => $json->has('data') + ->where('data.object', 'User') + ->where('data.email', $user->email) + ->missing('data.password') + ->etc() + ); + } + + public function testNewPasswordFieldShouldBeRequired(): void + { + $user = $this->getTestingUser(); + $data = [ + 'new_password' => '', + ]; + + $response = $this->injectId($user->id)->makeCall($data); + + $response->assertStatus(422); + $response->assertJson( + fn (AssertableJson $json) => $json->has('errors') + ->where('errors.new_password.0', 'The new password field is required.') + ->etc() + ); + } + + public function testGivenUserAlreadyHaveAPassword_CurrentPasswordFieldShouldBeRequired(): void + { + $user = $this->getTestingUser([ + 'password' => 'Av@dakedavra!', + ]); + $data = [ + 'new_password' => 'updated#Password111', + ]; + + $response = $this->injectId($user->id)->makeCall($data); + + $response->assertStatus(422); + $response->assertJson( + fn (AssertableJson $json) => $json->has('errors') + ->where('errors.current_password.0', 'The current password field is required.') + ->etc() + ); + } + + public function testGivenUserAlreadyHaveAPassword_CurrentPasswordFieldMustMatchUserCurrentPassword(): void + { + $user = $this->getTestingUser([ + 'password' => 'Av@dakedavra!', + ]); + $data = [ + 'current_password' => 'notMatchingP@ssw0rd', + 'new_password' => 'updated#Password111', + ]; + + $response = $this->injectId($user->id)->makeCall($data); + + $response->assertStatus(422); + $response->assertJson( + fn (AssertableJson $json) => $json->has('errors') + ->where('errors.current_password.0', 'The password is incorrect.') + ->etc() + ); + } + + public function testGivenUserDoesntHaveAPassword_CurrentPasswordFieldShouldBeProhibited(): void + { + $user = $this->getTestingUser([ + 'password' => null, + ]); + $data = [ + 'current_password' => 'sh0uldBeProhibited!!11', + 'new_password' => 'updated#Password111', + ]; + + $response = $this->injectId($user->id)->makeCall($data); + + $response->assertStatus(422); + $response->assertJson( + fn (AssertableJson $json) => $json->has('errors') + ->where('errors.current_password.0', 'The password is incorrect.') + ->etc() + ); + } +} diff --git a/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserTest.php b/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserTest.php index deea0c228..ae95587a8 100644 --- a/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserTest.php +++ b/app/Containers/AppSection/User/UI/API/Tests/Functional/UpdateUserTest.php @@ -25,12 +25,10 @@ public function testUpdateExistingUser(): void { $user = $this->getTestingUser([ 'name' => 'He who should not be named', - 'password' => 'Av@dakedavra!', 'gender' => 'female', ]); $data = [ 'name' => 'Updated Name', - 'password' => 'updated#Password', 'gender' => 'male', 'birth' => '2015-10-15', ]; @@ -39,15 +37,14 @@ public function testUpdateExistingUser(): void $response->assertStatus(200); $response->assertJson( - fn (AssertableJson $json) => - $json->has('data') - ->where('data.object', 'User') - ->where('data.email', $user->email) - ->where('data.name', $data['name']) - ->where('data.gender', $data['gender']) - ->where('data.birth', Carbon::parse($data['birth'])->toISOString()) - ->missing('data.password') - ->etc() + fn (AssertableJson $json) => $json->has('data') + ->where('data.object', 'User') + ->where('data.email', $user->email) + ->where('data.name', $data['name']) + ->where('data.gender', $data['gender']) + ->where('data.birth', Carbon::parse($data['birth'])->toISOString()) + ->missing('data.password') + ->etc() ); } @@ -65,7 +62,6 @@ public function testUpdateExistingUserWithEmptyValues(): void $user = $this->getTestingUser(); $data = [ 'name' => '', - 'password' => '', 'gender' => '', 'birth' => '', ]; @@ -74,9 +70,7 @@ public function testUpdateExistingUserWithEmptyValues(): void $response->assertStatus(422); $response->assertJson( - fn (AssertableJson $json) => - $json->has('errors') - ->where('errors.password.0', 'The password must be at least 6 characters.') + fn (AssertableJson $json) => $json->has('errors') ->where('errors.name.0', 'The name must be at least 2 characters.') ->where('errors.gender.0', 'The selected gender is invalid.') ->where('errors.birth.0', 'The birth is not a valid date.') diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 6107c0f2c..397f78e2c 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -14,6 +14,7 @@ */ 'accepted' => 'The :attribute must be accepted.', + 'accepted_if' => 'The :attribute must be accepted when :other is :value.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', @@ -24,37 +25,41 @@ 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ - 'numeric' => 'The :attribute must be between :min and :max.', + 'array' => 'The :attribute must have between :min and :max items.', 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute must be between :min and :max.', 'string' => 'The :attribute must be between :min and :max characters.', - 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', + 'current_password' => 'The password is incorrect.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', + 'declined' => 'The :attribute must be declined.', + 'declined_if' => 'The :attribute must be declined when :other is :value.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', - 'ends_with' => 'The :attribute must end with one of the following: :values', + 'ends_with' => 'The :attribute must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ - 'numeric' => 'The :attribute must be greater than :value.', + 'array' => 'The :attribute must have more than :value items.', 'file' => 'The :attribute must be greater than :value kilobytes.', + 'numeric' => 'The :attribute must be greater than :value.', 'string' => 'The :attribute must be greater than :value characters.', - 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', + 'file' => 'The :attribute must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute must be greater than or equal to :value.', + 'string' => 'The :attribute must be greater than or equal to :value characters.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', @@ -65,59 +70,63 @@ 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', + 'array' => 'The :attribute must have less than :value items.', 'file' => 'The :attribute must be less than :value kilobytes.', + 'numeric' => 'The :attribute must be less than :value.', 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', + 'file' => 'The :attribute must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute must be less than or equal to :value.', + 'string' => 'The :attribute must be less than or equal to :value characters.', ], + 'mac_address' => 'The :attribute must be a valid MAC address.', 'max' => [ - 'numeric' => 'The :attribute must not be greater than :max.', + 'array' => 'The :attribute must not have more than :max items.', 'file' => 'The :attribute must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute must not be greater than :max.', 'string' => 'The :attribute must not be greater than :max characters.', - 'array' => 'The :attribute must not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', + 'array' => 'The :attribute must have at least :min items.', 'file' => 'The :attribute must be at least :min kilobytes.', + 'numeric' => 'The :attribute must be at least :min.', 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', ], + 'multiple_of' => 'The :attribute must be a multiple of :value.', 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', - 'password' => 'The password is incorrect.', 'present' => 'The :attribute field must be present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', - 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', 'same' => 'The :attribute and :other must match.', 'size' => [ - 'numeric' => 'The :attribute must be :size.', + 'array' => 'The :attribute must contain :size items.', 'file' => 'The :attribute must be :size kilobytes.', + 'numeric' => 'The :attribute must be :size.', 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', ], - 'starts_with' => 'The :attribute must start with one of the following: :values', + 'starts_with' => 'The :attribute must start with one of the following: :values.', 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid zone.', + 'timezone' => 'The :attribute must be a valid timezone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', - 'url' => 'The :attribute format is invalid.', + 'url' => 'The :attribute must be a valid URL.', 'uuid' => 'The :attribute must be a valid UUID.', /*