Merge pull request #54 from silverstripe-security/pulls/3.5/ss-2017-009

[ss-2017-009] Prevent disclosure of sensitive information via LoginAttempt (3.5 branch)
This commit is contained in:
Damian Mooyman 2017-12-06 16:25:19 +13:00 committed by GitHub
commit 3e2bcaa0b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 51 additions and 38 deletions

View File

@ -12,18 +12,20 @@
* @package framework * @package framework
* @subpackage security * @subpackage security
* *
* @property string Email Email address used for login attempt * @property string $Email Email address used for login attempt. @deprecated 3.0...5.0
* @property string Status Status of the login attempt, either 'Success' or 'Failure' * @property string $EmailHashed sha1 hashed Email address used for login attempt
* @property string IP IP address of user attempting to login * @property string $Status Status of the login attempt, either 'Success' or 'Failure'
* @property string $IP IP address of user attempting to login
* *
* @property int MemberID ID of the Member, only if Member with Email exists * @property int $MemberID ID of the Member, only if Member with Email exists
* *
* @method Member Member() Member object of the user trying to log in, only if Member with Email exists * @method Member Member() Member object of the user trying to log in, only if Member with Email exists
*/ */
class LoginAttempt extends DataObject { class LoginAttempt extends DataObject {
private static $db = array( private static $db = array(
'Email' => 'Varchar(255)', 'Email' => 'Varchar(255)', // Remove in 5.0
'EmailHashed' => 'Varchar(255)',
'Status' => "Enum('Success,Failure')", 'Status' => "Enum('Success,Failure')",
'IP' => 'Varchar(255)', 'IP' => 'Varchar(255)',
); );
@ -32,24 +34,38 @@ class LoginAttempt extends DataObject {
'Member' => 'Member', // only linked if the member actually exists 'Member' => 'Member', // only linked if the member actually exists
); );
private static $has_many = array();
private static $many_many = array();
private static $belongs_many_many = array();
/**
*
* @param boolean $includerelations a boolean value to indicate if the labels returned include relation fields
*
*/
public function fieldLabels($includerelations = true) { public function fieldLabels($includerelations = true) {
$labels = parent::fieldLabels($includerelations); $labels = parent::fieldLabels($includerelations);
$labels['Email'] = _t('LoginAttempt.Email', 'Email Address'); $labels['Email'] = _t('LoginAttempt.Email', 'Email Address');
$labels['EmailHashed'] = _t('LoginAttempt.EmailHashed', 'Email Address (hashed)');
$labels['Status'] = _t('LoginAttempt.Status', 'Status'); $labels['Status'] = _t('LoginAttempt.Status', 'Status');
$labels['IP'] = _t('LoginAttempt.IP', 'IP Address'); $labels['IP'] = _t('LoginAttempt.IP', 'IP Address');
return $labels; return $labels;
} }
/**
* Set email used for this attempt
*
* @param string $email
* @return $this
*/
public function setEmail($email) {
// Store hashed email only
$this->EmailHashed = sha1($email);
return $this;
}
/**
* Get all login attempts for the given email address
*
* @param string $email
* @return DataList
*/
public static function getByEmail($email) {
return static::get()->filterAny(array(
'Email' => $email,
'EmailHashed' => sha1($email),
));
}
} }

View File

