mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #10974 from creative-commoners/pulls/5/random-password
NEW Generate a random password if a blank password is entered
This commit is contained in:
commit
64e2b5e489
@ -36,6 +36,7 @@ en:
|
||||
SilverStripe\Forms\ConfirmedPasswordField:
|
||||
ATLEAST: 'Passwords must be at least {min} 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_MISSING: 'You must enter your current password.'
|
||||
LOGGED_IN_ERROR: 'You must be logged in to change your password.'
|
||||
|
@ -2,11 +2,13 @@
|
||||
|
||||
namespace SilverStripe\Forms;
|
||||
|
||||
use LogicException;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\DataObjectInterface;
|
||||
use SilverStripe\Security\Authenticator;
|
||||
use SilverStripe\Security\Security;
|
||||
use SilverStripe\View\HTML;
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Two masked input fields, checks for matching passwords.
|
||||
@ -43,12 +45,20 @@ class ConfirmedPasswordField extends FormField
|
||||
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
|
||||
*/
|
||||
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
|
||||
* 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)
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -552,9 +582,7 @@ class ConfirmedPasswordField extends FormField
|
||||
}
|
||||
|
||||
/**
|
||||
* Only save if field was shown on the client, and is not empty.
|
||||
*
|
||||
* @param DataObjectInterface $record
|
||||
* Only save if field was shown on the client, and is not empty or random password generation is enabled
|
||||
*/
|
||||
public function saveInto(DataObjectInterface $record)
|
||||
{
|
||||
@ -562,7 +590,18 @@ class ConfirmedPasswordField extends FormField
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -694,4 +733,21 @@ class ConfirmedPasswordField extends FormField
|
||||
{
|
||||
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\ValidationResult;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Closure;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The member class which represents the users of the system
|
||||
@ -670,7 +672,13 @@ class Member extends DataObject
|
||||
$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);
|
||||
|
||||
return $password;
|
||||
@ -1702,4 +1710,51 @@ class Member extends DataObject
|
||||
// If can't find a suitable editor, just default to 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\Security\Member;
|
||||
use SilverStripe\Security\PasswordValidator;
|
||||
use Closure;
|
||||
|
||||
class ConfirmedPasswordFieldTest extends SapphireTest
|
||||
{
|
||||
@ -381,4 +382,49 @@ class ConfirmedPasswordFieldTest extends SapphireTest
|
||||
$field->setRequireExistingPassword(false);
|
||||
$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