NEW Generate a random password if a blank password is entered

This commit is contained in:
Steve Boyd 2023-10-20 11:37:25 +13:00
parent 87958e7484
commit 159112ca8b
5 changed files with 195 additions and 6 deletions

View File

@ -36,6 +36,7 @@ en:
SilverStripe\Forms\ConfirmedPasswordField: SilverStripe\Forms\ConfirmedPasswordField:
ATLEAST: 'Passwords must be at least {min} characters long.' ATLEAST: 'Passwords must be at least {min} characters long.'
BETWEEN: 'Passwords must be {min} to {max} characters long.' BETWEEN: 'Passwords must be {min} to {max} characters long.'
RANDOM_IF_EMPTY: 'If this is left blank then a random password will be automatically generated.'
CURRENT_PASSWORD_ERROR: 'The current password you have entered is not correct.' CURRENT_PASSWORD_ERROR: 'The current password you have entered is not correct.'
CURRENT_PASSWORD_MISSING: 'You must enter your current password.' CURRENT_PASSWORD_MISSING: 'You must enter your current password.'
LOGGED_IN_ERROR: 'You must be logged in to change your password.' LOGGED_IN_ERROR: 'You must be logged in to change your password.'

View File

@ -2,11 +2,13 @@
namespace SilverStripe\Forms; namespace SilverStripe\Forms;
use LogicException;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface; use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\Security\Authenticator; use SilverStripe\Security\Authenticator;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\View\HTML; use SilverStripe\View\HTML;
use Closure;
/** /**
* Two masked input fields, checks for matching passwords. * Two masked input fields, checks for matching passwords.
@ -43,12 +45,20 @@ class ConfirmedPasswordField extends FormField
public $requireStrongPassword = false; public $requireStrongPassword = false;
/** /**
* Allow empty fields in serverside validation * Allow empty fields when entering the password for the first time
* If this is set to true then a random password may be generated if the field is empty
* depending on the value of $self::generateRandomPasswordOnEmtpy
* *
* @var boolean * @var boolean
*/ */
public $canBeEmpty = false; public $canBeEmpty = false;
/**
* Callback used to generate a random password if $this->canBeEmpty is true and the field is left blank
* If this is set to null then a random password will not be generated
*/
private ?Closure $randomPasswordCallback = null;
/** /**
* If set to TRUE, the "password" and "confirm password" form fields will * If set to TRUE, the "password" and "confirm password" form fields will
* be hidden via CSS and JavaScript by default, and triggered by a link. * be hidden via CSS and JavaScript by default, and triggered by a link.
@ -255,7 +265,27 @@ class ConfirmedPasswordField extends FormField
public function setCanBeEmpty($value) public function setCanBeEmpty($value)
{ {
$this->canBeEmpty = (bool)$value; $this->canBeEmpty = (bool)$value;
$this->updateRightTitle();
return $this;
}
/**
* Gets the callback used to generate a random password
*/
public function getRandomPasswordCallback(): ?Closure
{
return $this->randomPasswordCallback;
}
/**
* Sets a callback used to generate a random password if canBeEmpty is set to true
* and the password field is left blank
* If this is set to null then a random password will not be generated
*/
public function setRandomPasswordCallback(?Closure $callback): static
{
$this->randomPasswordCallback = $callback;
$this->updateRightTitle();
return $this; return $this;
} }
@ -552,9 +582,7 @@ class ConfirmedPasswordField extends FormField
} }
/** /**
* Only save if field was shown on the client, and is not empty. * Only save if field was shown on the client, and is not empty or random password generation is enabled
*
* @param DataObjectInterface $record
*/ */
public function saveInto(DataObjectInterface $record) public function saveInto(DataObjectInterface $record)
{ {
@ -562,7 +590,18 @@ class ConfirmedPasswordField extends FormField
return; return;
} }
if (!($this->canBeEmpty && !$this->value)) { // Create a random password if password is blank and the flag is set
if (!$this->value
&& $this->canBeEmpty
&& $this->randomPasswordCallback
) {
if (!is_callable($this->randomPasswordCallback)) {
throw new LogicException('randomPasswordCallback must be callable');
}
$this->value = call_user_func_array($this->randomPasswordCallback, [$this->maxLength ?: 0]);
}
if ($this->value || $this->canBeEmtpy) {
parent::saveInto($record); parent::saveInto($record);
} }
} }
@ -694,4 +733,21 @@ class ConfirmedPasswordField extends FormField
{ {
return $this->requireStrongPassword; return $this->requireStrongPassword;
} }
/**
* Appends a warning to the right title, or removes that appended warning.
*/
private function updateRightTitle(): void
{
$text = _t(
__CLASS__ . '.RANDOM_IF_EMPTY',
'If this is left blank then a random password will be automatically generated.'
);
$rightTitle = $this->passwordField->RightTitle() ?? '';
$rightTitle = trim(str_replace($text, '', $rightTitle));
if ($this->canBeEmpty && $this->randomPasswordCallback) {
$rightTitle = $text . ' ' . $rightTitle;
}
$this->passwordField->setRightTitle($rightTitle ?: null);
}
} }

View File