@ -407,9 +407,10 @@ class Member extends DataObject implements TemplateGlobalProvider {
return false; return false;
} }
$attempts = LoginAttempt::get()->filter($filter = array( $email = $this->{static::config()->unique_identifier_field};
'Email' => $this->{static::config()->unique_identifier_field}, $attempts = LoginAttempt::getByEmail($email)
))->sort('Created', 'DESC')->limit($this->config()->lock_out_after_incorrect_logins); ->sort('Created', 'DESC')
->limit($this->config()->lock_out_after_incorrect_logins);
if ($attempts->count() < $this->config()->lock_out_after_incorrect_logins) { if ($attempts->count() < $this->config()->lock_out_after_incorrect_logins) {
return false; return false;

View File

@ -196,7 +196,8 @@ class MemberAuthenticatorTest extends SapphireTest {
$this->assertNull($response); $this->assertNull($response);
$this->assertCount(1, LoginAttempt::get()); $this->assertCount(1, LoginAttempt::get());
$attempt = LoginAttempt::get()->first(); $attempt = LoginAttempt::get()->first();
$this->assertEquals($email, $attempt->Email); $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
$this->assertEquals(sha1($email), $attempt->EmailHashed);
$this->assertEquals('Failure', $attempt->Status); $this->assertEquals('Failure', $attempt->Status);
} }

View File

@ -507,25 +507,21 @@ class SecurityTest extends FunctionalTest {
/* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */ /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
$this->doTestLoginForm('testuser@example.com', 'wrongpassword'); $this->doTestLoginForm('testuser@example.com', 'wrongpassword');
$attempt = DataObject::get_one('LoginAttempt', array( $attempt = LoginAttempt::getByEmail('testuser@example.com')->first();
'"LoginAttempt"."Email"' => 'testuser@example.com' $this->assertInstanceOf('LoginAttempt', $attempt);
)); $member = Member::get()->filter('Email', 'testuser@example.com')->first();
$this->assertTrue(is_object($attempt));
$member = DataObject::get_one('Member', array(
'"Member"."Email"' => 'testuser@example.com'
));
$this->assertEquals($attempt->Status, 'Failure'); $this->assertEquals($attempt->Status, 'Failure');
$this->assertEquals($attempt->Email, 'testuser@example.com'); $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
$this->assertEquals($attempt->EmailHashed, sha1('testuser@example.com'));
$this->assertEquals($attempt->Member(), $member); $this->assertEquals($attempt->Member(), $member);
/* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */ /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
$this->doTestLoginForm('wronguser@silverstripe.com', 'wrongpassword'); $this->doTestLoginForm('wronguser@silverstripe.com', 'wrongpassword');
$attempt = DataObject::get_one('LoginAttempt', array( $attempt = LoginAttempt::getByEmail('wronguser@silverstripe.com')->first();
'"LoginAttempt"."Email"' => 'wronguser@silverstripe.com' $this->assertInstanceOf('LoginAttempt', $attempt);
));
$this->assertTrue(is_object($attempt));
$this->assertEquals($attempt->Status, 'Failure'); $this->assertEquals($attempt->Status, 'Failure');
$this->assertEquals($attempt->Email, 'wronguser@silverstripe.com'); $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
$this->assertEquals($attempt->EmailHashed, sha1('wronguser@silverstripe.com'));
$this->assertNotNull( $this->assertNotNull(
$this->loginErrorMessage(), 'An invalid email returns a message.' $this->loginErrorMessage(), 'An invalid email returns a message.'
); );
@ -536,15 +532,14 @@ class SecurityTest extends FunctionalTest {
/* SUCCESSFUL ATTEMPTS ARE LOGGED */ /* SUCCESSFUL ATTEMPTS ARE LOGGED */
$this->doTestLoginForm('testuser@example.com', '1nitialPassword'); $this->doTestLoginForm('testuser@example.com', '1nitialPassword');
$attempt = DataObject::get_one('LoginAttempt', array( $attempt = LoginAttempt::getByEmail('testuser@example.com')->first();
'"LoginAttempt"."Email"' => 'testuser@example.com'
));
$member = DataObject::get_one('Member', array( $member = DataObject::get_one('Member', array(
'"Member"."Email"' => 'testuser@example.com' '"Member"."Email"' => 'testuser@example.com'
)); ));
$this->assertTrue(is_object($attempt)); $this->assertInstanceOf('LoginAttempt', $attempt);
$this->assertEquals($attempt->Status, 'Success'); $this->assertEquals($attempt->Status, 'Success');
$this->assertEquals($attempt->Email, 'testuser@example.com'); $this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
$this->assertEquals($attempt->EmailHashed, sha1('testuser@example.com'));
$this->assertEquals($attempt->Member(), $member); $this->assertEquals($attempt->Member(), $member);
} }