Compare commits

...

4 Commits

Author SHA1 Message Date
Steve Boyd
f9aad1365d
Merge b5fee199c8 into 6bb9a0b33d 2024-10-14 05:30:35 +13:00
github-actions
6bb9a0b33d Merge branch '5' into 6 2024-10-13 08:43:04 +00:00
Guy Sartorelli
ebbd6427b2
ENH Allow overriding GridFieldFilterHeader placeholder (#11418) 2024-10-11 15:49:39 +13:00
Steve Boyd
b5fee199c8 NEW Give feedback of password strength 2024-10-04 15:39:19 +13:00
6 changed files with 234 additions and 16 deletions

View File

@ -10,8 +10,13 @@ use SilverStripe\Security\Authenticator;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\View\HTML; use SilverStripe\View\HTML;
use Closure; use Closure;
use SilverStripe\Control\HTTP;
use SilverStripe\Core\Validation\ConstraintValidator; use SilverStripe\Core\Validation\ConstraintValidator;
use Symfony\Component\Validator\Constraints\PasswordStrength; use Symfony\Component\Validator\Constraints\PasswordStrength;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Security\Validation\PasswordValidator;
/** /**
* Two masked input fields, checks for matching passwords. * Two masked input fields, checks for matching passwords.
@ -24,6 +29,9 @@ use Symfony\Component\Validator\Constraints\PasswordStrength;
*/ */
class ConfirmedPasswordField extends FormField class ConfirmedPasswordField extends FormField
{ {
private static $allowed_actions = [
'strength',
];
/** /**
* Minimum character length of the password. * Minimum character length of the password.
@ -106,6 +114,8 @@ class ConfirmedPasswordField extends FormField
protected ?PasswordField $passwordField; protected ?PasswordField $passwordField;
protected ?LiteralField $passwordStrengthField;
protected ?PasswordField $confirmPasswordfield; protected ?PasswordField $confirmPasswordfield;
protected ?HiddenField $hiddenField = null; protected ?HiddenField $hiddenField = null;
@ -132,6 +142,10 @@ class ConfirmedPasswordField extends FormField
"{$name}[_Password]", "{$name}[_Password]",
$title $title
), ),
$this->passwordStrengthField = LiteralField::create(
"{$name}[_PasswordStrength]",
'<div class="passwordstrength"></div>'
),
$this->confirmPasswordfield = PasswordField::create( $this->confirmPasswordfield = PasswordField::create(
"{$name}[_ConfirmPassword]", "{$name}[_ConfirmPassword]",
(isset($titleConfirmField)) ? $titleConfirmField : _t('SilverStripe\\Security\\Member.CONFIRMPASSWORD', 'Confirm Password') (isset($titleConfirmField)) ? $titleConfirmField : _t('SilverStripe\\Security\\Member.CONFIRMPASSWORD', 'Confirm Password')
@ -154,6 +168,50 @@ class ConfirmedPasswordField extends FormField
$this->setValue($value); $this->setValue($value);
} }
/**
* Provides feedback for the current and required level of password strength
*/
public function strength(HTTPRequest $request): HTTPResponse
{
$response = HTTPResponse::create();
$json = json_decode($request->getBody(), true);
if (!$json || !array_key_exists('password', $json) || !$request->isPOST()) {
$response->setStatusCode(400);
return $response;
}
$password = $json['password'];
$validator = PasswordValidator::create();
if ($this->getRequireStrongPassword()) {
$requiredStrength = $this->getMinPasswordStrength();
} else {
$requiredStrength = $validator->getRequiredStrength();
}
$requiredLevel = $validator->getStrengthLevel($requiredStrength);
$passwordStrength = $validator->evaluateStrength($password);
$passwordLevel = $validator->getStrengthLevel($passwordStrength);
if ($passwordStrength < $requiredStrength) {
$valid = false;
$message = _t(
__CLASS__ . '.STRENGTH',
'Password strength is {passwordLevel}, must be at least {requiredLevel}',
['passwordLevel' => $passwordLevel, 'requiredLevel' => $requiredLevel]
);
} else {
$valid = true;
$message = _t(
__CLASS__ . '.STRENGTH',
'Password strength is {passwordLevel}',
['passwordLevel' => $passwordLevel]
);
}
$body = json_encode((object) [
'valid' => $valid,
'message' => $message,
]);
$response->setBody($body);
return $response;
}
public function Title() public function Title()
{ {
// Title is displayed on nested field, not on the top level field // Title is displayed on nested field, not on the top level field
@ -173,6 +231,7 @@ class ConfirmedPasswordField extends FormField
*/ */
public function Field($properties = []) public function Field($properties = [])
{ {
$canEvaluateStrength = PasswordValidator::singleton()->canEvaluateStrength();
// Build inner content // Build inner content
$fieldContent = ''; $fieldContent = '';
foreach ($this->getChildren() as $field) { foreach ($this->getChildren() as $field) {
@ -184,6 +243,9 @@ class ConfirmedPasswordField extends FormField
$field->setAttribute($name, $value); $field->setAttribute($name, $value);
} }
} }
if ($canEvaluateStrength && is_a($field, PasswordField::class)) {
$field->setAttribute('data-strengthurl', $this->Link('strength'));
}
$fieldContent .= $field->FieldHolder(['AttributesHTML' => $this->getAttributesHTMLForChild($field)]); $fieldContent .= $field->FieldHolder(['AttributesHTML' => $this->getAttributesHTMLForChild($field)]);
} }

View File

@ -49,6 +49,8 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
*/ */
protected ?string $searchField = null; protected ?string $searchField = null;
private string $placeHolderText = '';
/** /**
* @inheritDoc * @inheritDoc
*/ */
@ -245,6 +247,24 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
return false; return false;
} }
/**
* Get the text to be used as a placeholder in the search field.
* If blank, the placeholder will be generated based on the class held in the GridField.
*/
public function getPlaceHolderText(): string
{
return $this->placeHolderText;
}
/**
* Set the text to be used as a placeholder in the search field.
* If blank, this text will be generated based on the class held in the GridField.
*/
public function setPlaceHolderText(string $placeHolderText): static
{
$this->placeHolderText = $placeHolderText;
return $this;
}
/** /**
* Generate a search context based on the model class of the of the GridField * Generate a search context based on the model class of the of the GridField
@ -318,7 +338,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
$schema = [ $schema = [
'formSchemaUrl' => $schemaUrl, 'formSchemaUrl' => $schemaUrl,
'name' => $searchField, 'name' => $searchField,
'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $this->getTitle($gridField, $inst)]), 'placeholder' => $this->getPlaceHolder($inst),
'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}' 'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}'
'gridfield' => $gridField->getName(), 'gridfield' => $gridField->getName(),
'searchAction' => $searchAction->getAttribute('name'), 'searchAction' => $searchAction->getAttribute('name'),
@ -330,19 +350,6 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
return json_encode($schema); return json_encode($schema);
} }
private function getTitle(GridField $gridField, object $inst): string
{
if ($gridField->Title) {
return $gridField->Title;
}
if (ClassInfo::hasMethod($inst, 'i18n_plural_name')) {
return $inst->i18n_plural_name();
}
return ClassInfo::shortName($inst);
}
/** /**
* Returns the search form for the component * Returns the search form for the component
* *
@ -386,7 +393,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
$field->addExtraClass('stacked no-change-track'); $field->addExtraClass('stacked no-change-track');
} }
$name = $this->getTitle($gridField, singleton($gridField->getModelClass())); $name = $this->getTitle(singleton($gridField->getModelClass()));
$this->searchForm = $form = new Form( $this->searchForm = $form = new Form(
$gridField, $gridField,
@ -456,4 +463,32 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
) )
]; ];
} }
/**
* Get the text that will be used as a placeholder in the search field.
*
* @param object $obj An instance of the class that will be searched against.
* If getPlaceHolderText is empty, this object will be used to build the placeholder
* e.g. 'Search "My Data Object"'
*/
private function getPlaceHolder(object $obj): string
{
$placeholder = $this->getPlaceHolderText();
if (!empty($placeholder)) {
return $placeholder;
}
if ($obj) {
return _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $this->getTitle($obj)]);
}
return _t(__CLASS__ . '.Search_Default', 'Search');
}
private function getTitle(object $inst): string
{
if (ClassInfo::hasMethod($inst, 'i18n_plural_name')) {
return $inst->i18n_plural_name();
}
return ClassInfo::shortName($inst);
}
} }

