mirror of
https://github.com/moodle/moodle.git
synced 2025-08-06 09:26:35 +02:00
MDL-21342 add user login lockout
This commit is contained in:
parent
0dc5a532ec
commit
b28247fe90
13 changed files with 550 additions and 49 deletions
191
lib/authlib.php
191
lib/authlib.php
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
194
lib/tests/authlib_test.php
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue