MDL-21342 add user login lockout

This commit is contained in:
Petr Škoda 2013-01-03 23:29:43 +01:00
parent 0dc5a532ec
commit b28247fe90
13 changed files with 550 additions and 49 deletions

View file

@ -61,6 +61,22 @@ define('AUTH_REMOVEUSER_KEEP', 0);
define('AUTH_REMOVEUSER_SUSPEND', 1);
define('AUTH_REMOVEUSER_FULLDELETE', 2);
/** Login attempt successful. */
define('AUTH_LOGIN_OK', 0);
/** Can not login because user does not exist. */
define('AUTH_LOGIN_NOUSER', 1);
/** Can not login because user is suspended. */
define('AUTH_LOGIN_SUSPENDED', 2);
/** Can not login, most probably password did not match. */
define('AUTH_LOGIN_FAILED', 3);
/** Can not login because user is locked out. */
define('AUTH_LOGIN_LOCKOUT', 4);
/**
* Abstract authentication plugin.
*
@ -507,3 +523,178 @@ class auth_plugin_base {
}
}
/**
* Verify if user is locked out.
*
* @param stdClass $user
* @return bool true if user locked out
*/
function login_is_lockedout($user) {
global $CFG;
if ($user->mnethostid != $CFG->mnet_localhost_id) {
return false;
}
if (isguestuser($user)) {
return false;
}
if (empty($CFG->lockoutthreshold)) {
// Lockout not enabled.
return false;
}
if (get_user_preferences('login_lockout_ignored', 0, $user)) {
// This preference may be used for accounts that must not be locked out.
return false;
}
$locked = get_user_preferences('login_lockout', 0, $user);
if (!$locked) {
return false;
}
if (empty($CFG->lockoutduration)) {
// Locked out forever.
return true;
}
if (time() - $locked < $CFG->lockoutduration) {
return true;
}
login_unlock_account($user);
return false;
}
/**
* To be called after valid user login.
* @param stdClass $user
*/
function login_attempt_valid($user) {
global $CFG;
if ($user->mnethostid != $CFG->mnet_localhost_id) {
return;
}
if (isguestuser($user)) {
return;
}
// Always unlock here, there might be some race conditions or leftovers when switching threshold.
login_unlock_account($user);
}
/**
* To be called after failed user login.
* @param stdClass $user
*/
function login_attempt_failed($user) {
global $CFG;
if ($user->mnethostid != $CFG->mnet_localhost_id) {
return;
}
if (isguestuser($user)) {
return;
}
if (empty($CFG->lockoutthreshold)) {
// No threshold means no lockout.
// Always unlock here, there might be some race conditions or leftovers when switching threshold.
login_unlock_account($user);
return;
}
$count = get_user_preferences('login_failed_count', 0, $user);
$last = get_user_preferences('login_failed_last', 0, $user);
if (!empty($CFG->lockoutwindow) and time() - $last > $CFG->lockoutwindow) {
$count = 0;
}
$count = $count+1;
set_user_preference('login_failed_count', $count, $user);
set_user_preference('login_failed_last', time(), $user);
if ($count >= $CFG->lockoutthreshold) {
login_lock_account($user);
}
}
/**
* Lockout user and send notification email.
*
* @param stdClass $user
*/
function login_lock_account($user) {
global $CFG, $SESSION;
if ($user->mnethostid != $CFG->mnet_localhost_id) {
return;
}
if (isguestuser($user)) {
return;
}
if (get_user_preferences('login_lockout_ignored', 0, $user)) {
// This user can not be locked out.
return;
}
$alreadylockedout = get_user_preferences('login_lockout', 0, $user);
set_user_preference('login_lockout', time(), $user);
if ($alreadylockedout == 0) {
$secret = random_string(15);
set_user_preference('login_lockout_secret', $secret, $user);
// Some nasty hackery to get strings and dates localised for target user.
$sessionlang = isset($SESSION->lang) ? $SESSION->lang : null;
if (get_string_manager()->translation_exists($user->lang, false)) {
$SESSION->lang = $user->lang;
moodle_setlocale();
}
$site = get_site();
$supportuser = generate_email_supportuser();
$data = new stdClass();
$data->firstname = $user->firstname;
$data->lastname = $user->lastname;
$data->username = $user->username;
$data->sitename = format_string($site->fullname);
$data->link = $CFG->wwwroot.'/login/unlock_account.php?u='.$user->id.'&s='.$secret;
$data->admin = generate_email_signoff();
$message = get_string('lockoutemailbody', 'admin', $data);
$subject = get_string('lockoutemailsubject', 'admin', format_string($site->fullname));
if ($message) {
// Directly email rather than using the messaging system to ensure its not routed to a popup or jabber.
email_to_user($user, $supportuser, $subject, $message);
}
if ($SESSION->lang !== $sessionlang) {
$SESSION->lang = $sessionlang;
moodle_setlocale();
}
}
}
/**
* Unlock user account and reset timers.
*
* @param stdClass $user
*/
function login_unlock_account($user) {
unset_user_preference('login_lockout', $user);
unset_user_preference('login_failed_count', $user);
unset_user_preference('login_failed_last', $user);
// Note: do not clear the lockout secret because user might click on the link repeatedly.
}