View File

@ -2,6 +2,12 @@
namespace SilverStripe\Forms; namespace SilverStripe\Forms;
use SilverStripe\Control\Director;
use SilverStripe\Security\Security;
use SilverStripe\Security\Validation\PasswordValidator;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
/** /**
* Password input field. * Password input field.
*/ */

View File

@ -20,7 +20,7 @@ class EntropyPasswordValidator extends PasswordValidator
* The strength of a valid password. * The strength of a valid password.
* See https://symfony.com/doc/current/reference/constraints/PasswordStrength.html#minscore * See https://symfony.com/doc/current/reference/constraints/PasswordStrength.html#minscore
*/ */
private static int $password_strength = PasswordStrength::STRENGTH_STRONG; private static int $password_strength = PasswordStrength::STRENGTH_MEDIUM;
public function validate(string $password, Member $member): ValidationResult public function validate(string $password, Member $member): ValidationResult
{ {
@ -30,4 +30,35 @@ class EntropyPasswordValidator extends PasswordValidator
$this->extend('updateValidatePassword', $password, $member, $result, $this); $this->extend('updateValidatePassword', $password, $member, $result, $this);
return $result; return $result;
} }
public function getRequiredStrength(): int
{
return static::config()->get('password_strength');
}
public function canEvaluateStrength(): bool
{
return true;
}
public function evaluateStrength(string $password): int
{
$strengths = [
PasswordStrength::STRENGTH_WEAK,
PasswordStrength::STRENGTH_MEDIUM,
PasswordStrength::STRENGTH_STRONG,
PasswordStrength::STRENGTH_VERY_STRONG,
];
// STRENGTH_VERY_WEAK is not validatable, it's just the default value
$lastPassedStrength = PasswordStrength::STRENGTH_VERY_WEAK;
foreach ($strengths as $strength) {
$result = ConstraintValidator::validate($password, new PasswordStrength(minScore: $strength));
if ($result->isValid()) {
$lastPassedStrength = $strength;
} else {
break;
}
}
return $lastPassedStrength;
}
} }

