mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW Generate a random password if a blank password is entered
This commit is contained in:
parent
87958e7484
commit
159112ca8b
@ -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.'
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user