Skip to content

Commit f1dd3d6

Browse files
author
Damian Mooyman
committed
[ss-2017-009] Prevent disclosure of sensitive information via LoginAttempt
1 parent d57dea0 commit f1dd3d6

File tree

4 files changed

+53
-46
lines changed

4 files changed

+53
-46
lines changed

src/Security/LoginAttempt.php

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace SilverStripe\Security;
44

5+
use SilverStripe\ORM\DataList;
56
use SilverStripe\ORM\DataObject;
67

78
/**
@@ -14,11 +15,11 @@
1415
* complies with your privacy standards. We're logging
1516
* username and IP.
1617
*
17-
* @property string Email Email address used for login attempt
18-
* @property string Status Status of the login attempt, either 'Success' or 'Failure'
19-
* @property string IP IP address of user attempting to login
20-
*
21-
* @property int MemberID ID of the Member, only if Member with Email exists
18+
* @property string $Email Email address used for login attempt. @deprecated 3.0...5.0
19+
* @property string $EmailHashed sha1 hashed Email address used for login attempt
20+
* @property string $Status Status of the login attempt, either 'Success' or 'Failure'
21+
* @property string $IP IP address of user attempting to login
22+
* @property int $MemberID ID of the Member, only if Member with Email exists
2223
*
2324
* @method Member Member() Member object of the user trying to log in, only if Member with Email exists
2425
*/
@@ -35,7 +36,8 @@ class LoginAttempt extends DataObject
3536
const FAILURE = 'Failure';
3637

3738
private static $db = array(
38-
'Email' => 'Varchar(255)',
39+
'Email' => 'Varchar(255)', // Remove in 5.0
40+
'EmailHashed' => 'Varchar(255)',
3941
'Status' => "Enum('Success,Failure')",
4042
'IP' => 'Varchar(255)',
4143
);
@@ -55,9 +57,37 @@ public function fieldLabels($includerelations = true)
5557
{
5658
$labels = parent::fieldLabels($includerelations);
5759
$labels['Email'] = _t(__CLASS__.'.Email', 'Email Address');
60+
$labels['EmailHashed'] = _t(__CLASS__.'.EmailHashed', 'Email Address (hashed)');
5861
$labels['Status'] = _t(__CLASS__.'.Status', 'Status');
5962
$labels['IP'] = _t(__CLASS__.'.IP', 'IP Address');
6063

6164
return $labels;
6265
}
66+
67+
/**
68+
* Set email used for this attempt
69+
*
70+
* @param string $email
71+
* @return $this
72+
*/
73+
public function setEmail($email)
74+
{
75+
// Store hashed email only
76+
$this->EmailHashed = sha1($email);
77+
return $this;
78+
}
79+
80+
/**
81+
* Get all login attempts for the given email address
82+
*
83+
* @param string $email
84+
* @return DataList|LoginAttempt[]
85+
*/
86+
public static function getByEmail($email)
87+
{
88+
return static::get()->filterAny(array(
89+
'Email' => $email,
90+
'EmailHashed' => sha1($email),
91+
));
92+
}
6393
}

src/Security/Member.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,7 @@ public function isLockedOut()
384384
}
385385

386386
$idField = static::config()->get('unique_identifier_field');
387-
$attempts = LoginAttempt::get()
388-
->filter('Email', $this->{$idField})
387+
$attempts = LoginAttempt::getByEmail($this->{$idField})
389388
->sort('Created', 'DESC')
390389
->limit($maxAttempts);
391390

tests/php/Security/MemberAuthenticatorTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ public function testNonExistantMemberGetsLoginAttemptRecorded()
265265
$this->assertNull($member);
266266
$this->assertCount(1, LoginAttempt::get());
267267
$attempt = LoginAttempt::get()->first();
268-
$this->assertEquals($email, $attempt->Email);
268+
$this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
269+
$this->assertEquals(sha1($email), $attempt->EmailHashed);
269270
$this->assertEquals(LoginAttempt::FAILURE, $attempt->Status);
270271
}
271272

tests/php/Security/SecurityTest.php

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
namespace SilverStripe\Security\Tests;
44