View File

@ -8,6 +8,7 @@ use SilverStripe\ORM\DataObject;
use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\MemberPassword; use SilverStripe\Security\MemberPassword;
use Symfony\Component\Validator\Constraints\PasswordStrength;
/** /**
* Abstract validator with functionality for checking for reusing old passwords. * Abstract validator with functionality for checking for reusing old passwords.
@ -69,4 +70,63 @@ abstract class PasswordValidator
$this->historicalPasswordCount = $count; $this->historicalPasswordCount = $count;
return $this; return $this;
} }
/**
* Get the required strength of a password based on the consts in
* Symfony\Component\Validator\Constraints\PasswordStrength
* Default return -1 for validators that do not support this
*
*/
public function getRequiredStrength(): int
{
return -1;
}
/**
* Check if this validator can evaluate password strength.
*/
public function canEvaluateStrength(): bool
{
return false;
}
/**
* Evaluate the strength of a password based on the consts in
* Symfony\Component\Validator\Constraints\PasswordStrength
* Default return -1 for validators that do not support this
*/
public function evaluateStrength(string $password): int
{
return -1;
}
/**
* Textual representation of an evaluated password strength
*/
public static function getStrengthLevel(int $strength): string
{
return match ($strength) {
PasswordStrength::STRENGTH_VERY_WEAK => _t(
PasswordValidator::class . '.VERYWEAK',
'very weak'
),
PasswordStrength::STRENGTH_WEAK => _t(
PasswordValidator::class . '.WEAK',
'weak'
),
PasswordStrength::STRENGTH_MEDIUM => _t(
PasswordValidator::class . '.MEDIUM',
'medium'
),
PasswordStrength::STRENGTH_STRONG => _t(
PasswordValidator::class . '.STRONG',
'strong'
),
PasswordStrength::STRENGTH_VERY_STRONG => _t(
PasswordValidator::class . '.VERYSTRONG',
'very strong'
),
default => '',
};
}
} }

View File

@ -3,6 +3,7 @@
namespace SilverStripe\Forms\Tests\GridField; namespace SilverStripe\Forms\Tests\GridField;
use LogicException; use LogicException;
use ReflectionMethod;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
@ -117,6 +118,29 @@ class GridFieldFilterHeaderTest extends SapphireTest
$this->assertEquals('testfield', $searchSchema->gridfield); $this->assertEquals('testfield', $searchSchema->gridfield);
} }
/**
* Tests the private method that returns the placeholder for the search field
*/
public function testGetPlaceHolder()
{
$gridField = new GridField('test');
$filterHeader = new GridFieldFilterHeader();
$reflectionGetPlaceHolder = new ReflectionMethod($filterHeader, 'getPlaceHolder');
$reflectionGetPlaceHolder->setAccessible(true);
// No explicit placeholder or model i18n_plural_name method
$this->assertSame('Search "ArrayData"', $reflectionGetPlaceHolder->invoke($filterHeader, new ArrayData()));
// No explicit placeholder, but model has i18n_plural_name method
$model = new DataObject();
$this->assertSame('Search "' . $model->i18n_plural_name() . '"', $reflectionGetPlaceHolder->invoke($filterHeader, $model));
// Explicit placeholder is set, which overrides both of the above cases
$filterHeader->setPlaceHolderText('This is the text');
$this->assertSame('This is the text', $reflectionGetPlaceHolder->invoke($filterHeader, $model));
$this->assertSame('This is the text', $reflectionGetPlaceHolder->invoke($filterHeader, new ArrayData()));
}
public function testHandleActionReset() public function testHandleActionReset()
{ {
// Init Grid state with some pre-existing filters // Init Grid state with some pre-existing filters