diff --git a/src/bundle/Security/Authentication/DefaultAuthenticationFailureHandler.php b/src/bundle/Security/Authentication/DefaultAuthenticationFailureHandler.php index 9d6c799..5ec8a55 100644 --- a/src/bundle/Security/Authentication/DefaultAuthenticationFailureHandler.php +++ b/src/bundle/Security/Authentication/DefaultAuthenticationFailureHandler.php @@ -8,6 +8,7 @@ namespace Ibexa\Bundle\User\Security\Authentication; +use Ibexa\Bundle\User\Security\Exception\BlankCredentialsException; use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,6 +18,11 @@ final class DefaultAuthenticationFailureHandler extends HttpDefaultAuthenticationFailureHandler { + // Symfony's form_login defaults, not read from the firewall configuration. + private const string USERNAME_PARAMETER = '_username'; + + private const string PASSWORD_PARAMETER = '_password'; + #[\Override] public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { @@ -30,14 +36,40 @@ public function onAuthenticationFailure(Request $request, AuthenticationExceptio if ($exception instanceof BadCredentialsException) { $previous = $exception->getPrevious(); $code = $previous ? $previous->getCode() : 0; + $blankFields = $this->getBlankCredentialFields($request); - $exception = new BadCredentialsException( - 'Bad credentials.', - $code, - $previous - ); + $exception = $blankFields === [] + ? new BadCredentialsException( + 'Bad credentials.', + $code, + $previous + ) + : new BlankCredentialsException( + $blankFields, + 'Bad credentials.', + $code, + $previous + ); } return parent::onAuthenticationFailure($request, $exception); } + + /** + * @return list + */ + private function getBlankCredentialFields(Request $request): array + { + $blankFields = []; + + if (trim((string)$request->request->get(self::USERNAME_PARAMETER, '')) === '') { + $blankFields[] = BlankCredentialsException::FIELD_USERNAME; + } + + if ((string)$request->request->get(self::PASSWORD_PARAMETER, '') === '') { + $blankFields[] = BlankCredentialsException::FIELD_PASSWORD; + } + + return $blankFields; + } } diff --git a/src/bundle/Security/Exception/BlankCredentialsException.php b/src/bundle/Security/Exception/BlankCredentialsException.php new file mode 100644 index 0000000..3e9cb7e --- /dev/null +++ b/src/bundle/Security/Exception/BlankCredentialsException.php @@ -0,0 +1,49 @@ + $blankFields + */ + public function __construct( + public readonly array $blankFields, + string $message = '', + int $code = 0, + ?Throwable $previous = null + ) { + parent::__construct($message, $code, $previous); + } + + /** + * @return array{list, array} + */ + public function __serialize(): array + { + return [$this->blankFields, parent::__serialize()]; + } + + /** + * @param array{list, array} $data + */ + public function __unserialize(array $data): void + { + [$this->blankFields, $parentData] = $data; + + parent::__unserialize($parentData); + } +} diff --git a/src/lib/Form/Type/UserPasswordResetType.php b/src/lib/Form/Type/UserPasswordResetType.php index 69e7073..e895375 100644 --- a/src/lib/Form/Type/UserPasswordResetType.php +++ b/src/lib/Form/Type/UserPasswordResetType.php @@ -18,6 +18,7 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\NotBlank; class UserPasswordResetType extends AbstractType { @@ -29,7 +30,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'invalid_message' => /** @Desc("Passwords do not match.") */ 'ezplatform.reset_user_password.passwords_must_match', 'required' => true, 'first_options' => ['label' => /** @Desc("New password") */ 'ezplatform.reset_user_password.new_password'], - 'second_options' => ['label' => /** @Desc("Confirm password") */ 'ezplatform.reset_user_password.confirm_new_password'], + 'second_options' => [ + 'label' => /** @Desc("Confirm password") */ 'ezplatform.reset_user_password.confirm_new_password', + 'constraints' => [new NotBlank()], + ], 'constraints' => [ new Password( contentType: $options['content_type'], diff --git a/tests/lib/Security/Authentication/DefaultAuthenticationFailureHandlerTest.php b/tests/lib/Security/Authentication/DefaultAuthenticationFailureHandlerTest.php index 1a34b6c..89a8434 100644 --- a/tests/lib/Security/Authentication/DefaultAuthenticationFailureHandlerTest.php +++ b/tests/lib/Security/Authentication/DefaultAuthenticationFailureHandlerTest.php @@ -9,6 +9,7 @@ namespace Ibexa\Tests\Bundle\User\Security\Authentication; use Ibexa\Bundle\User\Security\Authentication\DefaultAuthenticationFailureHandler; +use Ibexa\Bundle\User\Security\Exception\BlankCredentialsException; use Ibexa\Contracts\Core\Repository\Exceptions\PasswordInUnsupportedFormatException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -59,13 +60,14 @@ public function testOnAuthenticationFailureAltersBadCredentialsExceptionMessage( '_security.last_error', self::callback(static function (AuthenticationException $exception): bool { self::assertInstanceOf(BadCredentialsException::class, $exception); + self::assertNotInstanceOf(BlankCredentialsException::class, $exception); self::assertSame('Bad credentials.', $exception->getMessage()); return true; }) ); - $request = $this->getRequest($session); + $request = $this->getRequest($session, 'admin', 'secret'); $originalException = new BadCredentialsException('Original message'); $this->httpUtils @@ -76,9 +78,100 @@ public function testOnAuthenticationFailureAltersBadCredentialsExceptionMessage( $this->handler->onAuthenticationFailure($request, $originalException); } - private function getRequest(?Session $session = null): Request + /** + * @dataProvider dataProviderForBlankCredentials + * + * @param list $expectedBlankFields + */ + public function testOnAuthenticationFailureReportsBlankCredentialFields( + string $username, + string $password, + array $expectedBlankFields + ): void { + $session = $this->getSession(); + $session + ->expects(self::once()) + ->method('set') + ->with( + '_security.last_error', + self::callback( + static function (AuthenticationException $exception) use ($expectedBlankFields): bool { + self::assertInstanceOf(BlankCredentialsException::class, $exception); + self::assertSame($expectedBlankFields, $exception->getBlankFields()); + self::assertSame('Bad credentials.', $exception->getMessage()); + + return true; + } + ) + ); + + $request = $this->getRequest($session, $username, $password); + + $this->handler->onAuthenticationFailure($request, new BadCredentialsException('Original message')); + } + + public function testOnAuthenticationFailureTreatsWhitespacePasswordAsFilled(): void { - $request = new Request(); + $session = $this->getSession(); + $session + ->expects(self::once()) + ->method('set') + ->with( + '_security.last_error', + self::callback(static function (AuthenticationException $exception): bool { + self::assertNotInstanceOf(BlankCredentialsException::class, $exception); + + return true; + }) + ); + + $request = $this->getRequest($session, 'admin', ' '); + + $this->handler->onAuthenticationFailure($request, new BadCredentialsException('Original message')); + } + + /** + * @return array}> + */ + public function dataProviderForBlankCredentials(): array + { + return [ + 'both fields blank' => [ + '', + '', + [BlankCredentialsException::FIELD_USERNAME, BlankCredentialsException::FIELD_PASSWORD], + ], + 'blank username only' => [ + '', + 'secret', + [BlankCredentialsException::FIELD_USERNAME], + ], + 'blank password only' => [ + 'admin', + '', + [BlankCredentialsException::FIELD_PASSWORD], + ], + 'whitespace-only username' => [ + ' ', + 'secret', + [BlankCredentialsException::FIELD_USERNAME], + ], + ]; + } + + private function getRequest(?Session $session = null, ?string $username = null, ?string $password = null): Request + { + $parameters = []; + + if ($username !== null) { + $parameters['_username'] = $username; + } + + if ($password !== null) { + $parameters['_password'] = $password; + } + + $request = new Request([], $parameters); $request->setSession($session ?? $this->getSession()); return $request;