55
use Page;
6-
use PageController;
76
use SilverStripe\Control\Controller;
87
use SilverStripe\Control\Director;
98
use SilverStripe\Control\HTTPRequest;
@@ -615,34 +614,21 @@ public function testUnsuccessfulLoginAttempts()
615614
/* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
616615
$this->doTestLoginForm('testuser@example.com', 'wrongpassword');
617616
/** @var LoginAttempt $attempt */
618-
$attempt = DataObject::get_one(
619-
LoginAttempt::class,
620-
array(
621-
'"LoginAttempt"."Email"' => 'testuser@example.com'
622-
)
623-
);
617+
$attempt = LoginAttempt::getByEmail('testuser@example.com')->first();
624618
$this->assertInstanceOf(LoginAttempt::class, $attempt);
625-
$member = DataObject::get_one(
626-
Member::class,
627-
array(
628-
'"Member"."Email"' => 'testuser@example.com'
629-
)
630-
);
619+
$member = Member::get()->filter('Email', 'testuser@example.com')->first();
631620
$this->assertEquals($attempt->Status, 'Failure');
632-
$this->assertEquals($attempt->Email, 'testuser@example.com');
621+
$this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
622+
$this->assertEquals($attempt->EmailHashed, sha1('testuser@example.com'));
633623
$this->assertEquals($attempt->Member()->toMap(), $member->toMap());
634624

635625
/* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
636626
$this->doTestLoginForm('wronguser@silverstripe.com', 'wrongpassword');
637-
$attempt = DataObject::get_one(
638-
LoginAttempt::class,
639-
array(
640-
'"LoginAttempt"."Email"' => 'wronguser@silverstripe.com'
641-
)
642-
);
643-
$this->assertTrue(is_object($attempt));
627+
$attempt = LoginAttempt::getByEmail('wronguser@silverstripe.com')->first();
628+
$this->assertInstanceOf(LoginAttempt::class, $attempt);
644629
$this->assertEquals($attempt->Status, 'Failure');
645-
$this->assertEquals($attempt->Email, 'wronguser@silverstripe.com');
630+
$this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
631+
$this->assertEquals($attempt->EmailHashed, sha1('wronguser@silverstripe.com'));
646632
$this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
647633
}
648634

@@ -653,22 +639,12 @@ public function testSuccessfulLoginAttempts()
653639
/* SUCCESSFUL ATTEMPTS ARE LOGGED */
654640
$this->doTestLoginForm('testuser@example.com', '1nitialPassword');
655641
/** @var LoginAttempt $attempt */
656-
$attempt = DataObject::get_one(
657-
LoginAttempt::class,
658-
array(
659-
'"LoginAttempt"."Email"' => 'testuser@example.com'
660-
)
661-
);
662-
/** @var Member $member */
663-
$member = DataObject::get_one(
664-
Member::class,
665-
array(
666-
'"Member"."Email"' => 'testuser@example.com'
667-
)
668-
);
669-
$this->assertTrue(is_object($attempt));
642+
$attempt = LoginAttempt::getByEmail('testuser@example.com')->first();
643+
$member = Member::get()->filter('Email', 'testuser@example.com')->first();
644+
$this->assertInstanceOf(LoginAttempt::class, $attempt);
670645
$this->assertEquals($attempt->Status, 'Success');
671-
$this->assertEquals($attempt->Email, 'testuser@example.com');
646+
$this->assertEmpty($attempt->Email); // Doesn't store potentially sensitive data
647+
$this->assertEquals($attempt->EmailHashed, sha1('testuser@example.com'));
672648
$this->assertEquals($attempt->Member()->toMap(), $member->toMap());
673649
}
674650

@@ -725,6 +701,7 @@ public function testGetResponseController()
725701

726702
// Ensure page shares the same controller as security
727703
$securityClass = Config::inst()->get(Security::class, 'page_class');
704+
/** @var Page $securityPage */
728705
$securityPage = new $securityClass();
729706
$this->assertInstanceOf($securityPage->getControllerName(), $result);
730707
$this->assertEquals($request, $result->getRequest());

0 commit comments

Comments
 (0)