View file

@ -30,6 +30,26 @@
defined('MOODLE_INTERNAL') || die();
/**
* Not used any more, the account lockout handling is now
* part of authenticate_user_login().
* @deprecated
*/
function update_login_count() {
// note: remove 'errortoomanylogins' string from moodle.php too
// TODO: uncomment in Moodle 2.5, delete function in Moodle 2.6
//debugging('update_login_count() is deprecated, all calls need to be removed');
}
/**
* Not used any more, replaced by proper account lockout.
* @deprecated
*/
function reset_login_count() {
// TODO: uncomment in Moodle 2.5, delete function in Moodle 2.6
//debugging('reset_login_count() is deprecated, all calls need to be removed');
}
/**
* Unsupported session id rewriting.
* @deprecated

View file

@ -3468,39 +3468,6 @@ function set_bounce_count($user,$reset=false) {
}
}
/**
* Keeps track of login attempts
*
* @global object
*/
function update_login_count() {
global $SESSION;
$max_logins = 10;
if (empty($SESSION->logincount)) {
$SESSION->logincount = 1;
} else {
$SESSION->logincount++;
}
if ($SESSION->logincount > $max_logins) {
unset($SESSION->wantsurl);
print_error('errortoomanylogins');
}
}
/**
* Resets login attempts
*
* @global object
*/
function reset_login_count() {
global $SESSION;
$SESSION->logincount = 0;
}
/**
* Determines if the currently logged in user is in editing mode.
* Note: originally this function had $userid parameter - it was not usable anyway
@ -4134,10 +4101,13 @@ function guest_user() {
*
* @param string $username User's username
* @param string $password User's password
* @return user|flase A {@link $USER} object or false if error
* @param bool $ignorelockout useful when guessing is prevented by other mechanism such as captcha or SSO
* @param int $failurereason login failure reason, can be used in renderers (it may disclose if account exists)
* @return stdClass|false A {@link $USER} object or false if error
*/
function authenticate_user_login($username, $password) {
function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null) {
global $CFG, $DB;
require_once("$CFG->libdir/authlib.php");
$authsenabled = get_enabled_auth_plugins();
@ -4146,11 +4116,13 @@ function authenticate_user_login($username, $password) {
if (!empty($user->suspended)) {
add_to_log(SITEID, 'login', 'error', 'index.php', $username);
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Suspended Login: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_SUSPENDED;
return false;
}
if ($auth=='nologin' or !is_enabled_auth($auth)) {
add_to_log(SITEID, 'login', 'error', 'index.php', $username);
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Disabled Login: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_SUSPENDED; // Legacy way to suspend user.
return false;
}
$auths = array($auth);
@ -4159,6 +4131,7 @@ function authenticate_user_login($username, $password) {
// Check if there's a deleted record (cheaply), this should not happen because we mangle usernames in delete_user().
if ($DB->get_field('user', 'id', array('username'=>$username, 'mnethostid'=>$CFG->mnet_localhost_id, 'deleted'=>1))) {
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Deleted Login: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_NOUSER;
return false;
}
@ -4166,6 +4139,7 @@ function authenticate_user_login($username, $password) {
if (!empty($CFG->authpreventaccountcreation)) {
add_to_log(SITEID, 'login', 'error', 'index.php', $username);
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Unknown user, can not create new accounts: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_NOUSER;
return false;
}
@ -4175,6 +4149,24 @@ function authenticate_user_login($username, $password) {
$user->id = 0;
}
if ($ignorelockout) {
// Some other mechanism protects against brute force password guessing,
// for example login form might include reCAPTCHA or this function
// is called from a SSO script.
} else if ($user->id) {
// Verify login lockout after other ways that may prevent user login.
if (login_is_lockedout($user)) {
add_to_log(SITEID, 'login', 'error', 'index.php', $username);
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Login lockout: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_LOCKOUT;
return false;
}
} else {
// We can not lockout non-existing accounts.
}
foreach ($auths as $auth) {
$authplugin = get_auth_plugin($auth);
@ -4208,6 +4200,7 @@ function authenticate_user_login($username, $password) {
}
if (empty($user->id)) {
$failurereason = AUTH_LOGIN_NOUSER;
return false;
}
@ -4215,9 +4208,12 @@ function authenticate_user_login($username, $password) {
// just in case some auth plugin suspended account
add_to_log(SITEID, 'login', 'error', 'index.php', $username);
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Suspended Login: $username ".$_SERVER['HTTP_USER_AGENT']);
$failurereason = AUTH_LOGIN_SUSPENDED;
return false;
}
login_attempt_valid($user);
$failurereason = AUTH_LOGIN_OK;
return $user;
}
@ -4226,6 +4222,14 @@ function authenticate_user_login($username, $password) {
if (debugging('', DEBUG_ALL)) {
error_log('[client '.getremoteaddr()."] $CFG->wwwroot Failed Login: $username ".$_SERVER['HTTP_USER_AGENT']);
}
if ($user->id) {
login_attempt_failed($user);
$failurereason = AUTH_LOGIN_FAILED;
} else {
$failurereason = AUTH_LOGIN_NOUSER;
}
return false;
}

194
lib/tests/authlib_test.php Normal file
View file

@ -0,0 +1,194 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Authentication related tests.
*
* @package core_auth
* @category phpunit
* @copyright 2012 Petr Skoda {@link http://skodak.org}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Functional test for authentication related APIs.
*/
class authlib_testcase extends advanced_testcase {
public function test_lockout() {
global $CFG;
require_once("$CFG->libdir/authlib.php");
$this->resetAfterTest();
$oldlog = ini_get('error_log');
ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
set_config('lockoutthreshold', 0);
set_config('lockoutwindow', 60*20);
set_config('lockoutduration', 60*30);
$user = $this->getDataGenerator()->create_user();
// Test lockout is disabled when threshold not set.
$this->assertFalse(login_is_lockedout($user));
login_attempt_failed($user);
login_attempt_failed($user);
login_attempt_failed($user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
// Test lockout threshold works.
set_config('lockoutthreshold', 3);
login_attempt_failed($user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
ob_start();
login_attempt_failed($user);
$output = ob_get_clean();
$this->assertContains('noemailever', $output);
$this->assertTrue(login_is_lockedout($user));
// Test unlock works.
login_unlock_account($user);
$this->assertFalse(login_is_lockedout($user));
// Test lockout window works.
login_attempt_failed($user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
set_user_preference('login_failed_last', time()-60*20-10, $user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
// Test valid login resets window.
login_attempt_valid($user);
$this->assertFalse(login_is_lockedout($user));
login_attempt_failed($user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
// Test lock duration works.
ob_start(); // Prevent nomailever notice.
login_attempt_failed($user);
$output = ob_get_clean();
$this->assertContains('noemailever', $output);
$this->assertTrue(login_is_lockedout($user));
set_user_preference('login_lockout', time()-60*30+10, $user);
$this->assertTrue(login_is_lockedout($user));
set_user_preference('login_lockout', time()-60*30-10, $user);
$this->assertFalse(login_is_lockedout($user));
// Test lockout ignored pref works.
set_user_preference('login_lockout_ignored', 1, $user);
login_attempt_failed($user);
login_attempt_failed($user);
login_attempt_failed($user);
login_attempt_failed($user);
$this->assertFalse(login_is_lockedout($user));
ini_set('error_log', $oldlog);
}
public function test_authenticate_user_login() {
global $CFG;
$this->resetAfterTest();
$oldlog = ini_get('error_log');
ini_set('error_log', "$CFG->dataroot/testlog.log"); // Prevent standard logging.
set_config('lockoutthreshold', 0);
set_config('lockoutwindow', 60*20);
set_config('lockoutduration', 60*30);
$_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
$user1 = $this->getDataGenerator()->create_user(array('username'=>'username1', 'password'=>'password1'));
$user2 = $this->getDataGenerator()->create_user(array('username'=>'username2', 'password'=>'password2', 'suspended'=>1));
$user3 = $this->getDataGenerator()->create_user(array('username'=>'username3', 'password'=>'password3', 'auth'=>'nologin'));
$result = authenticate_user_login('username1', 'password1');
$this->assertInstanceOf('stdClass', $result);
$this->assertEquals($user1->id, $result->id);
$reason = null;
$result = authenticate_user_login('username1', 'password1', false, $reason);
$this->assertInstanceOf('stdClass', $result);
$this->assertEquals(AUTH_LOGIN_OK, $reason);
$reason = null;
$result = authenticate_user_login('username1', 'nopass', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_FAILED, $reason);
$reason = null;
$result = authenticate_user_login('username2', 'password2', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_SUSPENDED, $reason);
$reason = null;
$result = authenticate_user_login('username3', 'password3', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_SUSPENDED, $reason);
$reason = null;
$result = authenticate_user_login('username4', 'password3', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_NOUSER, $reason);
set_config('lockoutthreshold', 3);
$reason = null;
$result = authenticate_user_login('username1', 'nopass', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_FAILED, $reason);
$result = authenticate_user_login('username1', 'nopass', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_FAILED, $reason);
ob_start(); // Prevent nomailever notice.
$result = authenticate_user_login('username1', 'nopass', false, $reason);
ob_end_clean();
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_FAILED, $reason);
$result = authenticate_user_login('username1', 'password1', false, $reason);
$this->assertFalse($result);
$this->assertEquals(AUTH_LOGIN_LOCKOUT, $reason);
$result = authenticate_user_login('username1', 'password1', true, $reason);
$this->assertInstanceOf('stdClass', $result);
$this->assertEquals(AUTH_LOGIN_OK, $reason);
ini_set('error_log', $oldlog);
}
}