@ -35,6 +35,8 @@ use SilverStripe\ORM\UnsavedRelationList;
use SilverStripe\ORM\ValidationException; use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult; use SilverStripe\ORM\ValidationResult;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Closure;
use RuntimeException;
/** /**
* The member class which represents the users of the system * The member class which represents the users of the system
@ -670,7 +672,13 @@ class Member extends DataObject
$password->setRequireExistingPassword(true); $password->setRequireExistingPassword(true);
} }
$password->setCanBeEmpty(false); if (!$editingPassword) {
$password->setCanBeEmpty(true);
$password->setRandomPasswordCallback(Closure::fromCallable([$this, 'generateRandomPassword']));
// explicitly set "require strong password" to false because its regex in ConfirmedPasswordField
// is too restrictive for generateRandomPassword() which will add in non-alphanumeric characters
$password->setRequireStrongPassword(false);
}
$this->extend('updateMemberPasswordField', $password); $this->extend('updateMemberPasswordField', $password);
return $password; return $password;
@ -1702,4 +1710,51 @@ class Member extends DataObject
// If can't find a suitable editor, just default to cms // If can't find a suitable editor, just default to cms
return $currentName ? $currentName : 'cms'; return $currentName ? $currentName : 'cms';
} }
/**
* Generate a random password and validate it against the current password validator if one is set
*
* @param int $length The length of the password to generate, defaults to 0 which will use the
* greater of the validator's minimum length or 20
*/
public function generateRandomPassword(int $length = 0): string
{
$password = '';
$validator = self::password_validator();
if ($length && $validator && $length < $validator->getMinLength()) {
throw new InvalidArgumentException('length argument is less than password validator minLength');
}
$validatorMinLength = $validator ? $validator->getMinLength() : 0;
$len = $length ?: max($validatorMinLength, 20);
// The default PasswordValidator checks the password includes the following four character sets
$charsets = [
'abcdefghijklmnopqrstuvwyxz',
'ABCDEFGHIJKLMNOPQRSTUVWYXZ',
'0123456789',
'!@#$%^&*()_+-=[]{};:,./<>?',
];
$password = '';
for ($i = 0; $i < $len; $i++) {
$charset = $charsets[$i % 4];
$randomInt = random_int(0, strlen($charset) - 1);
$password .= $charset[$randomInt];
}
// randomise the order of the characters
$passwordArr = [];
$len = strlen($password);
foreach (str_split($password) as $char) {
$r = random_int(0, $len + 10000);
while (array_key_exists($r, $passwordArr)) {
$r++;
}
$passwordArr[$r] = $char;
}
ksort($passwordArr);
$password = implode('', $passwordArr);
$this->extend('updateRandomPassword', $password);
if ($validator && !$validator->validate($password, $this)) {
throw new RuntimeException('Unable to generate a random password');
}
return $password;
}
} }

View File

@ -11,6 +11,7 @@ use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\RequiredFields; use SilverStripe\Forms\RequiredFields;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordValidator; use SilverStripe\Security\PasswordValidator;
use Closure;
class ConfirmedPasswordFieldTest extends SapphireTest class ConfirmedPasswordFieldTest extends SapphireTest
{ {
@ -381,4 +382,49 @@ class ConfirmedPasswordFieldTest extends SapphireTest
$field->setRequireExistingPassword(false); $field->setRequireExistingPassword(false);
$this->assertCount(2, $field->getChildren(), 'Current password field should not be removed'); $this->assertCount(2, $field->getChildren(), 'Current password field should not be removed');
} }
/**
* @dataProvider provideSetCanBeEmptySaveInto
*/
public function testSetCanBeEmptySaveInto(bool $generateRandomPasswordOnEmpty, ?string $expected)
{
$field = new ConfirmedPasswordField('Test', 'Change it');
$field->setCanBeEmpty(true);
if ($generateRandomPasswordOnEmpty) {
$field->setRandomPasswordCallback(Closure::fromCallable(function () {
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
}));
}
$this->assertEmpty($field->Value());
$member = new Member();
$field->saveInto($member);
$this->assertSame($expected, $field->Value());
}
public function provideSetCanBeEmptySaveInto(): array
{
return [
[
'generateRandomPasswordOnEmpty' => true,
'expected' => 'R4ndom-P4ssw0rd$LOREM^ipsum#12345',
],
[
'generateRandomPasswordOnEmpty' => false,
'expected' => null,
],
];
}
public function testSetCanBeEmptyRightTitle()
{
$field = new ConfirmedPasswordField('Test', 'Change it');
$passwordField = $field->getPasswordField();
$this->assertEmpty($passwordField->RightTitle());
$field->setCanBeEmpty(true);
$this->assertEmpty($passwordField->RightTitle());
$field->setRandomPasswordCallback(Closure::fromCallable(function () {
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
}));
$this->assertNotEmpty($passwordField->RightTitle());
}
} }

View File

@ -1896,4 +1896,35 @@ class MemberTest extends FunctionalTest
], ],
]; ];
} }
public function testGenerateRandomPassword()
{
$member = new Member();
// no password validator
Member::set_password_validator(null);
// password length is same as length argument
$password = $member->generateRandomPassword(5);
$this->assertSame(5, strlen($password));
// default to 20 if not length argument
$password = $member->generateRandomPassword();
$this->assertSame(20, strlen($password));
// password validator
$validator = new PasswordValidator();
Member::set_password_validator($validator);
// Password length of 20 even if validator minLength is less than 20
$validator->setMinLength(10);
$password = $member->generateRandomPassword();
$this->assertSame(20, strlen($password));
// Password length of 25 if passing length argument, and validator minlength is less than length argument
$password = $member->generateRandomPassword(25);
$this->assertSame(25, strlen($password));
// Password length is validator minLength if validator minLength is greater than 20 and no length argument
$validator->setMinLength(30);
$password = $member->generateRandomPassword();
$this->assertSame(30, strlen($password));
// Exception throw if length argument is less than validator minLength
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('length argument is less than password validator minLength');
$password = $member->generateRandomPassword(15);
}
} }