ENH Use symfony/validation logic (#11399)

This commit is contained in:
Guy Sartorelli 2024-10-02 14:38:21 +13:00 committed by GitHub
parent aa2b8c380e
commit 7f11bf3587
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 545 additions and 461 deletions

View File

@ -28,7 +28,8 @@ SilverStripe\Dev\Backtrace:
- ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptA'] - ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptA']
- ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptX'] - ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptX']
- ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptY'] - ['SilverStripe\Security\PasswordEncryptor_Blowfish', 'encryptY']
- ['SilverStripe\Security\PasswordValidator', 'validate'] - ['SilverStripe\Security\Validation\RulesPasswordValidator', 'validate']
- ['SilverStripe\Security\Validation\EntropyPasswordValidator', 'validate']
- ['SilverStripe\Security\RememberLoginHash', 'setToken'] - ['SilverStripe\Security\RememberLoginHash', 'setToken']
- ['SilverStripe\Security\Security', 'encrypt_password'] - ['SilverStripe\Security\Security', 'encrypt_password']
- ['*', 'checkPassword'] - ['*', 'checkPassword']

View File

@ -2,12 +2,5 @@
Name: corepasswords Name: corepasswords
--- ---
SilverStripe\Core\Injector\Injector: SilverStripe\Core\Injector\Injector:
SilverStripe\Security\PasswordValidator: SilverStripe\Security\Validation\PasswordValidator:
properties: class: 'SilverStripe\Security\Validation\EntropyPasswordValidator'
MinLength: 8
HistoricCount: 6
# In the case someone uses `new PasswordValidator` instead of Injector, provide some safe defaults through config.
SilverStripe\Security\PasswordValidator:
min_length: 8
historic_count: 6

View File

@ -50,7 +50,7 @@
"symfony/mailer": "^7.0", "symfony/mailer": "^7.0",
"symfony/mime": "^7.0", "symfony/mime": "^7.0",
"symfony/translation": "^7.0", "symfony/translation": "^7.0",
"symfony/validator": "^7.0", "symfony/validator": "^7.1",
"symfony/yaml": "^7.0", "symfony/yaml": "^7.0",
"ext-ctype": "*", "ext-ctype": "*",
"ext-dom": "*", "ext-dom": "*",

View File

