BUG Prevent session hijackers from resetting a user password

BUG Member::checkPassword incorrect for default admin
This commit is contained in:
Damian Mooyman 2016-05-13 16:23:38 +12:00
parent 8ebdedf330
commit 4d1ddf0e62
No known key found for this signature in database
GPG Key ID: 78B823A10DE27D1A
3 changed files with 183 additions and 40 deletions

View File

@ -54,13 +54,28 @@ class ConfirmedPasswordField extends FormField {
*/ */
protected $showOnClick = false; protected $showOnClick = false;
/**
* Check if the existing password should be entered first
*
* @var bool
*/
protected $requireExistingPassword = false;
/** /**
* A place to temporarly store the confirm password value * A place to temporarily store the confirm password value
*
* @var string * @var string
*/ */
protected $confirmValue; protected $confirmValue;
/**
* Store value of "Current Password" field
*
* @var string
*/
protected $currentPasswordValue;
/** /**
* Title for the link that triggers the visibility of password fields. * Title for the link that triggers the visibility of password fields.
* *
@ -105,6 +120,7 @@ class ConfirmedPasswordField extends FormField {
// disable auto complete // disable auto complete
foreach($this->children as $child) { foreach($this->children as $child) {
/** @var FormField $child */
$child->setAttribute('autocomplete', 'off'); $child->setAttribute('autocomplete', 'off');
} }
@ -113,7 +129,7 @@ class ConfirmedPasswordField extends FormField {
// we have labels for the subfields // we have labels for the subfields
$title = false; $title = false;
parent::__construct($name, $title, null, $form); parent::__construct($name, $title);
$this->setValue($value); $this->setValue($value);
} }
@ -147,6 +163,7 @@ class ConfirmedPasswordField extends FormField {
} }
foreach($this->children as $field) { foreach($this->children as $field) {
/** @var FormField $field */
$field->setDisabled($this->isDisabled()); $field->setDisabled($this->isDisabled());
$field->setReadonly($this->isReadonly()); $field->setReadonly($this->isReadonly());
@ -220,6 +237,7 @@ class ConfirmedPasswordField extends FormField {
*/ */
public function setRightTitle($title) { public function setRightTitle($title) {
foreach($this->children as $field) { foreach($this->children as $field) {
/** @var FormField $field */
$field->setRightTitle($title); $field->setRightTitle($title);
} }
@ -227,15 +245,20 @@ class ConfirmedPasswordField extends FormField {
} }
/** /**
* @param array $titles 2 entry array with the customized title for each * Set child field titles. Titles in order should be:
* of the 2 children. * - "Current Password" (if getRequireExistingPassword() is set)
* - "Password"
* - "Confirm Password"
* *
* @return ConfirmedPasswordField * @param array $titles List of child titles
* @return $this
*/ */
public function setChildrenTitles($titles) { public function setChildrenTitles($titles) {
if(is_array($titles) && count($titles) == 2) { $expectedChildren = $this->getRequireExistingPassword() ? 3 : 2;
if(is_array($titles) && count($titles) == $expectedChildren) {
foreach($this->children as $field) { foreach($this->children as $field) {
if(isset($titles[0])) { if(isset($titles[0])) {
/** @var FormField $field */
$field->setTitle($titles[0]); $field->setTitle($titles[0]);
array_shift($titles); array_shift($titles);
@ -251,8 +274,8 @@ class ConfirmedPasswordField extends FormField {
* to handle both cases. * to handle both cases.
* *
* @param mixed $value * @param mixed $value
* * @param mixed $data
* @return ConfirmedPasswordField * @return $this
*/ */
public function setValue($value, $data = null) { public function setValue($value, $data = null) {
// If $data is a DataObject, don't use the value, since it's a hashed value // If $data is a DataObject, don't use the value, since it's a hashed value
@ -264,6 +287,9 @@ class ConfirmedPasswordField extends FormField {
if(is_array($value)) { if(is_array($value)) {
$this->value = $value['_Password']; $this->value = $value['_Password'];
$this->confirmValue = $value['_ConfirmPassword']; $this->confirmValue = $value['_ConfirmPassword'];
$this->currentPasswordValue = ($this->getRequireExistingPassword() && isset($value['_CurrentPassword']))
? $value['_CurrentPassword']
: null;
if($this->showOnClick && isset($value['_PasswordFieldVisible'])) { if($this->showOnClick && isset($value['_PasswordFieldVisible'])) {
$this->children->fieldByName($this->getName() . '[_PasswordFieldVisible]') $this->children->fieldByName($this->getName() . '[_PasswordFieldVisible]')
@ -292,6 +318,7 @@ class ConfirmedPasswordField extends FormField {
* Update the names of the child fields when updating name of field. * Update the names of the child fields when updating name of field.
* *
* @param string $name new name to give to the field. * @param string $name new name to give to the field.
* @return $this
*/ */
public function setName($name) { public function setName($name) {
$this->children->fieldByName($this->getName() . '[_Password]') $this->children->fieldByName($this->getName() . '[_Password]')
@ -340,8 +367,7 @@ class ConfirmedPasswordField extends FormField {
$validator->validationError( $validator->validationError(
$name, $name,
_t('Form.VALIDATIONPASSWORDSDONTMATCH',"Passwords don't match"), _t('Form.VALIDATIONPASSWORDSDONTMATCH',"Passwords don't match"),
"validation", "validation"
false
); );
return false; return false;
@ -353,8 +379,7 @@ class ConfirmedPasswordField extends FormField {
$validator->validationError( $validator->validationError(
$name, $name,
_t('Form.VALIDATIONPASSWORDSNOTEMPTY', "Passwords can't be empty"), _t('Form.VALIDATIONPASSWORDSNOTEMPTY', "Passwords can't be empty"),
"validation", "validation"
false
); );
return false; return false;
@ -363,6 +388,8 @@ class ConfirmedPasswordField extends FormField {
// lengths // lengths
if(($this->minLength || $this->maxLength)) { if(($this->minLength || $this->maxLength)) {
$errorMsg = null;
$limit = null;
if($this->minLength && $this->maxLength) { if($this->minLength && $this->maxLength) {
$limit = "{{$this->minLength},{$this->maxLength}}"; $limit = "{{$this->minLength},{$this->maxLength}}";
$errorMsg = _t( $errorMsg = _t(
@ -390,8 +417,7 @@ class ConfirmedPasswordField extends FormField {
$validator->validationError( $validator->validationError(
$name, $name,
$errorMsg, $errorMsg,
"validation", "validation"
false
); );
} }
} }
@ -402,14 +428,56 @@ class ConfirmedPasswordField extends FormField {
$name, $name,
_t('Form.VALIDATIONSTRONGPASSWORD', _t('Form.VALIDATIONSTRONGPASSWORD',
"Passwords must have at least one digit and one alphanumeric character"), "Passwords must have at least one digit and one alphanumeric character"),
"validation", "validation"
false
); );
return false; return false;
} }
} }
// Check if current password is valid
if(!empty($value) && $this->getRequireExistingPassword()) {
if(!$this->currentPasswordValue) {
$validator->validationError(
$name,
_t(
'ConfirmedPasswordField.CURRENT_PASSWORD_MISSING',
"You must enter your current password."
),
"validation"
);
return false;
}
// Check this password is valid for the current user
$member = Member::currentUser();
if(!$member) {
$validator->validationError(
$name,
_t(
'ConfirmedPasswordField.LOGGED_IN_ERROR',
"You must be logged in to change your password."
),
"validation"
);
return false;
}
// With a valid user and password, check the password is correct
$checkResult = $member->checkPassword($this->currentPasswordValue);
if(!$checkResult->valid()) {
$validator->validationError(
$name,
_t(
'ConfirmedPasswordField.CURRENT_PASSWORD_ERROR',
"The current password you have entered is not correct."
),
"validation"
);
return false;
}
}
return true; return true;
} }
@ -442,4 +510,36 @@ class ConfirmedPasswordField extends FormField {
return $field; return $field;
} }
/**
* Check if existing password is required
*
* @return bool
*/
public function getRequireExistingPassword() {
return $this->requireExistingPassword;
}
/**
* Set if the existing password should be required
*
* @param bool $show Flag to show or hide this field
* @return $this
*/
public function setRequireExistingPassword($show) {
// Don't modify if already added / removed
if((bool)$show === $this->requireExistingPassword) {
return $this;
}
$this->requireExistingPassword = $show;
$name = $this->getName();
$currentName = "{$name}[_CurrentPassword]";
if ($show) {
$confirmField = PasswordField::create($currentName, _t('Member.CURRENT_PASSWORD', 'Current Password'));
$this->children->unshift($confirmField);
} else {
$this->children->removeByName($currentName, true);
}
return $this;
}
} }

View File

@ -319,8 +319,16 @@ class Member extends DataObject implements TemplateGlobalProvider {
$result = $this->canLogIn(); $result = $this->canLogIn();
// Short-circuit the result upon failure, no further checks needed. // Short-circuit the result upon failure, no further checks needed.
if (!$result->valid()) return $result; if (!$result->valid()) {
return $result;
}
// Allow default admin to login as self
if($this->isDefaultAdmin() && Security::check_default_admin($this->Email, $password)) {
return $result;
}
// Check a password is set on this member
if(empty($this->Password) && $this->exists()) { if(empty($this->Password) && $this->exists()) {
$result->error(_t('Member.NoPassword','There is no password on this member.')); $result->error(_t('Member.NoPassword','There is no password on this member.'));
return $result; return $result;
@ -337,6 +345,16 @@ class Member extends DataObject implements TemplateGlobalProvider {
return $result; return $result;
} }
/**
* Check if this user is the currently configured default admin
*
* @return bool
*/
public function isDefaultAdmin() {
return Security::has_default_admin()
&& $this->Email === Security::default_admin_username();
}
/** /**
* Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid * Returns a valid {@link ValidationResult} if this member can currently log in, or an invalid
* one with error messages to display if the member is locked out. * one with error messages to display if the member is locked out.
@ -730,14 +748,7 @@ class Member extends DataObject implements TemplateGlobalProvider {
public function getMemberFormFields() { public function getMemberFormFields() {
$fields = parent::getFrontendFields(); $fields = parent::getFrontendFields();
$fields->replaceField('Password', $password = new ConfirmedPasswordField ( $fields->replaceField('Password', $this->getMemberPasswordField());
'Password',
$this->fieldLabel('Password'),
null,
null,
(bool) $this->ID
));
$password->setCanBeEmpty(true);
$fields->replaceField('Locale', new DropdownField ( $fields->replaceField('Locale', new DropdownField (
'Locale', 'Locale',
@ -754,6 +765,36 @@ class Member extends DataObject implements TemplateGlobalProvider {
return $fields; return $fields;
} }
/**
* Builds "Change / Create Password" field for this member
*
* @return ConfirmedPasswordField
*/
public function getMemberPasswordField() {
$editingPassword = $this->isInDB();
$label = $editingPassword
? _t('Member.EDIT_PASSWORD', 'New Password')
: $this->fieldLabel('Password');
/** @var ConfirmedPasswordField $password */
$password = ConfirmedPasswordField::create(
'Password',
$label,
null,
null,
$editingPassword
);
// If editing own password, require confirmation of existing
if($editingPassword && $this->ID == Member::currentUserID()) {
$password->setRequireExistingPassword(true);
}
$password->setCanBeEmpty(true);
$this->extend('updateMemberPasswordField', $password);
return $password;
}
/** /**
* Returns the {@link RequiredFields} instance for the Member object. This * Returns the {@link RequiredFields} instance for the Member object. This
* Validator is used when saving a {@link CMSProfileController} or added to * Validator is used when saving a {@link CMSProfileController} or added to
@ -1341,19 +1382,12 @@ class Member extends DataObject implements TemplateGlobalProvider {
require_once 'Zend/Date.php'; require_once 'Zend/Date.php';
$self = $this; $self = $this;
$this->beforeUpdateCMSFields(function($fields) use ($self) { $this->beforeUpdateCMSFields(function(FieldList $fields) use ($self) {
$mainFields = $fields->fieldByName("Root")->fieldByName("Main")->Children; /** @var FieldList $mainFields */
$mainFields = $fields->fieldByName("Root")->fieldByName("Main")->getChildren();
$password = new ConfirmedPasswordField( // Build change password field
'Password', $mainFields->replaceField('Password', $self->getMemberPasswordField());
null,
null,
null,
true // showOnClick
);
$password->setCanBeEmpty(true);
if( ! $self->ID) $password->showOnClick = false;
$mainFields->replaceField('Password', $password);
$mainFields->replaceField('Locale', new DropdownField( $mainFields->replaceField('Locale', new DropdownField(
"Locale", "Locale",

View File

@ -19,9 +19,18 @@ Feature: Manage my own settings
Then I should see "Jack" Then I should see "Jack"
And I should see "Johnson" And I should see "Johnson"
Scenario: I can't reset the password without the original
Given I follow "Change Password"
And I fill in "Current Password" with "idontknow"
And I fill in "New Password" with "newsecret"
And I fill in "Confirm Password" with "newsecret"
And I press the "Save" button
Then I should see "The current password you have entered is not correct."
Scenario: I can change my password Scenario: I can change my password
Given I follow "Change Password" Given I follow "Change Password"
And I fill in "Password" with "newsecret" And I fill in "Current Password" with "secret"
And I fill in "New Password" with "newsecret"
And I fill in "Confirm Password" with "newsecret" And I fill in "Confirm Password" with "newsecret"
And I press the "Save" button And I press the "Save" button
And I am not logged in And I am not logged in