[SS-2017-002] FIX Lock out users who dont exist in the DB

This commit is contained in:
Daniel Hensby 2017-05-09 21:24:15 +01:00 committed by Damian Mooyman
parent 23a719e14e
commit 30986b4ea3
No known key found for this signature in database
GPG Key ID: 78B823A10DE27D1A
5 changed files with 130 additions and 29 deletions

View File

@ -24,6 +24,15 @@ use SilverStripe\ORM\DataObject;
*/ */
class LoginAttempt extends DataObject class LoginAttempt extends DataObject
{ {
/**
* Success status
*/
const SUCCESS = 'Success';
/**
* Failure status
*/
const FAILURE = 'Failure';
private static $db = array( private static $db = array(
'Email' => 'Varchar(255)', 'Email' => 'Varchar(255)',
@ -32,7 +41,7 @@ class LoginAttempt extends DataObject
); );
private static $has_one = array( private static $has_one = array(
'Member' => 'SilverStripe\\Security\\Member', // only linked if the member actually exists 'Member' => Member::class, // only linked if the member actually exists
); );
private static $table_name = "LoginAttempt"; private static $table_name = "LoginAttempt";

View File

@ -350,13 +350,44 @@ class Member extends DataObject
*/ */
public function isLockedOut() public function isLockedOut()
{ {
if (!$this->LockedOutUntil) { /** @var DBDatetime $lockedOutUntilObj */
$lockedOutUntilObj = $this->dbObject('LockedOutUntil');
if ($lockedOutUntilObj->InFuture()) {
return true;
}
$maxAttempts = $this->config()->get('lock_out_after_incorrect_logins');
if ($maxAttempts <= 0) {
return false; return false;
} }
/** @var DBDatetime $lockedOutUntil */ $idField = static::config()->get('unique_identifier_field');
$lockedOutUntil = $this->dbObject('LockedOutUntil'); $attempts = LoginAttempt::get()
return DBDatetime::now()->getTimestamp() < $lockedOutUntil->getTimestamp(); ->filter('Email', $this->{$idField})
->sort('Created', 'DESC')
->limit($maxAttempts);
if ($attempts->count() < $maxAttempts) {
return false;
}
foreach ($attempts as $attempt) {
if ($attempt->Status === 'Success') {
return false;
}
}
// Calculate effective LockedOutUntil
/** @var DBDatetime $firstFailureDate */
$firstFailureDate = $attempts->first()->dbObject('Created');
$maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
$lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
$now = DBDatetime::now()->getTimestamp();
if ($now < $lockedOutUntil) {
return true;
}
return false;
} }
/** /**
@ -1652,7 +1683,7 @@ class Member extends DataObject
$lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins'); $lockOutAfterCount = self::config()->get('lock_out_after_incorrect_logins');
if ($lockOutAfterCount) { if ($lockOutAfterCount) {
// Keep a tally of the number of failed log-ins so that we can lock people out // Keep a tally of the number of failed log-ins so that we can lock people out
$this->FailedLoginCount = $this->FailedLoginCount + 1; ++$this->FailedLoginCount;
if ($this->FailedLoginCount >= $lockOutAfterCount) { if ($this->FailedLoginCount >= $lockOutAfterCount) {
$lockoutMins = self::config()->get('lock_out_delay_mins'); $lockoutMins = self::config()->get('lock_out_delay_mins');
@ -1672,6 +1703,7 @@ class Member extends DataObject
if (self::config()->get('lock_out_after_incorrect_logins')) { if (self::config()->get('lock_out_after_incorrect_logins')) {
// Forgive all past login failures // Forgive all past login failures
$this->FailedLoginCount = 0; $this->FailedLoginCount = 0;
$this->LockedOutUntil = null;
$this->write(); $this->write();
} }
} }

View File

@ -99,7 +99,7 @@ class MemberAuthenticator implements Authenticator
$member->registerFailedLogin(); $member->registerFailedLogin();
} }
} elseif ($member) { } elseif ($member) {
$member->registerSuccessfulLogin(); $member->registerSuccessfulLogin();
} else { } else {
// A non-existing member occurred. This will make the result "valid" so let's invalidate // A non-existing member occurred. This will make the result "valid" so let's invalidate
$result->addError(_t( $result->addError(_t(
@ -179,13 +179,13 @@ class MemberAuthenticator implements Authenticator
if ($success && $member) { if ($success && $member) {
// successful login (member is existing with matching password) // successful login (member is existing with matching password)
$attempt->MemberID = $member->ID; $attempt->MemberID = $member->ID;
$attempt->Status = 'Success'; $attempt->Status = LoginAttempt::SUCCESS;
// Audit logging hook // Audit logging hook
$member->extend('authenticationSucceeded'); $member->extend('authenticationSucceeded');
} else { } else {
// Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords) // Failed login - we're trying to see if a user exists with this email (disregarding wrong passwords)
$attempt->Status = 'Failure'; $attempt->Status = LoginAttempt::FAILURE;
if ($member) { if ($member) {
// Audit logging hook // Audit logging hook
$attempt->MemberID = $member->ID; $attempt->MemberID = $member->ID;

View File

@ -11,6 +11,7 @@ use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Authenticator; use SilverStripe\Security\Authenticator;
use SilverStripe\Security\DefaultAdminService; use SilverStripe\Security\DefaultAdminService;
use SilverStripe\Security\IdentityStore; 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\CMSMemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm; use SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm;
@ -205,4 +206,62 @@ class MemberAuthenticatorTest extends SapphireTest
$this->assertFalse($defaultAdmin->canLogin()); $this->assertFalse($defaultAdmin->canLogin());
$this->assertEquals('2016-04-18 00:10:00', $defaultAdmin->LockedOutUntil); $this->assertEquals('2016-04-18 00:10:00', $defaultAdmin->LockedOutUntil);
} }
public function testNonExistantMemberGetsLoginAttemptRecorded()
{
Security::config()->set('login_recording', true);
Member::config()
->set('lock_out_after_incorrect_logins', 1)
->set('lock_out_delay_mins', 10);
$email = 'notreal@example.com';
$this->assertFalse(Member::get()->filter(array('Email' => $email))->exists());
$this->assertCount(0, LoginAttempt::get());
$authenticator = new MemberAuthenticator();
$result = new ValidationResult();
$member = $authenticator->authenticate(
[
'Email' => $email,
'Password' => 'password',
],
Controller::curr()->getRequest(),
$result
);
$this->assertFalse($result->isValid());
$this->assertNull($member);
$this->assertCount(1, LoginAttempt::get());
$attempt = LoginAttempt::get()->first();
$this->assertEquals($email, $attempt->Email);
$this->assertEquals(LoginAttempt::FAILURE, $attempt->Status);
}
public function testNonExistantMemberGetsLockedOut()
{
Security::config()->set('login_recording', true);
Member::config()
->set('lock_out_after_incorrect_logins', 1)
->set('lock_out_delay_mins', 10);
$email = 'notreal@example.com';
$this->assertFalse(Member::get()->filter(array('Email' => $email))->exists());
$authenticator = new MemberAuthenticator();
$result = new ValidationResult();
$member = $authenticator->authenticate(
[
'Email' => $email,
'Password' => 'password',
],
Controller::curr()->getRequest(),
$result
);
$this->assertNull($member);
$this->assertFalse($result->isValid());
$member = new Member();
$member->Email = $email;
$this->assertTrue($member->isLockedOut());
$this->assertFalse($member->canLogIn());
}
} }

View File

@ -282,7 +282,7 @@ class SecurityTest extends FunctionalTest
Security::setCurrentUser($member); Security::setCurrentUser($member);
/* Visit the Security/logout page with a test referer, but without a security token */ /* Visit the Security/logout page with a test referer, but without a security token */
$response = $this->get( $this->get(
Config::inst()->get(Security::class, 'logout_url'), Config::inst()->get(Security::class, 'logout_url'),
null, null,
['Referer' => Director::absoluteBaseURL() . 'testpage'] ['Referer' => Director::absoluteBaseURL() . 'testpage']
@ -494,8 +494,11 @@ class SecurityTest extends FunctionalTest
i18n::set_locale('en_US'); i18n::set_locale('en_US');
Member::config()->set('lock_out_after_incorrect_logins', 5); Member::config()->set('lock_out_after_incorrect_logins', 5);
Member::config()->set('lock_out_delay_mins', 15); Member::config()->set('lock_out_delay_mins', 15);
DBDatetime::set_mock_now('2017-05-22 00:00:00');
// Login with a wrong password for more than the defined threshold // Login with a wrong password for more than the defined threshold
/** @var Member $member */
$member = null;
for ($i = 1; $i <= 6; $i++) { for ($i = 1; $i <= 6; $i++) {
$this->doTestLoginForm('testuser@example.com', 'incorrectpassword'); $this->doTestLoginForm('testuser@example.com', 'incorrectpassword');
/** @var Member $member */ /** @var Member $member */
@ -513,38 +516,36 @@ class SecurityTest extends FunctionalTest
) )
); );
} else { } else {
// Fuzzy matching for time to avoid side effects from slow running tests // Lockout should be exactly 15 minutes from now
$this->assertGreaterThan( /** @var DBDatetime $lockedOutUntilObj */
time() + 14*60, $lockedOutUntilObj = $member->dbObject('LockedOutUntil');
strtotime($member->LockedOutUntil), $this->assertEquals(
DBDatetime::now()->getTimestamp() + (15 * 60),
$lockedOutUntilObj->getTimestamp(),
'User has a lockout time set after too many failed attempts' 'User has a lockout time set after too many failed attempts'
); );
} }
} }
$msg = _t( $msg = _t(
'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2', 'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
'Your account has been temporarily disabled because of too many failed attempts at ' . 'Your account has been temporarily disabled because of too many failed attempts at ' .
'logging in. Please try again in {count} minutes.', 'logging in. Please try again in {count} minutes.',
null, null,
array('count' => 15) array('count' => 15)
); );
$this->assertHasMessage($msg); $this->assertHasMessage($msg);
$this->doTestLoginForm('testuser@example.com', '1nitialPassword'); $this->doTestLoginForm('testuser@example.com', '1nitialPassword');
$this->assertNull( $this->assertNull(
$this->session()->get('loggedInAs'), $this->session()->get('loggedInAs'),
'The user can\'t log in after being locked out, even with the right password' 'The user can\'t log in after being locked out, even with the right password'
); );
// (We fake this by re-setting LockedOutUntil) // Move into the future so we can login again
$member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test')); DBDatetime::set_mock_now('2017-06-22 00:00:00');
$member->LockedOutUntil = date('Y-m-d H:i:s', time() - 30);
$member->write();
$this->doTestLoginForm('testuser@example.com', '1nitialPassword'); $this->doTestLoginForm('testuser@example.com', '1nitialPassword');
$this->assertEquals( $this->assertEquals(
$this->session()->get('loggedInAs'),
$member->ID, $member->ID,
$this->session()->get('loggedInAs'),
'After lockout expires, the user can login again' 'After lockout expires, the user can login again'
); );