@ -4,14 +4,13 @@ namespace SilverStripe\Control\Email;
use Exception; use Exception;
use RuntimeException; use RuntimeException;
use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\RFCValidation;
use SilverStripe\Control\Director; use SilverStripe\Control\Director;
use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Environment; use SilverStripe\Core\Environment;
use SilverStripe\Core\Extensible; use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Validation\ConstraintValidator;
use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\Model\ArrayData; use SilverStripe\Model\ArrayData;
use SilverStripe\View\Requirements; use SilverStripe\View\Requirements;
@ -22,6 +21,8 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email as SymfonyEmail; use Symfony\Component\Mime\Email as SymfonyEmail;
use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Validator\Constraints\Email as EmailConstraint;
use Symfony\Component\Validator\Constraints\NotBlank;
class Email extends SymfonyEmail class Email extends SymfonyEmail
{ {
@ -63,16 +64,17 @@ class Email extends SymfonyEmail
private bool $dataHasBeenSet = false; private bool $dataHasBeenSet = false;
/** /**
* Checks for RFC822-valid email format. * Checks for RFC valid email format.
*
* @copyright Cal Henderson <cal@iamcal.com>
* This code is licensed under a Creative Commons Attribution-ShareAlike 2.5 License
* http://creativecommons.org/licenses/by-sa/2.5/
*/ */
public static function is_valid_address(string $address): bool public static function is_valid_address(string $address): bool
{ {
$validator = new EmailValidator(); return ConstraintValidator::validate(
return $validator->isValid($address, new RFCValidation()); $address,
[
new EmailConstraint(mode: EmailConstraint::VALIDATION_MODE_STRICT),
new NotBlank()
]
)->isValid();
} }
public static function getSendAllEmailsTo(): array public static function getSendAllEmailsTo(): array
@ -117,7 +119,7 @@ class Email extends SymfonyEmail
$addresses = []; $addresses = [];
if (is_array($config)) { if (is_array($config)) {
foreach ($config as $key => $val) { foreach ($config as $key => $val) {
if (filter_var($key, FILTER_VALIDATE_EMAIL)) { if (static::is_valid_address($key)) {
$addresses[] = new Address($key, $val); $addresses[] = new Address($key, $val);
} else { } else {
$addresses[] = new Address($val); $addresses[] = new Address($val);

View File

@ -7,6 +7,8 @@ use BadMethodCallException;
use InvalidArgumentException; use InvalidArgumentException;
use SilverStripe\Core\ClassInfo; use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\ArrayLib; use SilverStripe\Core\ArrayLib;
use Symfony\Component\Validator\Constraints\Ip;
use Symfony\Component\Validator\Constraints\IpValidator;
/** /**
* Represents a HTTP-request, including a URL that is tokenised for parsing, and a request method * Represents a HTTP-request, including a URL that is tokenised for parsing, and a request method
@ -810,7 +812,8 @@ class HTTPRequest implements ArrayAccess
*/ */
public function setIP($ip) public function setIP($ip)
{ {
if (!filter_var($ip, FILTER_VALIDATE_IP)) { // We can't use ConstraintValidator here because it relies on injector and the kernel may not have booted yet.
if (!IpValidator::checkIp($ip, Ip::ALL)) {
throw new InvalidArgumentException("Invalid ip $ip"); throw new InvalidArgumentException("Invalid ip $ip");
} }
$this->ip = $ip; $this->ip = $ip;

View File

@ -3,7 +3,10 @@
namespace SilverStripe\Control\Middleware; namespace SilverStripe\Control\Middleware;
use SilverStripe\Control\HTTPRequest; use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Validation\ConstraintValidator;
use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\Validator\Constraints\Ip;
use Symfony\Component\Validator\Constraints\NotBlank;
/** /**
* This middleware will rewrite headers that provide IP and host details from an upstream proxy. * This middleware will rewrite headers that provide IP and host details from an upstream proxy.
@ -220,14 +223,14 @@ class TrustedProxyMiddleware implements HTTPMiddleware
// Prioritise filters // Prioritise filters
$filters = [ $filters = [
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, Ip::ALL_ONLY_PUBLIC,
FILTER_FLAG_NO_PRIV_RANGE, Ip::ALL_NO_PRIVATE,
null Ip::ALL
]; ];
foreach ($filters as $filter) { foreach ($filters as $filter) {
// Find best IP // Find best IP
foreach ($ips as $ip) { foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, $filter ?? 0)) { if (ConstraintValidator::validate($ip, [new Ip(version: $filter), new NotBlank()])->isValid()) {
return $ip; return $ip;
} }
} }

View File

@ -2,9 +2,12 @@
namespace SilverStripe\Core; namespace SilverStripe\Core;
use SilverStripe\Core\Validation\ConstraintValidator;
use SimpleXMLElement; use SimpleXMLElement;
use SilverStripe\ORM\DB; use SilverStripe\ORM\DB;
use SilverStripe\View\Parsers\URLSegmentFilter; use SilverStripe\View\Parsers\URLSegmentFilter;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Url;
/** /**
* Library of conversion functions, implemented as static methods. * Library of conversion functions, implemented as static methods.
@ -226,16 +229,14 @@ class Convert
/** /**
* Create a link if the string is a valid URL * Create a link if the string is a valid URL
*
* @param string $string The string to linkify
* @return string A link to the URL if string is a URL
*/ */
public static function linkIfMatch($string) public static function linkIfMatch(
{ string $string,
if (preg_match('/^[a-z+]+\:\/\/[a-zA-Z0-9$-_.+?&=!*\'()%]+$/', $string ?? '')) { array $protocols = ['file', 'ftp', 'http', 'https', 'imap', 'nntp']
): string {
if (ConstraintValidator::validate($string, [new Url(protocols: $protocols), new NotBlank()])->isValid()) {
return "<a style=\"white-space: nowrap\" href=\"$string\">$string</a>"; return "<a style=\"white-space: nowrap\" href=\"$string\">$string</a>";
} }
return $string; return $string;
} }

View File

@ -10,6 +10,8 @@ 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\Core\Validation\ConstraintValidator;
use Symfony\Component\Validator\Constraints\PasswordStrength;
/** /**
* Two masked input fields, checks for matching passwords. * Two masked input fields, checks for matching passwords.
@ -25,34 +27,33 @@ class ConfirmedPasswordField extends FormField
/** /**
* Minimum character length of the password. * Minimum character length of the password.
*
* @var int
*/ */
public $minLength = null; public int $minLength = 0;
/** /**
* Maximum character length of the password. * Maximum character length of the password.
* * 0 means no maximum length.
* @var int
*/ */
public $maxLength = null; public int $maxLength = 0;
/** /**
* Enforces at least one digit and one alphanumeric * Enforces password strength validation based on entropy.
* character (in addition to {$minLength} and {$maxLength} * See setMinPasswordStrength()
*
* @var boolean
*/ */
public $requireStrongPassword = false; public bool $requireStrongPassword = false;
/** /**
* Allow empty fields when entering the password for the first time * 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 * If this is set to true then a random password may be generated if the field is empty
* depending on the value of $ConfirmedPasswordField::generateRandomPasswordOnEmtpy * depending on the value of $ConfirmedPasswordField::generateRandomPasswordOnEmtpy
*
* @var boolean
*/ */
public $canBeEmpty = false; public bool $canBeEmpty = false;
/**
* Minimum password strength if requireStrongPassword is true
* See https://symfony.com/doc/current/reference/constraints/PasswordStrength.html#minscore
*/
private int $minPasswordStrength = PasswordStrength::STRENGTH_STRONG;
/** /**
* Callback used to generate a random password if $this->canBeEmpty is true and the field is left blank * Callback used to generate a random password if $this->canBeEmpty is true and the field is left blank
@ -72,79 +73,54 @@ class ConfirmedPasswordField extends FormField
* *
* Caution: The form field does not include any JavaScript or CSS when used outside of the CMS context, * Caution: The form field does not include any JavaScript or CSS when used outside of the CMS context,
* since the required frontend dependencies are included through CMS bundling. * since the required frontend dependencies are included through CMS bundling.
*
* @param boolean $showOnClick
*/ */
protected $showOnClick = false; protected bool $showOnClick = false;
/** /**
* Check if the existing password should be entered first * Check if the existing password should be entered first
*
* @var bool
*/ */
protected $requireExistingPassword = false; protected bool $requireExistingPassword = false;
/** /**
* A place to temporarily store the confirm password value * A place to temporarily store the confirm password value
*
* @var string
*/ */
protected $confirmValue; protected ?string $confirmValue = null;
/** /**
* Store value of "Current Password" field * Store value of "Current Password" field
*
* @var string
*/ */
protected $currentPasswordValue; protected ?string $currentPasswordValue = null;
/** /**
* Title for the link that triggers the visibility of password fields. * Title for the link that triggers the visibility of password fields.
*
* @var string
*/ */
public $showOnClickTitle; public string $showOnClickTitle = '';
/** /**
* Child fields (_Password, _ConfirmPassword) * Child fields (_Password, _ConfirmPassword)
*
* @var FieldList
*/ */
public $children; public FieldList $children;
protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_STRUCTURAL; protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_STRUCTURAL;
/** protected ?PasswordField $passwordField;
* @var PasswordField
*/ protected ?PasswordField $confirmPasswordfield;
protected $passwordField = null;
protected ?HiddenField $hiddenField = null;
/** /**
* @var PasswordField
*/
protected $confirmPasswordfield = null;
/**
* @var HiddenField
*/
protected $hiddenField = null;
/**
* @param string $name
* @param string $title
* @param mixed $value
* @param Form $form Ignored for ConfirmedPasswordField. * @param Form $form Ignored for ConfirmedPasswordField.
* @param boolean $showOnClick
* @param string $titleConfirmField Alternate title (not localizeable) * @param string $titleConfirmField Alternate title (not localizeable)
*/ */
public function __construct( public function __construct(
$name, string $name,
$title = null, ?string $title = null,
$value = "", mixed $value = "",
$form = null, ?Form $form = null,
$showOnClick = false, bool $showOnClick = false,
$titleConfirmField = null ?string $titleConfirmField = null
) { ) {
// Set field title // Set field title
@ -528,14 +504,18 @@ class ConfirmedPasswordField extends FormField
} }
if ($this->getRequireStrongPassword()) { if ($this->getRequireStrongPassword()) {
if (!preg_match('/^(([a-zA-Z]+\d+)|(\d+[a-zA-Z]+))[a-zA-Z0-9]*$/', $value ?? '')) { $strongEnough = ConstraintValidator::validate(
$value,
new PasswordStrength(minScore: $this->getMinPasswordStrength())
)->isValid();
if (!$strongEnough) {
$validator->validationError( $validator->validationError(
$name, $name,
_t( _t(
'SilverStripe\\Forms\\Form.VALIDATIONSTRONGPASSWORD', __CLASS__ . '.VALIDATIONSTRONGPASSWORD',
'Passwords must have at least one digit and one alphanumeric character' 'The password strength is too low. Please use a stronger password.'
), ),
"validation" 'validation'
); );
return $this->extendValidationResult(false, $validator); return $this->extendValidationResult(false, $validator);
@ -637,24 +617,21 @@ class ConfirmedPasswordField extends FormField
/** /**
* Check if existing password is required * Check if existing password is required
* * If true, an extra form field will be added to enter the existing password
* @return bool
*/ */
public function getRequireExistingPassword() public function getRequireExistingPassword(): bool
{ {
return $this->requireExistingPassword; return $this->requireExistingPassword;
} }
/** /**
* Set if the existing password should be required * Set if the existing password should be required
* * If true, an extra form field will be added to enter the existing password
* @param bool $show Flag to show or hide this field
* @return $this
*/ */
public function setRequireExistingPassword($show) public function setRequireExistingPassword(bool $show): static
{ {
// Don't modify if already added / removed // Don't modify if already added / removed
if ((bool)$show === $this->requireExistingPassword) { if ($show === $this->requireExistingPassword) {
return $this; return $this;
} }
$this->requireExistingPassword = $show; $this->requireExistingPassword = $show;
@ -670,79 +647,91 @@ class ConfirmedPasswordField extends FormField
} }
/** /**
* @return PasswordField * Get the FormField that represents the main password field
*/ */
public function getPasswordField() public function getPasswordField(): PasswordField
{ {
return $this->passwordField; return $this->passwordField;
} }
/** /**
* @return PasswordField * Get the FormField that represents the "confirm" password field
*/ */
public function getConfirmPasswordField() public function getConfirmPasswordField(): PasswordField
{ {
return $this->confirmPasswordfield; return $this->confirmPasswordfield;
} }
/** /**
* Set the minimum length required for passwords * Set the minimum length required for passwords
*
* @param int $minLength
* @return $this
*/ */
public function setMinLength($minLength) public function setMinLength(int $minLength): static
{ {
$this->minLength = (int) $minLength; $this->minLength = $minLength;
return $this; return $this;
} }
/** /**
* @return int * Get the minimum length required for passwords
*/ */
public function getMinLength() public function getMinLength(): int
{ {
return $this->minLength; return $this->minLength;
} }
/** /**
* Set the maximum length required for passwords * Set the maximum length required for passwords.
* * 0 means no max length.
* @param int $maxLength
* @return $this
*/ */
public function setMaxLength($maxLength) public function setMaxLength(int $maxLength): static
{ {
$this->maxLength = (int) $maxLength; $this->maxLength = $maxLength;
return $this; return $this;
} }
/** /**
* @return int * Get the maximum length required for passwords.
* 0 means no max length.
*/ */
public function getMaxLength() public function getMaxLength(): int
{ {
return $this->maxLength; return $this->maxLength;
} }
/** /**
* @param bool $requireStrongPassword * Set whether password strength validation is enforced.
* @return $this * See setMinPasswordStrength()
*/ */
public function setRequireStrongPassword($requireStrongPassword) public function setRequireStrongPassword($requireStrongPassword): static
{ {
$this->requireStrongPassword = (bool) $requireStrongPassword; $this->requireStrongPassword = (bool) $requireStrongPassword;
return $this; return $this;
} }
/** /**
* @return bool * Get whether password strength validation is enforced.
* See setMinPasswordStrength()
*/ */
public function getRequireStrongPassword() public function getRequireStrongPassword(): bool
{ {
return $this->requireStrongPassword; return $this->requireStrongPassword;
} }
/**
* Set minimum password strength. Only applies if requireStrongPassword is true
* See https://symfony.com/doc/current/reference/constraints/PasswordStrength.html#minscore
*/
public function setMinPasswordStrength(int $strength): static
{
$this->minPasswordStrength = $strength;
return $this;
}
public function getMinPasswordStrength(): int
{
return $this->minPasswordStrength;
}
/** /**
* Appends a warning to the right title, or removes that appended warning. * Appends a warning to the right title, or removes that appended warning.
*/ */

View File

@ -2,52 +2,40 @@
namespace SilverStripe\Forms; namespace SilverStripe\Forms;
use SilverStripe\Core\Validation\ConstraintValidator;
use Symfony\Component\Validator\Constraints\Email as EmailConstraint;
/** /**
* Text input field with validation for correct email format according to RFC 2822. * Text input field with validation for correct email format according to the relevant RFC.
*/ */
class EmailField extends TextField class EmailField extends TextField
{ {
protected $inputType = 'email'; protected $inputType = 'email';
/**
* {@inheritdoc}
*/
public function Type() public function Type()
{ {
return 'email text'; return 'email text';
} }
/** /**
* Validates for RFC 2822 compliant email addresses. * Validates for RFC compliant email addresses.
*
* @see http://www.regular-expressions.info/email.html
* @see http://www.ietf.org/rfc/rfc2822.txt
* *
* @param Validator $validator * @param Validator $validator
*
* @return string
*/ */
public function validate($validator) public function validate($validator)
{ {
$result = true;
$this->value = trim($this->value ?? ''); $this->value = trim($this->value ?? '');
$pattern = '^[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'; $message = _t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address');
$result = ConstraintValidator::validate(
$this->value,
new EmailConstraint(message: $message, mode: EmailConstraint::VALIDATION_MODE_STRICT),
$this->getName()
);
$validator->getResult()->combineAnd($result);
$isValid = $result->isValid();
// Escape delimiter characters. return $this->extendValidationResult($isValid, $validator);
$safePattern = str_replace('/', '\\/', $pattern ?? '');
if ($this->value && !preg_match('/' . $safePattern . '/i', $this->value ?? '')) {
$validator->validationError(
$this->name,
_t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address'),
'validation'
);
$result = false;
}
return $this->extendValidationResult($result, $validator);
} }
public function getSchemaValidation() public function getSchemaValidation()

View File

@ -7,10 +7,24 @@ use Symfony\Component\Validator\Constraints\Url;
/** /**
* Text input field with validation for a url * Text input field with validation for a url
* Url must include a scheme, either http:// or https:// * Url must include a protocol (aka scheme) such as https:// or http://
*/ */
class UrlField extends TextField class UrlField extends TextField
{ {
/**
* The default set of protocols allowed for valid URLs
*/
private static array $default_protocols = ['https', 'http'];
/**
* The default value for whether a relative protocol (// on its own) is allowed
*/
private static bool $default_allow_relative_protocol = false;
private array $protocols = [];
private ?bool $allowRelativeProtocol = null;
public function Type() public function Type()
{ {
return 'text url'; return 'text url';
@ -18,15 +32,64 @@ class UrlField extends TextField
public function validate($validator) public function validate($validator)
{ {
$result = true; $allowedProtocols = $this->getAllowedProtocols();
if ($this->value && !ConstraintValidator::validate($this->value, new Url())->isValid()) { $message = _t(
$validator->validationError( __CLASS__ . '.INVALID_WITH_PROTOCOL',
$this->name, 'Please enter a valid URL including a protocol, e.g {protocol}://example.com',
_t(__CLASS__ . '.INVALID', 'Please enter a valid URL'), ['protocol' => $allowedProtocols[0]]
'validation' );
); $result = ConstraintValidator::validate(
$result = false; $this->value,
new Url(
message: $message,
protocols: $allowedProtocols,
relativeProtocol: $this->getAllowRelativeProtocol()
),
$this->getName()
);
$validator->getResult()->combineAnd($result);
$isValid = $result->isValid();
return $this->extendValidationResult($isValid, $validator);
}
/**
* Set which protocols valid URLs are allowed to have
*/
public function setAllowedProtocols(array $protocols): static
{
// Ensure the array isn't associative so we can use 0 index in validate().
$this->protocols = array_keys($protocols);
return $this;
}
/**
* Get which protocols valid URLs are allowed to have
*/
public function getAllowedProtocols(): array
{
if (empty($this->protocols)) {
return static::config()->get('default_protocols');
} }
return $this->extendValidationResult($result, $validator); return $this->protocols;
}
/**
* Set whether a relative protocol (// on its own) is allowed
*/
public function setAllowRelativeProtocol(?bool $allow): static
{
$this->allowRelativeProtocol = $allow;
return $this;
}
/**
* Get whether a relative protocol (// on its own) is allowed
*/
public function getAllowRelativeProtocol(): bool
{
if ($this->allowRelativeProtocol === null) {
return static::config()->get('default_allow_relative_protocol');
}
return $this->allowRelativeProtocol;
} }
} }

View File

@ -45,7 +45,8 @@ use SilverStripe\Forms\FormField;
use SilverStripe\Forms\SearchableDropdownField; use SilverStripe\Forms\SearchableDropdownField;
use SilverStripe\Forms\SearchableMultiDropdownField; use SilverStripe\Forms\SearchableMultiDropdownField;
use SilverStripe\ORM\FieldType\DBForeignKey; use SilverStripe\ORM\FieldType\DBForeignKey;
use SilverStripe\Dev\Deprecation; use SilverStripe\Security\Validation\PasswordValidator;
use SilverStripe\Security\Validation\RulesPasswordValidator;
/** /**
* The member class which represents the users of the system * The member class which represents the users of the system
@ -380,10 +381,8 @@ class Member extends DataObject
/** /**
* Set a {@link PasswordValidator} object to use to validate member's passwords. * Set a {@link PasswordValidator} object to use to validate member's passwords.
*
* @param PasswordValidator $validator
*/ */
public static function set_password_validator(PasswordValidator $validator = null) public static function set_password_validator(?PasswordValidator $validator = null)
{ {
// Override existing config // Override existing config
Config::modify()->remove(Injector::class, PasswordValidator::class); Config::modify()->remove(Injector::class, PasswordValidator::class);
@ -396,13 +395,11 @@ class Member extends DataObject
/** /**
* Returns the default {@link PasswordValidator} * Returns the default {@link PasswordValidator}
*
* @return PasswordValidator|null
*/ */
public static function password_validator() public static function password_validator(): ?PasswordValidator
{ {
if (Injector::inst()->has(PasswordValidator::class)) { if (Injector::inst()->has(PasswordValidator::class)) {
return Deprecation::withSuppressedNotice(fn() => Injector::inst()->get(PasswordValidator::class)); return Injector::inst()->get(PasswordValidator::class);
} }
return null; return null;
} }
@ -1779,11 +1776,16 @@ class Member extends DataObject
{ {
$password = ''; $password = '';
$validator = Member::password_validator(); $validator = Member::password_validator();
if ($length && $validator && $length < $validator->getMinLength()) { if ($validator instanceof RulesPasswordValidator) {
throw new InvalidArgumentException('length argument is less than password validator minLength'); $validatorMinLength = $validator->getMinLength();
if ($length && $length < $validatorMinLength) {
throw new InvalidArgumentException('length argument is less than password validator minLength');
}
} else {
// Make sure the password is long enough to beat even very strict entropy tests
$validatorMinLength = 128;
} }
$validatorMinLength = $validator ? $validator->getMinLength() : 0; $len = max($length, $validatorMinLength, 20);
$len = $length ?: max($validatorMinLength, 20);
// The default PasswordValidator checks the password includes the following four character sets // The default PasswordValidator checks the password includes the following four character sets
$charsets = [ $charsets = [
'abcdefghijklmnopqrstuvwyxz', 'abcdefghijklmnopqrstuvwyxz',

View File

@ -0,0 +1,33 @@
<?php
namespace SilverStripe\Security\Validation;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Validation\ConstraintValidator;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\Member;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\PasswordStrength;
/**
* Validates passwords based on entropy.
*/
class EntropyPasswordValidator extends PasswordValidator
{
use Extensible;
/**
* 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;
public function validate(string $password, Member $member): ValidationResult
{
$minScore = static::config()->get('password_strength');
$result = ConstraintValidator::validate($password, [new PasswordStrength(minScore: $minScore), new NotBlank()]);
$result->combineAnd(parent::validate($password, $member));
$this->extend('updateValidatePassword', $password, $member, $result, $this);
return $result;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace SilverStripe\Security\Validation;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\ORM\DataObject;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\Member;
use SilverStripe\Security\MemberPassword;
/**
* Abstract validator with functionality for checking for reusing old passwords.
*/
abstract class PasswordValidator
{
use Injectable;
use Configurable;
/**
* Default number of previous passwords to check for a reusing old passwords.
*/
private static int $historic_count = 6;
protected ?int $historicalPasswordCount = null;
public function validate(string $password, Member $member): ValidationResult
{
$result = ValidationResult::create();
$historicCount = $this->getHistoricCount();
if ($historicCount) {
$idColumn = DataObject::getSchema()->sqlColumnForField(MemberPassword::class, 'MemberID');
$previousPasswords = MemberPassword::get()
->where([$idColumn => $member->ID])
->sort(['Created' => 'DESC', 'ID' => 'DESC'])
->limit($historicCount);
foreach ($previousPasswords as $previousPassword) {
if ($previousPassword->checkPassword($password)) {
$error = _t(
PasswordValidator::class . '.PREVPASSWORD',
'You\'ve already used that password in the past, please choose a new password'
);
$result->addError($error, 'bad', 'PREVIOUS_PASSWORD');
break;
}
}
}
return $result;
}
/**
* Get the number of previous passwords to check for a reusing old passwords.
*/
public function getHistoricCount(): int
{
if ($this->historicalPasswordCount !== null) {
return $this->historicalPasswordCount;
}
return $this->config()->get('historic_count') ?? 0;
}
/**
* Set the number of previous passwords to check for a reusing old passwords.
*/
public function setHistoricCount(int $count): static
{
$this->historicalPasswordCount = $count;
return $this;
}
}

View File

@ -1,39 +1,32 @@
<?php <?php
namespace SilverStripe\Security; namespace SilverStripe\Security\Validation;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extensible; use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Dev\Deprecation; use SilverStripe\Security\Member;
/** /**
* This class represents a validator for member passwords. * Validates passwords based on a set of regex rules about what the password must contain.
* *
* <code> * <code>
* $pwdVal = new PasswordValidator(); * $pwdVal = new PasswordValidator();
* $pwdValidator->setMinLength(7); * $pwdValidator->setMinLength(7);
* $pwdValidator->checkHistoricalPasswords(6); * $pwdValidator->setHistoricCount(6);
* $pwdValidator->setMinTestScore(3); * $pwdValidator->setMinTestScore(3);
* $pwdValidator->setTestNames(array("lowercase", "uppercase", "digits", "punctuation")); * $pwdValidator->setTestNames(array("lowercase", "uppercase", "digits", "punctuation"));
* *
* Member::set_password_validator($pwdValidator); * Member::set_password_validator($pwdValidator);
* </code> * </code>
*
* @deprecated 5.4.0 Will be renamed to SilverStripe\Security\Validation\RulesPasswordValidator
*/ */
class PasswordValidator class RulesPasswordValidator extends PasswordValidator
{ {
use Injectable;
use Configurable;
use Extensible; use Extensible;
/** /**
* @config * Regex to test the password against. See min_test_score.
* @var array
*/ */
private static $character_strength_tests = [ private static array $character_strength_tests = [
'lowercase' => '/[a-z]/', 'lowercase' => '/[a-z]/',
'uppercase' => '/[A-Z]/', 'uppercase' => '/[A-Z]/',
'digits' => '/[0-9]/', 'digits' => '/[0-9]/',
@ -41,89 +34,62 @@ class PasswordValidator
]; ];
/** /**
* @config * Default minimum number of characters for a valid password.
* @var int
*/ */
private static $min_length = null; private static int $min_length = 8;
/** /**
* @config * Default minimum test score for a valid password.
* @var int * The test score is the number of character_strength_tests that the password matches.
*/ */
private static $min_test_score = null; private static int $min_test_score = 0;
/** protected ?int $minLength = null;
* @config
* @var int
*/
private static $historic_count = null;
/** protected ?int $minScore = null;
* @var int
*/
protected $minLength = null;
/**
* @var int
*/
protected $minScore = null;
/** /**
* @var string[] * @var string[]
*/ */
protected $testNames = null; protected ?array $testNames = null;
/** /**
* @var int * Get the minimum number of characters for a valid password.
*/ */
protected $historicalPasswordCount = null; public function getMinLength(): int
public function __construct()
{
Deprecation::notice(
'5.4.0',
'Will be renamed to SilverStripe\Security\Validation\RulesPasswordValidator',
Deprecation::SCOPE_CLASS
);
}
/**
* @return int
*/
public function getMinLength()
{ {
if ($this->minLength !== null) { if ($this->minLength !== null) {
return $this->minLength; return $this->minLength;
} }
return $this->config()->get('min_length'); return $this->config()->get('min_length') ?? 0;
} }
/** /**
* @param int $minLength * Set the minimum number of characters for a valid password.
* @return $this
*/ */
public function setMinLength($minLength) public function setMinLength(int $minLength): static
{ {
$this->minLength = $minLength; $this->minLength = $minLength;
return $this; return $this;
} }
/** /**
* @return integer * Get the minimum test score for a valid password.
* The test score is the number of character_strength_tests that the password matches.
*/ */
public function getMinTestScore() public function getMinTestScore(): int
{ {
if ($this->minScore !== null) { if ($this->minScore !== null) {
return $this->minScore; return $this->minScore;
} }
return $this->config()->get('min_test_score'); return $this->config()->get('min_test_score') ?? 0;
} }
/** /**
* @param int $minScore * Set the minimum test score for a valid password.
* @return $this * The test score is the number of character_strength_tests that the password matches.
*/ */
public function setMinTestScore($minScore) public function setMinTestScore(int $minScore): static
{ {
$this->minScore = $minScore; $this->minScore = $minScore;
return $this; return $this;
@ -134,7 +100,7 @@ class PasswordValidator
* *
* @return string[] * @return string[]
*/ */
public function getTestNames() public function getTestNames(): array
{ {
if ($this->testNames !== null) { if ($this->testNames !== null) {
return $this->testNames; return $this->testNames;
@ -146,51 +112,22 @@ class PasswordValidator
* Set list of tests to use for this validator * Set list of tests to use for this validator
* *
* @param string[] $testNames * @param string[] $testNames
* @return $this
*/ */
public function setTestNames($testNames) public function setTestNames(array $testNames): static
{ {
$this->testNames = $testNames; $this->testNames = $testNames;
return $this; return $this;
} }
/**
* @return int
*/
public function getHistoricCount()
{
if ($this->historicalPasswordCount !== null) {
return $this->historicalPasswordCount;
}
return $this->config()->get('historic_count');
}
/**
* @param int $count
* @return $this
*/
public function setHistoricCount($count)
{
$this->historicalPasswordCount = $count;
return $this;
}
/** /**
* Gets all possible tests * Gets all possible tests
*
* @return array
*/ */
public function getTests() public function getTests(): array
{ {
return $this->config()->get('character_strength_tests'); return $this->config()->get('character_strength_tests');
} }
/** public function validate(string $password, Member $member): ValidationResult
* @param string $password
* @param Member $member
* @return ValidationResult
*/
public function validate($password, $member)
{ {
$valid = ValidationResult::create(); $valid = ValidationResult::create();
@ -234,23 +171,7 @@ class PasswordValidator
} }
} }
$historicCount = $this->getHistoricCount(); $valid->combineAnd(parent::validate($password, $member));
if ($historicCount) {
$previousPasswords = MemberPassword::get()
->where(['"MemberPassword"."MemberID"' => $member->ID])
->sort('"Created" DESC, "ID" DESC')
->limit($historicCount);
foreach ($previousPasswords as $previousPassword) {
if ($previousPassword->checkPassword($password)) {
$error = _t(
__CLASS__ . '.PREVPASSWORD',
'You\'ve already used that password in the past, please choose a new password'
);
$valid->addError($error, 'bad', 'PREVIOUS_PASSWORD');
break;
}
}
}
$this->extend('updateValidatePassword', $password, $member, $valid, $this); $this->extend('updateValidatePassword', $password, $member, $valid, $this);

View File

@ -11,11 +11,9 @@ use SilverStripe\Forms\Form;
use SilverStripe\Forms\ReadonlyField; 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\View\SSViewer; use SilverStripe\View\SSViewer;
use Closure; use Closure;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\Deprecation;
class ConfirmedPasswordFieldTest extends SapphireTest class ConfirmedPasswordFieldTest extends SapphireTest
{ {
@ -25,11 +23,7 @@ class ConfirmedPasswordFieldTest extends SapphireTest
{ {
parent::setUp(); parent::setUp();
Deprecation::withSuppressedNotice( Member::set_password_validator(null);
fn() => PasswordValidator::singleton()
->setMinLength(0)
->setTestNames([])
);
} }
public function testSetValue() public function testSetValue()
@ -217,10 +211,10 @@ class ConfirmedPasswordFieldTest extends SapphireTest
return [ return [
'valid: within min and max' => [3, 8, true], 'valid: within min and max' => [3, 8, true],
'invalid: lower than min with max' => [8, 12, false, 'Passwords must be 8 to 12 characters long'], 'invalid: lower than min with max' => [8, 12, false, 'Passwords must be 8 to 12 characters long'],
'valid: greater than min' => [3, null, true], 'valid: greater than min' => [3, 0, true],
'invalid: lower than min' => [8, null, false, 'Passwords must be at least 8 characters long'], 'invalid: lower than min' => [8, 0, false, 'Passwords must be at least 8 characters long'],
'valid: less than max' => [null, 8, true], 'valid: less than max' => [0, 8, true],
'invalid: greater than max' => [null, 4, false, 'Passwords must be at most 4 characters long'], 'invalid: greater than max' => [0, 4, false, 'Passwords must be at most 4 characters long'],
]; ];
} }
@ -239,7 +233,7 @@ class ConfirmedPasswordFieldTest extends SapphireTest
$this->assertFalse($result, 'Validate method should return its result'); $this->assertFalse($result, 'Validate method should return its result');
$this->assertFalse($validator->getResult()->isValid()); $this->assertFalse($validator->getResult()->isValid());
$this->assertStringContainsString( $this->assertStringContainsString(
'Passwords must have at least one digit and one alphanumeric character', 'The password strength is too low. Please use a stronger password.',
json_encode($validator->getResult()->__serialize()) json_encode($validator->getResult()->__serialize())
); );
} }

View File

@ -4,9 +4,7 @@ namespace SilverStripe\Forms\Tests;
use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Forms\EmailField; use SilverStripe\Forms\EmailField;
use Exception; use SilverStripe\Forms\FieldsValidator;
use PHPUnit\Framework\AssertionFailedError;
use SilverStripe\Forms\Tests\EmailFieldTest\TestValidator;
class EmailFieldTest extends FunctionalTest class EmailFieldTest extends FunctionalTest
{ {
@ -40,21 +38,14 @@ class EmailFieldTest extends FunctionalTest
$field = new EmailField("MyEmail"); $field = new EmailField("MyEmail");
$field->setValue($email); $field->setValue($email);
$val = new TestValidator(); if ($expectSuccess) {
try { $message = $checkText . " (/$email/ did not pass validation, but was expected to)";
$field->validate($val); } else {
// If we expect failure and processing gets here without an exception, the test failed $message = $checkText . " (/$email/ passed validation, but not expected to)";
$this->assertTrue($expectSuccess, $checkText . " (/$email/ passed validation, but not expected to)");
} catch (Exception $e) {
if ($e instanceof AssertionFailedError) {
// re-throw assertion failure
throw $e;
} elseif ($expectSuccess) {
$this->fail(
$checkText . ": " . $e->getMessage() . " (/$email/ did not pass validation, but was expected to)"
);
}
} }
$result = $field->validate(new FieldsValidator());
$this->assertSame($expectSuccess, $result, $message);
} }
/** /**

View File

@ -1,27 +0,0 @@
<?php
namespace SilverStripe\Forms\Tests\EmailFieldTest;
use Exception;
use SilverStripe\Forms\Validator;
use SilverStripe\Core\Validation\ValidationResult;
class TestValidator extends Validator
{
public function validationError(
$fieldName,
$message,
$messageType = ValidationResult::TYPE_ERROR,
$cast = ValidationResult::CAST_TEXT
) {
throw new Exception($message);
}
public function javascript()
{
}
public function php($data)
{
}
}

View File

@ -6,7 +6,6 @@ use SilverStripe\Control\Controller;
use SilverStripe\Control\NullHTTPRequest; use SilverStripe\Control\NullHTTPRequest;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Core\Validation\ValidationResult;
@ -19,7 +18,6 @@ use SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm; use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm;
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\MemberLoginForm; use SilverStripe\Security\MemberAuthenticator\MemberLoginForm;
use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
class MemberAuthenticatorTest extends SapphireTest class MemberAuthenticatorTest extends SapphireTest
@ -44,12 +42,8 @@ class MemberAuthenticatorTest extends SapphireTest
} }
DefaultAdminService::setDefaultAdmin('admin', 'password'); DefaultAdminService::setDefaultAdmin('admin', 'password');
// Enforce dummy validation (this can otherwise be influenced by recipe config) // Enforce no password validation
Deprecation::withSuppressedNotice( Member::set_password_validator(null);
fn() => PasswordValidator::singleton()
->setMinLength(0)
->setTestNames([])
);
} }
protected function tearDown(): void protected function tearDown(): void

View File

@ -2,12 +2,10 @@
namespace SilverStripe\Security\Tests; namespace SilverStripe\Security\Tests;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\Security\Group; use SilverStripe\Security\Group;
use SilverStripe\Security\MemberCsvBulkLoader; use SilverStripe\Security\MemberCsvBulkLoader;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
@ -19,12 +17,7 @@ class MemberCsvBulkLoaderTest extends SapphireTest
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
Member::set_password_validator(null);
Deprecation::withSuppressedNotice(
fn() => PasswordValidator::singleton()
->setMinLength(0)
->setTestNames([])
);
} }
public function testNewImport() public function testNewImport()

View File

@ -8,7 +8,6 @@ use SilverStripe\Control\Cookie;
use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert; use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\FunctionalTest;
use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FieldList;
@ -29,7 +28,6 @@ use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler; use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler;
use SilverStripe\Security\MemberPassword; use SilverStripe\Security\MemberPassword;
use SilverStripe\Security\PasswordEncryptor_Blowfish; use SilverStripe\Security\PasswordEncryptor_Blowfish;
use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Permission; use SilverStripe\Security\Permission;
use SilverStripe\Security\RememberLoginHash; use SilverStripe\Security\RememberLoginHash;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
@ -37,6 +35,8 @@ use SilverStripe\Security\Tests\MemberTest\FieldsExtension;
use SilverStripe\SessionManager\Models\LoginSession; use SilverStripe\SessionManager\Models\LoginSession;
use ReflectionMethod; use ReflectionMethod;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Security\Validation\EntropyPasswordValidator;
use SilverStripe\Security\Validation\RulesPasswordValidator;
class MemberTest extends FunctionalTest class MemberTest extends FunctionalTest
{ {
@ -75,12 +75,6 @@ class MemberTest extends FunctionalTest
Member::config()->set('unique_identifier_field', 'Email'); Member::config()->set('unique_identifier_field', 'Email');
Deprecation::withSuppressedNotice(
fn() => PasswordValidator::singleton()
->setMinLength(0)
->setTestNames([])
);
i18n::set_locale('en_US'); i18n::set_locale('en_US');
} }
@ -1743,7 +1737,7 @@ class MemberTest extends FunctionalTest
public function testChangePasswordOnlyValidatesPlaintext() public function testChangePasswordOnlyValidatesPlaintext()
{ {
// This validator requires passwords to be 17 characters long // This validator requires passwords to be 17 characters long
Member::set_password_validator(Deprecation::withSuppressedNotice(fn() => new MemberTest\VerySpecificPasswordValidator())); Member::set_password_validator(new MemberTest\VerySpecificPasswordValidator());
// This algorithm will never return a 17 character hash // This algorithm will never return a 17 character hash
Security::config()->set('password_encryption_algorithm', 'blowfish'); Security::config()->set('password_encryption_algorithm', 'blowfish');
@ -1770,11 +1764,23 @@ class MemberTest extends FunctionalTest
$this->assertNotNull(Member::get()->find('Email', 'trimmed@test.com')); $this->assertNotNull(Member::get()->find('Email', 'trimmed@test.com'));
} }
public function testChangePasswordToBlankIsValidated() public static function provideChangePasswordToBlankIsValidated(): array
{ {
Member::set_password_validator(Deprecation::withSuppressedNotice(fn() => new PasswordValidator())); return [
// override setup() function which setMinLength(0) [
PasswordValidator::singleton()->setMinLength(8); 'validatorClass' => RulesPasswordValidator::class,
],
[
'validatorClass' => EntropyPasswordValidator::class,
],
];
}
#[DataProvider('provideChangePasswordToBlankIsValidated')]
public function testChangePasswordToBlankIsValidated(string $validatorClass): void
{
$validator = new $validatorClass();
Member::set_password_validator($validator);
// 'test' member has a password defined in yml // 'test' member has a password defined in yml
$member = $this->objFromFixture(Member::class, 'test'); $member = $this->objFromFixture(Member::class, 'test');
$result = $member->changePassword(''); $result = $member->changePassword('');
@ -1896,34 +1902,57 @@ class MemberTest extends FunctionalTest
]; ];
} }
public function testGenerateRandomPassword() public static function provideGenerateRandomPassword(): array
{
return [
[
'validatorClass' => RulesPasswordValidator::class,
],
[
'validatorClass' => EntropyPasswordValidator::class,
],
];
}
#[DataProvider('provideGenerateRandomPassword')]
public function testGenerateRandomPassword(string $validatorClass): void
{ {
$member = new Member(); $member = new Member();
// no password validator // no password validator
Member::set_password_validator(null); Member::set_password_validator(null);
// password length is same as length argument // password length is min 128 chars long
$password = $member->generateRandomPassword(5); $password = $member->generateRandomPassword(5);
$this->assertSame(5, strlen($password)); $this->assertSame(128, strlen($password));
// default to 20 if not length argument // password length can be longer
$password = $member->generateRandomPassword(); $password = $member->generateRandomPassword(130);
$this->assertSame(20, strlen($password)); $this->assertSame(130, strlen($password));
// password validator // password validator
$validator = Deprecation::withSuppressedNotice(fn() => new PasswordValidator()); $validator = new $validatorClass();
Member::set_password_validator($validator); Member::set_password_validator($validator);
// Password length of 20 even if validator minLength is less than 20 if ($validator instanceof RulesPasswordValidator) {
$validator->setMinLength(10); // Password length of 20 even if validator minLength is less than 20
$validator->setMinLength(10);
$minLengthInMember = 20;
} else {
$minLengthInMember = 128;
}
$password = $member->generateRandomPassword(); $password = $member->generateRandomPassword();
$this->assertSame(20, strlen($password)); $this->assertSame($minLengthInMember, strlen($password));
// Password length of 25 if passing length argument, and validator minlength is less than length argument // Password length of 256 if passing length argument, and validator minlength is less than length argument
$password = $member->generateRandomPassword(25); $password = $member->generateRandomPassword(256);
$this->assertSame(25, strlen($password)); $this->assertSame(256, strlen($password));
// Password length is validator minLength if validator minLength is greater than 20 and no length argument if ($validator instanceof RulesPasswordValidator) {
$validator->setMinLength(30); // Password length is validator minLength if validator minLength is greater than 20 and no length argument
$password = $member->generateRandomPassword(); $validator->setMinLength(30);
$this->assertSame(30, strlen($password)); $password = $member->generateRandomPassword();
// Exception throw if length argument is less than validator minLength $this->assertSame(30, strlen($password));
$this->expectException(InvalidArgumentException::class); // Exception throw if length argument is less than validator minLength
$this->expectExceptionMessage('length argument is less than password validator minLength'); $this->expectException(InvalidArgumentException::class);
$password = $member->generateRandomPassword(15); $this->expectExceptionMessage('length argument is less than password validator minLength');
$password = $member->generateRandomPassword(15);
} else {
// No exception for entropy validator
$password = $member->generateRandomPassword(15);
}
} }
} }

View File

@ -3,9 +3,9 @@
namespace SilverStripe\Security\Tests\MemberTest; namespace SilverStripe\Security\Tests\MemberTest;
use SilverStripe\Dev\TestOnly; use SilverStripe\Dev\TestOnly;
use SilverStripe\Security\PasswordValidator; use SilverStripe\Security\Validation\RulesPasswordValidator;
class TestPasswordValidator extends PasswordValidator implements TestOnly class TestPasswordValidator extends RulesPasswordValidator implements TestOnly
{ {
public function __construct() public function __construct()
{ {

View File

@ -4,11 +4,12 @@ namespace SilverStripe\Security\Tests\MemberTest;
use SilverStripe\Dev\TestOnly; use SilverStripe\Dev\TestOnly;
use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\PasswordValidator; use SilverStripe\Security\Member;
use SilverStripe\Security\Validation\PasswordValidator;
class VerySpecificPasswordValidator extends PasswordValidator implements TestOnly class VerySpecificPasswordValidator extends PasswordValidator implements TestOnly
{ {
public function validate($password, $member) public function validate(string $password, Member $member): ValidationResult
{ {
$result = ValidationResult::create(); $result = ValidationResult::create();
if (strlen($password ?? '') !== 17) { if (strlen($password ?? '') !== 17) {

View File

@ -22,7 +22,6 @@ use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\LoginAttempt; use SilverStripe\Security\LoginAttempt;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Security; use SilverStripe\Security\Security;
use SilverStripe\Security\SecurityToken; use SilverStripe\Security\SecurityToken;
@ -60,11 +59,6 @@ class SecurityTest extends FunctionalTest
Member::config()->set('unique_identifier_field', 'Email'); Member::config()->set('unique_identifier_field', 'Email');
PasswordValidator::config()
->remove('min_length')
->remove('historic_count')
->remove('min_test_score');
Member::set_password_validator(null); Member::set_password_validator(null);
parent::setUp(); parent::setUp();

View File

@ -0,0 +1,42 @@
<?php
namespace SilverStripe\Security\Tests\Validation;
use PHPUnit\Framework\Attributes\DataProvider;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Member;
use SilverStripe\Security\Validation\EntropyPasswordValidator;
/**
* EntropyPasswordValidator uses a third-party for its validation so we don't need rigorous testing here.
* Just test that stupid simple passwords don't pass, and complex ones do.
*/
class EntropyPasswordValidatorTest extends SapphireTest
{
protected $usesDatabase = false;
public static function provideValidate(): array
{
return [
[
'password' => '',
'expected' => false,
],
[
'password' => 'password123',
'expected' => false,
],
[
'password' => 'This is a really long and complex PASSWORD',
'expected' => true,
],
];
}
#[DataProvider('provideValidate')]
public function testValidate(string $password, bool $expected): void
{
$validator = new EntropyPasswordValidator();
$this->assertSame($expected, $validator->validate($password, new Member())->isValid());
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace SilverStripe\Security\Tests\Validation;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Member;
use SilverStripe\Security\Tests\Validation\RulesPasswordValidatorTest\DummyPasswordValidator;
class PasswordValidatorTest extends SapphireTest
{
/**
* {@inheritDoc}
* @var bool
*/
protected $usesDatabase = true;
public function testValidate()
{
$validator = new DummyPasswordValidator;
$validator->setHistoricCount(3);
Member::set_password_validator($validator);
$member = new Member;
$member->FirstName = 'Repeat';
$member->Surname = 'Password-Man';
$member->Password = 'honk';
$member->write();
// Create a set of used passwords
$member->changePassword('foobar');
$member->changePassword('foobaz');
$member->changePassword('barbaz');
$this->assertFalse($member->changePassword('barbaz')->isValid());
$this->assertFalse($member->changePassword('foobaz')->isValid());
$this->assertFalse($member->changePassword('foobar')->isValid());
$this->assertTrue($member->changePassword('honk')->isValid());
$this->assertTrue($member->changePassword('newpassword')->isValid());
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace SilverStripe\Security\Tests\Validation\RulesPasswordValidatorTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\Security\Validation\PasswordValidator;
class DummyPasswordValidator extends PasswordValidator implements TestOnly
{
// no-op, just need a concrete class instead of an abstract one.
}

View File

@ -1,25 +1,20 @@
<?php <?php
namespace SilverStripe\Security\Tests; namespace SilverStripe\Security\Tests\Validation;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\PasswordValidator; use SilverStripe\Security\Validation\RulesPasswordValidator;
class PasswordValidatorTest extends SapphireTest class RulesPasswordValidatorTest extends SapphireTest
{ {
/** protected $usesDatabase = false;
* {@inheritDoc}
* @var bool
*/
protected $usesDatabase = true;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
PasswordValidator::config() RulesPasswordValidator::config()
->remove('min_length') ->remove('min_length')
->remove('historic_count') ->remove('historic_count')
->set('min_test_score', 0); ->set('min_test_score', 0);
@ -27,7 +22,7 @@ class PasswordValidatorTest extends SapphireTest
public function testValidate() public function testValidate()
{ {
$v = Deprecation::withSuppressedNotice(fn() => new PasswordValidator()); $v = new RulesPasswordValidator();
$r = $v->validate('', new Member()); $r = $v->validate('', new Member());
$this->assertTrue($r->isValid(), 'Empty password is valid by default'); $this->assertTrue($r->isValid(), 'Empty password is valid by default');
@ -37,7 +32,7 @@ class PasswordValidatorTest extends SapphireTest
public function testValidateMinLength() public function testValidateMinLength()
{ {
$v = Deprecation::withSuppressedNotice(fn() => new PasswordValidator()); $v = new RulesPasswordValidator();
$v->setMinLength(4); $v->setMinLength(4);
$r = $v->validate('123', new Member()); $r = $v->validate('123', new Member());
@ -51,7 +46,7 @@ class PasswordValidatorTest extends SapphireTest
public function testValidateMinScore() public function testValidateMinScore()
{ {
// Set both score and set of tests // Set both score and set of tests
$v = Deprecation::withSuppressedNotice(fn() => new PasswordValidator()); $v = new RulesPasswordValidator();
$v->setMinTestScore(3); $v->setMinTestScore(3);
$v->setTestNames(["lowercase", "uppercase", "digits", "punctuation"]); $v->setTestNames(["lowercase", "uppercase", "digits", "punctuation"]);
@ -62,7 +57,7 @@ class PasswordValidatorTest extends SapphireTest
$this->assertTrue($r->isValid(), 'Passing enough tests'); $this->assertTrue($r->isValid(), 'Passing enough tests');
// Ensure min score without tests works (uses default tests) // Ensure min score without tests works (uses default tests)
$v = Deprecation::withSuppressedNotice(fn() => new PasswordValidator()); $v = new RulesPasswordValidator();
$v->setMinTestScore(3); $v->setMinTestScore(3);
$r = $v->validate('aA', new Member()); $r = $v->validate('aA', new Member());
@ -76,31 +71,4 @@ class PasswordValidatorTest extends SapphireTest
$r = $v->validate('aA1!', new Member()); $r = $v->validate('aA1!', new Member());
$this->assertTrue($r->isValid(), 'Passing enough tests'); $this->assertTrue($r->isValid(), 'Passing enough tests');
} }
/**
* Test that a certain number of historical passwords are checked if specified
*/
public function testHistoricalPasswordCount()
{
$validator = Deprecation::withSuppressedNotice(fn() => new PasswordValidator);
$validator->setHistoricCount(3);
Member::set_password_validator($validator);
$member = new Member;
$member->FirstName = 'Repeat';
$member->Surname = 'Password-Man';
$member->Password = 'honk';
$member->write();
// Create a set of used passwords
$member->changePassword('foobar');
$member->changePassword('foobaz');
$member->changePassword('barbaz');
$this->assertFalse($member->changePassword('barbaz')->isValid());
$this->assertFalse($member->changePassword('foobaz')->isValid());
$this->assertFalse($member->changePassword('foobar')->isValid());
$this->assertTrue($member->changePassword('honk')->isValid());
$this->assertTrue($member->changePassword('newpassword')->isValid());
}
} }

View File

@ -3,24 +3,11 @@
namespace SilverStripe\Security\Tests; namespace SilverStripe\Security\Tests;
use SilverStripe\Control\Controller; use SilverStripe\Control\Controller;
use SilverStripe\Control\NullHTTPRequest;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Security\Authenticator;
use SilverStripe\Security\DefaultAdminService;
use SilverStripe\Security\IdentityStore;
use SilverStripe\Security\LoginAttempt;
use SilverStripe\Security\Member; use SilverStripe\Security\Member;
use SilverStripe\Security\MemberAuthenticator\CMSMemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm;
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator; use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\MemberLoginForm;
use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Security;
use SilverStripe\Versioned\Versioned; use SilverStripe\Versioned\Versioned;
class VersionedMemberAuthenticatorTest extends SapphireTest class VersionedMemberAuthenticatorTest extends SapphireTest
@ -43,12 +30,8 @@ class VersionedMemberAuthenticatorTest extends SapphireTest
return; return;
} }
// Enforce dummy validation (this can otherwise be influenced by recipe config) // Remove password validation
Deprecation::withSuppressedNotice( Member::set_password_validator(null);
fn() => PasswordValidator::singleton()
->setMinLength(0)
->setTestNames([])
);
} }
protected function tearDown(): void protected function tearDown(): void