diff --git a/src/Forms/ConfirmedPasswordField.php b/src/Forms/ConfirmedPasswordField.php index 1856a057e..1e02672d8 100644 --- a/src/Forms/ConfirmedPasswordField.php +++ b/src/Forms/ConfirmedPasswordField.php @@ -10,8 +10,13 @@ use SilverStripe\Security\Authenticator; use SilverStripe\Security\Security; use SilverStripe\View\HTML; use Closure; +use SilverStripe\Control\HTTP; use SilverStripe\Core\Validation\ConstraintValidator; 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. @@ -24,6 +29,9 @@ use Symfony\Component\Validator\Constraints\PasswordStrength; */ class ConfirmedPasswordField extends FormField { + private static $allowed_actions = [ + 'strength', + ]; /** * Minimum character length of the password. @@ -106,6 +114,8 @@ class ConfirmedPasswordField extends FormField protected ?PasswordField $passwordField; + protected ?LiteralField $passwordStrengthField; + protected ?PasswordField $confirmPasswordfield; protected ?HiddenField $hiddenField = null; @@ -132,6 +142,10 @@ class ConfirmedPasswordField extends FormField "{$name}[_Password]", $title ), + $this->passwordStrengthField = LiteralField::create( + "{$name}[_PasswordStrength]", + '
' + ), $this->confirmPasswordfield = PasswordField::create( "{$name}[_ConfirmPassword]", (isset($titleConfirmField)) ? $titleConfirmField : _t('SilverStripe\\Security\\Member.CONFIRMPASSWORD', 'Confirm Password') @@ -154,6 +168,50 @@ class ConfirmedPasswordField extends FormField $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() { // Title is displayed on nested field, not on the top level field @@ -173,6 +231,7 @@ class ConfirmedPasswordField extends FormField */ public function Field($properties = []) { + $canEvaluateStrength = PasswordValidator::singleton()->canEvaluateStrength(); // Build inner content $fieldContent = ''; foreach ($this->getChildren() as $field) { @@ -184,6 +243,9 @@ class ConfirmedPasswordField extends FormField $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)]); } diff --git a/src/Forms/PasswordField.php b/src/Forms/PasswordField.php index bd1adf7ab..7bf5ff471 100644 --- a/src/Forms/PasswordField.php +++ b/src/Forms/PasswordField.php @@ -2,6 +2,12 @@ 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. */ diff --git a/src/Security/Validation/EntropyPasswordValidator.php b/src/Security/Validation/EntropyPasswordValidator.php index 27c7f6186..2302447c6 100644 --- a/src/Security/Validation/EntropyPasswordValidator.php +++ b/src/Security/Validation/EntropyPasswordValidator.php @@ -20,7 +20,7 @@ class EntropyPasswordValidator extends PasswordValidator * The strength of a valid password. * 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 { @@ -30,4 +30,35 @@ class EntropyPasswordValidator extends PasswordValidator $this->extend('updateValidatePassword', $password, $member, $result, $this); 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; + } } diff --git a/src/Security/Validation/PasswordValidator.php b/src/Security/Validation/PasswordValidator.php index fe0d83e2e..2b7030bf6 100644 --- a/src/Security/Validation/PasswordValidator.php +++ b/src/Security/Validation/PasswordValidator.php @@ -8,6 +8,7 @@ use SilverStripe\ORM\DataObject; use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Security\Member; use SilverStripe\Security\MemberPassword; +use Symfony\Component\Validator\Constraints\PasswordStrength; /** * Abstract validator with functionality for checking for reusing old passwords. @@ -69,4 +70,63 @@ abstract class PasswordValidator $this->historicalPasswordCount = $count; 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 => '', + }; + } }