diff --git a/routes/web.php b/routes/web.php index 8b78959c82..5e2f8f0f66 100755 --- a/routes/web.php +++ b/routes/web.php @@ -27,11 +27,14 @@ Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store'); Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () { - Route::post('login', [UserController::class, 'login'])->name('login'); Route::get('logout', [UserController::class, 'logout'])->name('logout'); - Route::post('register', [UserController::class, 'register'])->name('register'); - Route::post('profile', [UserController::class, 'profile'])->name('profile'); - Route::post('password', [UserController::class, 'password'])->name('password'); + + Route::group(['middleware' => [HandlePrecognitiveRequests::class]], function () { + Route::post('login', [UserController::class, 'login'])->name('login'); + Route::post('register', [UserController::class, 'register'])->name('register'); + Route::post('profile', [UserController::class, 'profile'])->name('profile'); + Route::post('password', [UserController::class, 'password'])->name('password'); + }); Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); diff --git a/src/Http/Controllers/UserController.php b/src/Http/Controllers/UserController.php index db58c9f308..45e1a6e465 100644 --- a/src/Http/Controllers/UserController.php +++ b/src/Http/Controllers/UserController.php @@ -2,19 +2,19 @@ namespace Statamic\Http\Controllers; -use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rules\Password; +use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; use Statamic\Auth\ThrottlesLogins; use Statamic\Events\UserRegistered; use Statamic\Events\UserRegistering; use Statamic\Exceptions\SilentFormFailureException; -use Statamic\Exceptions\UnauthorizedHttpException; use Statamic\Facades\User; -use Statamic\Rules\UniqueUserValue; +use Statamic\Http\Requests\UserLoginRequest; +use Statamic\Http\Requests\UserPasswordRequest; +use Statamic\Http\Requests\UserProfileRequest; +use Statamic\Http\Requests\UserRegisterRequest; class UserController extends Controller { @@ -22,27 +22,20 @@ class UserController extends Controller private $request; - public function login(Request $request) + public function login(UserLoginRequest $request) { - $validator = Validator::make($request->all(), [ - 'email' => 'required', - 'password' => 'required', - ]); + if ($this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); - if ($validator->passes()) { - if ($this->hasTooManyLoginAttempts($request)) { - $this->fireLockoutEvent($request); - - return $this->sendLockoutResponse($request); - } - - if (Auth::attempt($request->only('email', 'password'), $request->has('remember'))) { - return redirect($request->input('_redirect', '/'))->withSuccess(__('Login successful.')); - } + return $this->sendLockoutResponse($request); + } - $this->incrementLoginAttempts($request); + if (Auth::attempt($request->only('email', 'password'), $request->has('remember'))) { + return redirect($request->input('_redirect', '/'))->withSuccess(__('Login successful.')); } + $this->incrementLoginAttempts($request); + $errorResponse = $request->has('_error_redirect') ? redirect($request->input('_error_redirect')) : back(); return $errorResponse->withInput()->withErrors(__('Invalid credentials.')); @@ -55,33 +48,12 @@ public function logout() return redirect(request()->get('redirect', '/')); } - public function register(Request $request) + public function register(UserRegisterRequest $request) { - $blueprint = User::blueprint(); - - $fields = $blueprint->fields(); - $values = $this->valuesWithoutAssetFields($fields, $request); - $fields = $fields->addValues($values); - - $fieldRules = $fields->validator()->withRules([ - 'email' => ['required', 'email', new UniqueUserValue], - 'password' => ['required', 'confirmed', Password::default()], - ])->rules(); - - $validator = Validator::make($values, $fieldRules); - - if ($validator->fails()) { - return $this->userRegistrationFailure($validator->errors()); - } - - $values = $fields->process()->values() - ->only(array_keys($values)) - ->except(['email', 'groups', 'roles', 'super']); - $user = User::make() ->email($request->email) ->password($request->password) - ->data($values); + ->data($request->processedValues()); if ($roles = config('statamic.users.new_user_roles')) { $user->explicitRoles($roles); @@ -98,7 +70,7 @@ public function register(Request $request) throw_if(UserRegistering::dispatch($user) === false, new SilentFormFailureException); } catch (ValidationException $e) { - return $this->userRegistrationFailure($e->errors()); + return $this->userRegistrationFailure($e); } catch (SilentFormFailureException $e) { return $this->userRegistrationSuccess(true); } @@ -112,63 +84,31 @@ public function register(Request $request) return $this->userRegistrationSuccess(); } - public function profile(Request $request) + public function profile(UserProfileRequest $request) { - throw_unless($user = User::current(), new UnauthorizedHttpException(403)); - - $blueprint = User::blueprint(); - - $fields = $blueprint->fields(); - $values = $this->valuesWithoutAssetFields($fields, $request); - $fields = $fields->addValues($values); - - try { - $fields - ->validator() - ->withRules(['email' => ['required', 'email', new UniqueUserValue(except: $user->id())]]) - ->withReplacements(['id' => $user->id()]) - ->validate(); - } catch (ValidationException $e) { - return $this->userProfileFailure($e->validator->errors()); - } - - $values = $fields->process()->values() - ->only(array_keys($values)) - ->except(['email', 'password', 'groups', 'roles', 'super']); + $user = User::current(); if ($request->email) { $user->email($request->email); } - foreach ($values as $key => $value) { + + foreach ($request->processedValues() as $key => $value) { $user->set($key, $value); } $user->save(); - session()->flash('user.profile.success', __('Update successful.')); - return $this->userProfileSuccess(); } - public function password(Request $request) + public function password(UserPasswordRequest $request) { - throw_unless($user = User::current(), new UnauthorizedHttpException(403)); - - $validator = Validator::make($request->all(), [ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', 'confirmed', Password::default()], - ]); - - if ($validator->fails()) { - return $this->userPasswordFailure($validator->errors()); - } + $user = User::current(); $user->password($request->password); $user->save(); - session()->flash('user.password.success', __('Change successful.')); - return $this->userPasswordSuccess(); } @@ -177,8 +117,23 @@ public function username() return 'email'; } - private function userRegistrationFailure($errors = null) + private function userRegistrationFailure($validator) { + $errors = $validator->errors(); + + if (request()->ajax()) { + return response([ + 'errors' => (new MessageBag($errors))->all(), + 'error' => collect($errors)->map(function ($errors, $field) { + return $errors[0]; + })->all(), + ], 400); + } + + if (request()->wantsJson()) { + return (new ValidationException($validator))->errorBag(new MessageBag($errors)); + } + $errorResponse = request()->has('_error_redirect') ? redirect(request()->input('_error_redirect')) : back(); return $errorResponse->withInput()->withErrors($errors, 'user.register'); @@ -188,50 +143,51 @@ private function userRegistrationSuccess(bool $silentFailure = false) { $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + if (request()->ajax() || request()->wantsJson()) { + return response([ + 'success' => true, + 'user_created' => ! $silentFailure, + 'redirect' => $response->getTargetUrl(), + ]); + } + session()->flash('user.register.success', __('Registration successful.')); session()->flash('user.register.user_created', ! $silentFailure); return $response; } - private function userProfileFailure($errors = null) - { - $errorResponse = request()->has('_error_redirect') ? redirect(request()->input('_error_redirect')) : back(); - - return $errorResponse->withInput()->withErrors($errors, 'user.profile'); - } - private function userProfileSuccess(bool $silentFailure = false) { $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + if (request()->ajax() || request()->wantsJson()) { + return response([ + 'success' => true, + 'user_updated' => ! $silentFailure, + 'redirect' => $response->getTargetUrl(), + ]); + } + session()->flash('user.profile.success', __('Update successful.')); return $response; } - private function userPasswordFailure($errors = null) - { - $errorResponse = request()->has('_error_redirect') ? redirect(request()->input('_error_redirect')) : back(); - - return $errorResponse->withInput()->withErrors($errors, 'user.password'); - } - private function userPasswordSuccess(bool $silentFailure = false) { $response = request()->has('_redirect') ? redirect(request()->get('_redirect')) : back(); + if (request()->ajax() || request()->wantsJson()) { + return response([ + 'success' => true, + 'password_updated' => ! $silentFailure, + 'redirect' => $response->getTargetUrl(), + ]); + } + session()->flash('user.password.success', __('Change successful.')); return $response; } - - private function valuesWithoutAssetFields($fields, $request) - { - $assets = $fields->all() - ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') - ->keys()->all(); - - return $request->except($assets); - } } diff --git a/src/Http/Requests/UserLoginRequest.php b/src/Http/Requests/UserLoginRequest.php new file mode 100644 index 0000000000..298a6dc039 --- /dev/null +++ b/src/Http/Requests/UserLoginRequest.php @@ -0,0 +1,59 @@ + 'required', + 'password' => 'required', + ]; + } + + protected function failedValidation(Validator $validator) + { + if ($this->isPrecognitive() || $this->wantsJson()) { + return parent::failedValidation($validator); + } + + if ($this->ajax()) { + $errors = $validator->errors(); + + $response = response([ + 'errors' => $errors->all(), + 'error' => collect($errors->messages())->map(function ($errors, $field) { + return $errors[0]; + })->all(), + ], 400); + + throw (new ValidationException($validator, $response)); + } + + $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + + throw (new ValidationException($validator, $errorResponse->withInput()->withErrors(__('Invalid credentials.')))); + } + + public function validateResolved() + { + $site = Site::findByUrl(URL::previous()) ?? Site::default(); + + return $this->withLocale($site->lang(), fn () => parent::validateResolved()); + } +} diff --git a/src/Http/Requests/UserPasswordRequest.php b/src/Http/Requests/UserPasswordRequest.php new file mode 100644 index 0000000000..4bb5b770aa --- /dev/null +++ b/src/Http/Requests/UserPasswordRequest.php @@ -0,0 +1,61 @@ + ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::default()], + ]; + } + + protected function failedValidation(Validator $validator) + { + if ($this->isPrecognitive() || $this->wantsJson()) { + return parent::failedValidation($validator); + } + + if ($this->ajax()) { + $errors = $validator->errors(); + + $response = response([ + 'errors' => $errors->all(), + 'error' => collect($errors->messages())->map(function ($errors, $field) { + return $errors[0]; + })->all(), + ], 400); + + throw (new ValidationException($validator, $response)); + } + + $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + + throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.password'))); + } + + public function validateResolved() + { + $site = Site::findByUrl(URL::previous()) ?? Site::default(); + + return $this->withLocale($site->lang(), fn () => parent::validateResolved()); + } +} diff --git a/src/Http/Requests/UserProfileRequest.php b/src/Http/Requests/UserProfileRequest.php new file mode 100644 index 0000000000..555cf1235f --- /dev/null +++ b/src/Http/Requests/UserProfileRequest.php @@ -0,0 +1,89 @@ +isPrecognitive() || $this->wantsJson()) { + return parent::failedValidation($validator); + } + + if ($this->ajax()) { + $errors = $validator->errors(); + + $response = response([ + 'errors' => $errors->all(), + 'error' => collect($errors->messages())->map(function ($errors, $field) { + return $errors[0]; + })->all(), + ], 400); + + throw (new ValidationException($validator, $response)); + } + + $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + + throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.profile'))); + } + + public function processedValues() + { + return $this->blueprintFields->process()->values() + ->only(array_keys($this->submittedValues)) + ->except(['email', 'password', 'groups', 'roles', 'super']); + } + + public function validator() + { + $blueprint = User::blueprint(); + + $fields = $blueprint->fields(); + $this->submittedValues = $this->valuesWithoutAssetFields($fields); + $this->blueprintFields = $fields->addValues($this->submittedValues); + + $userId = User::current()->id(); + + return $this->blueprintFields + ->validator() + ->withRules(['email' => ['required', 'email', new UniqueUserValue(except: $userId)]]) + ->withReplacements(['id' => $userId]) + ->validator(); + } + + public function validateResolved() + { + $site = Site::findByUrl(URL::previous()) ?? Site::default(); + + return $this->withLocale($site->lang(), fn () => parent::validateResolved()); + } + + private function valuesWithoutAssetFields($fields) + { + $assets = $fields->all() + ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') + ->keys()->all(); + + return $this->except($assets); + } +} diff --git a/src/Http/Requests/UserRegisterRequest.php b/src/Http/Requests/UserRegisterRequest.php new file mode 100644 index 0000000000..d7bc535d21 --- /dev/null +++ b/src/Http/Requests/UserRegisterRequest.php @@ -0,0 +1,92 @@ +isPrecognitive() || $this->wantsJson()) { + return parent::failedValidation($validator); + } + + if ($this->ajax()) { + $errors = $validator->errors(); + + $response = response([ + 'errors' => $errors->all(), + 'error' => collect($errors->messages())->map(function ($errors, $field) { + return $errors[0]; + })->all(), + ], 400); + + throw (new ValidationException($validator, $response)); + } + + $errorResponse = $this->has('_error_redirect') ? redirect($this->input('_error_redirect')) : back(); + + throw (new ValidationException($validator, $errorResponse->withInput()->withErrors($validator->errors(), 'user.register'))); + } + + public function processedValues() + { + return $this->blueprintFields->process()->values() + ->only(array_keys($this->submittedValues)) + ->except(['email', 'groups', 'roles', 'super']); + } + + public function validator() + { + $blueprint = User::blueprint(); + $blueprint->ensureField('password', ['display' => __('Password')]); + $blueprint->ensureField('password_confirmation', ['display' => __('Password Confirmation')]); + + $fields = $blueprint->fields(); + $this->submittedValues = $this->valuesWithoutAssetFields($fields); + $this->blueprintFields = $fields->addValues($this->submittedValues); + + return $this->blueprintFields + ->validator() + ->withRules([ + 'email' => ['required', 'email', new UniqueUserValue], + 'password' => ['required', 'confirmed', Password::default()], + ]) + ->validator(); + } + + public function validateResolved() + { + $site = Site::findByUrl(URL::previous()) ?? Site::default(); + + return $this->withLocale($site->lang(), fn () => parent::validateResolved()); + } + + private function valuesWithoutAssetFields($fields) + { + $assets = $fields->all() + ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') + ->keys()->all(); + + return $this->except($assets); + } +} diff --git a/tests/Tags/User/LoginFormTest.php b/tests/Tags/User/LoginFormTest.php index 0f6c069508..3263db1a23 100644 --- a/tests/Tags/User/LoginFormTest.php +++ b/tests/Tags/User/LoginFormTest.php @@ -224,4 +224,22 @@ public function it_fetches_form_data() $this->assertArrayHasKey('_token', $form['params']); } + + /** @test */ + public function it_handles_precognitive_requests() + { + if (! method_exists($this, 'withPrecognition')) { + $this->markTestSkipped(); + } + + $response = $this + ->withPrecognition() + ->postJson('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + '_error_redirect' => '/login-error', + ]); + + $response->assertStatus(422); + } } diff --git a/tests/Tags/User/PasswordFormTest.php b/tests/Tags/User/PasswordFormTest.php index f7383cd71d..856ef5b08b 100644 --- a/tests/Tags/User/PasswordFormTest.php +++ b/tests/Tags/User/PasswordFormTest.php @@ -291,4 +291,24 @@ public function it_will_use_redirect_query_param_off_url() $this->assertStringContainsString($expectedRedirect, $output); $this->assertStringContainsString($expectedErrorRedirect, $output); } + + /** @test */ + public function it_handles_precognitive_requests() + { + if (! method_exists($this, 'withPrecognition')) { + $this->markTestSkipped(); + } + + $this->actingAs(User::make()->password('mypassword')->save()); + + $response = $this + ->withPrecognition() + ->postJson('/!/auth/password', [ + 'current_password' => 'wrongpassword', + 'password' => 'newpassword', + 'password_confirmation' => 'newpassword', + ]); + + $response->assertStatus(422); + } } diff --git a/tests/Tags/User/ProfileFormTest.php b/tests/Tags/User/ProfileFormTest.php index 6abfc404c7..7de8f0863c 100644 --- a/tests/Tags/User/ProfileFormTest.php +++ b/tests/Tags/User/ProfileFormTest.php @@ -314,4 +314,22 @@ private function useCustomBlueprint() ->with('user') ->andReturn($blueprint); } + + /** @test */ + public function it_handles_precognitive_requests() + { + if (! method_exists($this, 'withPrecognition')) { + $this->markTestSkipped(); + } + + $this->actingAs(User::make()->save()); + + $response = $this + ->withPrecognition() + ->postJson('/!/auth/profile', [ + 'some' => 'thing', + ]); + + $response->assertStatus(422); + } } diff --git a/tests/Tags/User/RegisterFormTest.php b/tests/Tags/User/RegisterFormTest.php index d83f5a7934..eead21e10a 100644 --- a/tests/Tags/User/RegisterFormTest.php +++ b/tests/Tags/User/RegisterFormTest.php @@ -139,8 +139,8 @@ public function it_wont_register_user_and_renders_errors() preg_match_all('/

(.+)<\/p>/U', $output, $inlineErrors); $expected = [ - 'The email field is required.', - 'The password field is required.', + 'The Email Address field is required.', + 'The Password field is required.', ]; $this->assertEmpty($success[1]); @@ -191,8 +191,8 @@ public function it_wont_register_user_and_renders_custom_validation_errors() preg_match_all('/

(.+)<\/p>/U', $output, $inlineErrors); $expected = [ - trans('validation.min.string', ['attribute' => 'password', 'min' => 8]), // 'The password must be at least 8 characters.', - trans('validation.required', ['attribute' => 'age']), // 'The age field is required.', + trans('validation.min.string', ['attribute' => 'Password', 'min' => 8]), // 'The password must be at least 8 characters.', + trans('validation.required', ['attribute' => 'Over 18 years of age?']), // 'The age field is required.', ]; $this->assertEmpty($success[1]); @@ -311,8 +311,8 @@ public function it_wont_register_user_and_follow_custom_redirect_with_errors() preg_match_all('/

(.+)<\/p>/U', $output, $inlineErrors); $expected = [ - 'The email field is required.', - 'The password field is required.', + 'The Email Address field is required.', + 'The Password field is required.', ]; $this->assertEmpty($success[1]); @@ -461,4 +461,20 @@ public function it_will_register_user_when_honeypot_is_not_present() config()->set('statamic.users.registration_form_honeypot_field', null); } + + /** @test */ + public function it_handles_precognitive_requests() + { + if (! method_exists($this, 'withPrecognition')) { + $this->markTestSkipped(); + } + + $response = $this + ->withPrecognition() + ->postJson('/!/auth/register', [ + 'password_confirmation' => 'no', + ]); + + $response->assertStatus(422); + } }