diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index 954d5aec200..7d6e0f54501 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -116,6 +116,17 @@ if ($hassiteconfig) { $temp->add($setting); $temp->add(new admin_setting_configcheckbox('verifychangedemail', new lang_string('verifychangedemail', 'admin'), new lang_string('configverifychangedemail', 'admin'), 1)); + // ReCaptcha. + $temp->add(new admin_setting_configselect('enableloginrecaptcha', + new lang_string('auth_loginrecaptcha', 'auth'), + new lang_string('auth_loginrecaptcha_desc', 'auth'), + 0, + [ + new lang_string('no'), + new lang_string('yes'), + ], + )); + $setting = new admin_setting_configtext('recaptchapublickey', new lang_string('recaptchapublickey', 'admin'), new lang_string('configrecaptchapublickey', 'admin'), '', PARAM_NOTAGS); $setting->set_force_ltr(true); $temp->add($setting); diff --git a/auth/classes/output/login.php b/auth/classes/output/login.php index c2bb00b15ac..0b0db310b33 100644 --- a/auth/classes/output/login.php +++ b/auth/classes/output/login.php @@ -71,6 +71,8 @@ class login implements renderable, templatable { public $logintoken; /** @var string Maintenance message, if Maintenance is enabled. */ public $maintenance; + /** @var string ReCaptcha element HTML. */ + public $recaptcha; /** * Constructor. @@ -120,6 +122,12 @@ class login implements renderable, templatable { // Identity providers. $this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence); $this->logintoken = \core\session\manager::get_login_token(); + + // ReCaptcha. + if (login_captcha_enabled()) { + require_once($CFG->libdir . '/recaptchalib_v2.php'); + $this->recaptcha = recaptcha_get_challenge_html(RECAPTCHA_API_URL, $CFG->recaptchapublickey); + } } /** @@ -154,6 +162,7 @@ class login implements renderable, templatable { $data->logintoken = $this->logintoken; $data->maintenance = format_text($this->maintenance, FORMAT_MOODLE); $data->languagemenu = $this->languagemenu; + $data->recaptcha = $this->recaptcha; return $data; } diff --git a/lang/en/auth.php b/lang/en/auth.php index 4eb470bfb03..c73816c063c 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -41,6 +41,8 @@ $string['auth_changepasswordurl_expl'] = 'Specify the url to send users who have $string['auth_changingemailaddress'] = 'You have requested a change of email address, from {$a->oldemail} to {$a->newemail}. For security reasons, we are sending you an email message at the new address to confirm that it belongs to you. Your email address will be updated as soon as you open the URL sent to you in that message.'; $string['authinstructions'] = 'Leave this blank for the default login instructions to be displayed on the login page. If you want to provide custom login instructions, enter them here.'; $string['auth_invalidnewemailkey'] = 'Error: if you are trying to confirm a change of email address, you may have made a mistake in copying the URL we sent you by email. Please copy the address and try again.'; +$string['auth_loginrecaptcha'] = 'Enable reCAPTCHA for login'; +$string['auth_loginrecaptcha_desc'] = 'Add a visual/audio confirmation form element to the login page. This reduces the risk of unwarranted login attempts. See Google reCAPTCHA for more details. '; $string['auth_multiplehosts'] = 'Multiple hosts OR addresses can be specified (eg host1.com;host2.com;host3.com) or (eg xxx.xxx.xxx.xxx;xxx.xxx.xxx.xxx)'; $string['auth_notconfigured'] = 'The authentication method {$a} is not configured.'; $string['auth_outofnewemailupdateattempts'] = 'You have run out of allowed attempts to update your email address. Your update request has been cancelled.'; diff --git a/lib/authlib.php b/lib/authlib.php index 2d742cdc5c3..ba8d25a5653 100644 --- a/lib/authlib.php +++ b/lib/authlib.php @@ -79,6 +79,9 @@ define('AUTH_LOGIN_LOCKOUT', 4); /** Can not login becauser user is not authorised. */ define('AUTH_LOGIN_UNAUTHORISED', 5); +/** Can not login, failed reCaptcha challenge. */ +define('AUTH_LOGIN_FAILED_RECAPTCHA', 6); + /** * Abstract authentication plugin. * @@ -1037,6 +1040,40 @@ function signup_captcha_enabled() { return !empty($CFG->recaptchapublickey) && !empty($CFG->recaptchaprivatekey) && $authplugin->is_captcha_enabled(); } +/** + * Returns whether the captcha element is enabled for the login form, and the admin settings fulfil its requirements. + * @return bool + */ +function login_captcha_enabled(): bool { + global $CFG; + return !empty($CFG->recaptchapublickey) && !empty($CFG->recaptchaprivatekey) && $CFG->enableloginrecaptcha == true; +} + +/** + * Check the submitted captcha is valid or not. + * + * @param string|bool $captcha The value submitted in the login form that we are validating. + * If false is passed for the captcha, this function will always return true. + * @return boolean If the submitted captcha is valid. + */ +function validate_login_captcha(string|bool $captcha): bool { + global $CFG; + if (!empty($CFG->alternateloginurl)) { + // An external login page cannot use the reCaptcha. + return true; + } + if ($captcha === false) { + // The authenticate_user_login() is a core function was extended to validate captcha. + // For existing uses other than the login form it does not need to validate the captcha. + // Example: login/change_password_form.php or login/token.php. + return true; + } + + require_once($CFG->libdir . '/recaptchalib_v2.php'); + $response = recaptcha_check_response(RECAPTCHA_VERIFY_URL, $CFG->recaptchaprivatekey, getremoteaddr(), $captcha); + return $response['isvalid']; +} + /** * Validates the standard sign-up data (except recaptcha that is validated by the form element). * diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 794bad1a051..6277e8d10c8 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -4242,10 +4242,18 @@ function guest_user() { * @param string $password User's password * @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) - * @param mixed logintoken If this is set to a string it is validated against the login token for the session. + * @param string|bool $logintoken If this is set to a string it is validated against the login token for the session. + * @param string|bool $loginrecaptcha If this is set to a string it is validated against Google reCaptcha. * @return stdClass|false A {@link $USER} object or false if error */ -function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null, $logintoken=false) { +function authenticate_user_login( + $username, + $password, + $ignorelockout = false, + &$failurereason = null, + $logintoken = false, + string|bool $loginrecaptcha = false, +) { global $CFG, $DB, $PAGE; require_once("$CFG->libdir/authlib.php"); @@ -4284,6 +4292,20 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f return false; } + // Login reCaptcha. + if (login_captcha_enabled() && !validate_login_captcha($loginrecaptcha)) { + $failurereason = AUTH_LOGIN_FAILED_RECAPTCHA; + // Trigger login failed event (specifying the ID of the found user, if available). + \core\event\user_login_failed::create([ + 'userid' => ($user->id ?? 0), + 'other' => [ + 'username' => $username, + 'reason' => $failurereason, + ], + ])->trigger(); + return false; + } + $authsenabled = get_enabled_auth_plugins(); if ($user) { diff --git a/lib/templates/loginform.mustache b/lib/templates/loginform.mustache index c4f656864f3..1930ee550fa 100644 --- a/lib/templates/loginform.mustache +++ b/lib/templates/loginform.mustache @@ -142,6 +142,11 @@ !}}placeholder="{{#cleanstr}}password{{/cleanstr}}" {{! !}}autocomplete="current-password"> + {{#recaptcha}} +
+ {{{recaptcha}}} +
+ {{/recaptcha}}
diff --git a/lib/tests/authlib_test.php b/lib/tests/authlib_test.php index d1eb883c9a7..cb448bd4a87 100644 --- a/lib/tests/authlib_test.php +++ b/lib/tests/authlib_test.php @@ -390,6 +390,50 @@ class authlib_test extends \advanced_testcase { $this->assertEquals(count($events), 0); // Check no notifications. $this->assertEquals(count($notifications), 0); + + // Capture failed login reCaptcha. + $CFG->recaptchapublickey = 'randompublickey'; + $CFG->recaptchaprivatekey = 'randomprivatekey'; + $CFG->enableloginrecaptcha = true; + + // Login with blank captcha. + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, false, ''); + $events = $sink->get_events(); + $sink->close(); + $event = array_pop($events); + + $this->assertFalse($result); + $this->assertEquals(AUTH_LOGIN_FAILED_RECAPTCHA, $reason); + + // Test event. + $this->assertInstanceOf('\core\event\user_login_failed', $event); + $eventdata = $event->get_data(); + $this->assertSame($eventdata['other']['username'], 'username1'); + $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED_RECAPTCHA); + $this->assertEventContextNotUsed($event); + + // Login with invalid captcha. + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, false, 'invalidcaptcha'); + $events = $sink->get_events(); + $sink->close(); + $event = array_pop($events); + + $this->assertFalse($result); + $this->assertEquals(AUTH_LOGIN_FAILED_RECAPTCHA, $reason); + + // Test event. + $this->assertInstanceOf('\core\event\user_login_failed', $event); + $eventdata = $event->get_data(); + $this->assertSame($eventdata['other']['username'], 'username1'); + $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED_RECAPTCHA); + $this->assertEventContextNotUsed($event); + + // Unset settings. + unset($CFG->recaptchapublickey); + unset($CFG->recaptchaprivatekey); + unset($CFG->enableloginrecaptcha); } public function test_user_loggedin_event_exceptions() { diff --git a/lib/upgrade.txt b/lib/upgrade.txt index d1c97007233..00b0dd1f3a4 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -87,6 +87,9 @@ information provided here is intended especially for developers. to ensure in some test that the block drawer is closed. This helps with random failures due to the block drawer being forced open in all behat tests. * The core_useragent::get_device_type_list() function has been deprecated. Use core_useragent::devicetypes instead as a replacement. +* New method login_captcha_enabled() is created to check whether the login captcha is enabled or not. +* New method validate_login_captcha() is created to validate the login captcha. +* A new parameter $loginrecaptcha has been added to the authenticate_user_login() to check whether the login captcha is needed to verify or not. The default value is false. === 4.2 === diff --git a/login/index.php b/login/index.php index 151daec1f8f..5f690ed9cb8 100644 --- a/login/index.php +++ b/login/index.php @@ -151,7 +151,8 @@ if ($frm and isset($frm->username)) { // Login WITH } else { if (empty($errormsg)) { $logintoken = isset($frm->logintoken) ? $frm->logintoken : ''; - $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode, $logintoken); + $loginrecaptcha = $frm->{'g-recaptcha-response'} ?? false; + $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode, $logintoken, $loginrecaptcha); } } @@ -279,6 +280,8 @@ if ($frm and isset($frm->username)) { // Login WITH if (empty($errormsg)) { if ($errorcode == AUTH_LOGIN_UNAUTHORISED) { $errormsg = get_string("unauthorisedlogin", "", $frm->username); + } else if ($errorcode == AUTH_LOGIN_FAILED_RECAPTCHA) { + $errormsg = get_string('missingrecaptchachallengefield'); } else { $errormsg = get_string("invalidlogin"); $errorcode = 3;