diff --git a/config/services.php b/config/services.php index b620fde529..f986ec3b24 100644 --- a/config/services.php +++ b/config/services.php @@ -51,6 +51,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\LocaleConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\MoneyConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\NumberConfigurator; +use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\PasswordConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\PercentConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\SlugConfigurator; use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\TelephoneConfigurator; @@ -418,6 +419,7 @@ ->arg(1, service('property_accessor')) ->set(NumberConfigurator::class) + ->set(PasswordConfigurator::class) ->arg(0, service(IntlFormatter::class)) ->set(PercentConfigurator::class) diff --git a/doc/fields.rst b/doc/fields.rst index 7cb502cba4..58b22ba99d 100644 --- a/doc/fields.rst +++ b/doc/fields.rst @@ -711,6 +711,7 @@ These are all the built-in fields provided by EasyAdmin: * :doc:`LocaleField ` * :doc:`MoneyField ` * :doc:`NumberField ` +* :doc:`PasswordField ` * :doc:`PercentField ` * :doc:`SlugField ` * :doc:`TelephoneField ` diff --git a/doc/fields/PasswordField.rst b/doc/fields/PasswordField.rst new file mode 100644 index 0000000000..2116a385ae --- /dev/null +++ b/doc/fields/PasswordField.rst @@ -0,0 +1,62 @@ +PasswordField +============= + +This field is used to represent a user password in forms. On index and detail +pages, the password value is hidden and displayed as a sequence of bullets for +security purposes. + +Basic Information +----------------- + +* **PHP Class**: ``EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField`` +* **Doctrine DBAL Type**: ``string`` +* **Symfony Form Type**: ``Symfony\Component\Form\Extension\Core\Type\PasswordType`` +* **Rendered as**: ```` + +Usage +----- + +Basic usage:: + + use EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField; + + public function configureFields(string $pageName): iterable + { + return [ + // ... + PasswordField::new('password', 'User password'), + ]; + } + +Password Hashing +---------------- + +In most modern applications, plain text passwords should never be stored in the database. +You can use the ``hashPassword()`` method to define a callable that processes the plain +password submitted in the form before it is saved into the entity:: + + use EasyCorp\Bundle\EasyAdminBundle\Field\PasswordField; + use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; + + class UserCrudController extends AbstractCrudController + { + public function __construct( + private UserPasswordHasherInterface $userPasswordHasher + ) {} + + public function configureFields(string $pageName): iterable + { + $hashPassword = function ($plainPassword) { + // you can get the user entity from the current context or create a dummy one + // based on your Symfony configuration + return $this->userPasswordHasher->hashPassword($this->getUser(), $plainPassword); + }; + + return [ + PasswordField::new('password') + ->hashPassword($hashPassword), + ]; + } + } + +Alternatively, you could use Doctrine Entity Listeners to hash the password right before the entity is strictly persisted. Either way works natively with this field. diff --git a/src/Field/Configurator/PasswordConfigurator.php b/src/Field/Configurator/PasswordConfigurator.php new file mode 100644 index 0000000000..fe11d76281 --- /dev/null +++ b/src/Field/Configurator/PasswordConfigurator.php @@ -0,0 +1,50 @@ +getFieldFqcn(); + } + + public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void + { + $hashPasswordCallable = $field->getCustomOption(PasswordField::OPTION_HASH_PASSWORD); + + if (null !== $hashPasswordCallable) { + $field->setFormTypeOption('mapped', false); + + $builderDecorator = function (FormBuilderInterface $formBuilder) use ($hashPasswordCallable, $field) { + $formBuilder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($hashPasswordCallable, $field) { + $plainPassword = $event->getData(); + + if (null !== $plainPassword && '' !== $plainPassword) { + $hashedPassword = $hashPasswordCallable($plainPassword); + + $entity = $event->getForm()->getParent()->getData(); + $propertyName = $field->getProperty(); + + $propertyAccessor = \Symfony\Component\PropertyAccess\PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + + $propertyAccessor->setValue($entity, $propertyName, $hashedPassword); + } + }); + }; + + $field->setFormTypeOption('builder_callable', $builderDecorator); + } + } +} diff --git a/src/Field/PasswordField.php b/src/Field/PasswordField.php new file mode 100644 index 0000000000..e2712880b7 --- /dev/null +++ b/src/Field/PasswordField.php @@ -0,0 +1,31 @@ +setProperty($propertyName) + ->setLabel($label) + ->setTemplateName('crud/field/password') + ->setFormType(PasswordType::class) + ->addCssClass('field-password'); + } + + public function hashPassword(callable $passwordHasher): self + { + $this->setCustomOption(self::OPTION_HASH_PASSWORD, $passwordHasher); + + return $this; + } +} diff --git a/templates/crud/field/password.html.twig b/templates/crud/field/password.html.twig new file mode 100644 index 0000000000..756ad6c651 --- /dev/null +++ b/templates/crud/field/password.html.twig @@ -0,0 +1,8 @@ +{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #} +{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #} +{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #} +{% if field.value is null %} + {{ 'label.empty'|trans(domain: 'EasyAdminBundle') }} +{% else %} + •••••• +{% endif %} diff --git a/tests/Unit/Field/PasswordFieldTest.php b/tests/Unit/Field/PasswordFieldTest.php new file mode 100644 index 0000000000..ef2cc44bb0 --- /dev/null +++ b/tests/Unit/Field/PasswordFieldTest.php @@ -0,0 +1,56 @@ +configurator = new PasswordConfigurator(); + } + + public function testDefaultOptions(): void + { + $field = PasswordField::new('foo'); + $fieldDto = $this->configure($field); + + self::assertSame(PasswordType::class, $fieldDto->getFormType()); + self::assertStringContainsString('field-password', $fieldDto->getCssClass()); + self::assertSame('crud/field/password', $fieldDto->getTemplateName()); + self::assertNull($fieldDto->getCustomOption(PasswordField::OPTION_HASH_PASSWORD)); + } + + public function testFieldWithNullValue(): void + { + $field = PasswordField::new('foo'); + $field->setValue(null); + $fieldDto = $this->configure($field); + + self::assertNull($fieldDto->getValue()); + } + + public function testFieldWithStringValue(): void + { + $field = PasswordField::new('foo'); + $field->setValue('my_secret_password'); + $fieldDto = $this->configure($field); + + self::assertSame('my_secret_password', $fieldDto->getValue()); + } + + public function testHashPasswordOption(): void + { + $hashFunction = static fn (string $password) => md5($password); + $field = PasswordField::new('foo')->hashPassword($hashFunction); + $fieldDto = $this->configure($field); + + self::assertSame($hashFunction, $fieldDto->getCustomOption(PasswordField::OPTION_HASH_PASSWORD)); + self::assertFalse($fieldDto->getFormTypeOption('mapped')); + self::assertNotNull($fieldDto->getFormTypeOption('builder_callable')); + } +}