diff --git a/auth/ldap/auth.php b/auth/ldap/auth.php index b23c9ec99be..09c5ad42689 100644 --- a/auth/ldap/auth.php +++ b/auth/ldap/auth.php @@ -1762,7 +1762,8 @@ class auth_plugin_ldap extends auth_plugin_base { // Here we want to trigger the whole authentication machinery // to make sure no step is bypassed... - $user = authenticate_user_login($username, $key); + $reason = null; + $user = authenticate_user_login($username, $key, false, $reason, false); if ($user) { complete_user_login($user); diff --git a/auth/shibboleth/index.php b/auth/shibboleth/index.php index a8cbd03c78d..a47d3ba96dd 100644 --- a/auth/shibboleth/index.php +++ b/auth/shibboleth/index.php @@ -49,9 +49,10 @@ $frm->password = generate_password(8); /// Check if the user has actually submitted login data to us + $reason = null; if ($shibbolethauth->user_login($frm->username, $frm->password) - && $user = authenticate_user_login($frm->username, $frm->password)) { + && $user = authenticate_user_login($frm->username, $frm->password, false, $reason, false)) { complete_user_login($user); if (user_not_fully_set_up($USER, true)) { diff --git a/auth/upgrade.txt b/auth/upgrade.txt index 0024b81aad9..c9a55d968f5 100644 --- a/auth/upgrade.txt +++ b/auth/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in /auth/* - plugins, information provided here is intended especially for developers. +=== 3.1.15 === + +* Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token(). + === 3.0 === * login_signup_form::signup_captcha_enabled() now calls is_captcha_enabled() from the current auth plugin instead of from auth_email diff --git a/blocks/login/block_login.php b/blocks/login/block_login.php index 2c4eda4aa72..25fac7a0341 100644 --- a/blocks/login/block_login.php +++ b/blocks/login/block_login.php @@ -92,6 +92,7 @@ class block_login extends block_base { } $this->content->text .= '
'; + $this->content->text .= ''; $this->content->text .= "\n"; diff --git a/config-dist.php b/config-dist.php index caf0f1c19ec..a103dd72564 100644 --- a/config-dist.php +++ b/config-dist.php @@ -561,6 +561,11 @@ $CFG->admin = 'admin'; // // $CFG->upgradekey = 'put_some_password-like_value_here'; // +// Disable login token validation for login pages. Login token validation is enabled +// by default unless $CFG->alternateloginurl is set. +// +// $CFG->disablelogintoken = true; +// //========================================================================= // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!! //========================================================================= diff --git a/lib/classes/session/manager.php b/lib/classes/session/manager.php index 9f0bbd5e387..b67f8d3804f 100644 --- a/lib/classes/session/manager.php +++ b/lib/classes/session/manager.php @@ -46,6 +46,9 @@ class manager { /** @var bool $sessionactive Is the session active? */ protected static $sessionactive = null; + /** @var string $logintokenkey Key used to get and store request protection for login form. */ + protected static $logintokenkey = 'core_auth_login'; + /** * Start user session. * @@ -906,4 +909,102 @@ class manager { ))); } + /** + * Generate a new login token and store it in the session. + * + * @return array The current login state. + */ + private static function create_login_token() { + global $SESSION; + + $state = [ + 'token' => random_string(32), + 'created' => time() // Server time - not user time. + ]; + + if (!isset($SESSION->logintoken)) { + $SESSION->logintoken = []; + } + + // Overwrite any previous values. + $SESSION->logintoken[self::$logintokenkey] = $state; + + return $state; + } + + /** + * Get the current login token or generate a new one. + * + * All login forms generated from Moodle must include a login token + * named "logintoken" with the value being the result of this function. + * Logins will be rejected if they do not include this token as well as + * the username and password fields. + * + * @return string The current login token. + */ + public static function get_login_token() { + global $CFG, $SESSION; + + $state = false; + + if (!isset($SESSION->logintoken)) { + $SESSION->logintoken = []; + } + + if (array_key_exists(self::$logintokenkey, $SESSION->logintoken)) { + $state = $SESSION->logintoken[self::$logintokenkey]; + } + if (empty($state)) { + $state = self::create_login_token(); + } + + // Check token lifespan. + if ($state['created'] < (time() - $CFG->sessiontimeout)) { + $state = self::create_login_token(); + } + + // Return the current session login token. + if (array_key_exists('token', $state)) { + return $state['token']; + } else { + return false; + } + } + + /** + * Check the submitted value against the stored login token. + * + * @param mixed $token The value submitted in the login form that we are validating. + * If false is passed for the token, this function will always return true. + * @return boolean If the submitted token is valid. + */ + public static function validate_login_token($token = false) { + global $CFG; + + if (!empty($CFG->alternateloginurl) || !empty($CFG->disablelogintoken)) { + // An external login page cannot generate the login token we need to protect CSRF on + // login requests. + // Other custom login workflows may skip this check by setting disablelogintoken in config. + return true; + } + if ($token === false) { + // authenticate_user_login is a core function was extended to validate tokens. + // For existing uses other than the login form it does not + // validate that a token was generated. + // Some uses that do not validate the token are login/token.php, + // or an auth plugin like auth/ldap/auth.php. + return true; + } + + $currenttoken = self::get_login_token(); + + // We need to clean the login token so the old one is not valid again. + self::create_login_token(); + + if ($currenttoken !== $token) { + // Fail the login. + return false; + } + return true; + } } diff --git a/lib/moodlelib.php b/lib/moodlelib.php index 00df35da94c..71a93d70084 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -2441,6 +2441,9 @@ function dayofweek($day, $month, $year) { /** * Returns full login url. * + * Any form submissions for authentication to this URL must include username, + * password as well as a logintoken generated by \core\session\manager::get_login_token(). + * * @return string login url */ function get_login_url() { @@ -4141,9 +4144,10 @@ 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. * @return stdClass|false A {@link $USER} object or false if error */ -function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null) { +function authenticate_user_login($username, $password, $ignorelockout=false, &$failurereason=null, $logintoken=false) { global $CFG, $DB; require_once("$CFG->libdir/authlib.php"); @@ -4165,6 +4169,18 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f } } + // Make sure this request came from the login form. + if (!\core\session\manager::validate_login_token($logintoken)) { + $failurereason = AUTH_LOGIN_FAILED; + + // Trigger login failed event. + $event = \core\event\user_login_failed::create(array('userid' => $user->id, + 'other' => array('username' => $username, 'reason' => $failurereason))); + $event->trigger(); + error_log('[client '.getremoteaddr()."] $CFG->wwwroot Invalid Login Token: $username ".$_SERVER['HTTP_USER_AGENT']); + return false; + } + $authsenabled = get_enabled_auth_plugins(); if ($user) { diff --git a/lib/tests/authlib_test.php b/lib/tests/authlib_test.php index 53ad3076306..e59e3833296 100644 --- a/lib/tests/authlib_test.php +++ b/lib/tests/authlib_test.php @@ -200,6 +200,59 @@ class core_authlib_testcase extends advanced_testcase { $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED); $this->assertEventContextNotUsed($event); + // Capture failed login token. + unset($CFG->alternateloginurl); + unset($CFG->disablelogintoken); + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken'); + $events = $sink->get_events(); + $sink->close(); + $event = array_pop($events); + + $this->assertFalse($result); + $this->assertEquals(AUTH_LOGIN_FAILED, $reason); + // Test Event. + $this->assertInstanceOf('\core\event\user_login_failed', $event); + $expectedlogdata = array(SITEID, 'login', 'error', 'index.php', 'username1'); + $this->assertEventLegacyLogData($expectedlogdata, $event); + $eventdata = $event->get_data(); + $this->assertSame($eventdata['other']['username'], 'username1'); + $this->assertSame($eventdata['other']['reason'], AUTH_LOGIN_FAILED); + $this->assertEventContextNotUsed($event); + + // Login should work with invalid token if CFG login token settings override it. + $CFG->alternateloginurl = 'http://localhost/'; + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken'); + $events = $sink->get_events(); + $sink->close(); + $this->assertEmpty($events); + $this->assertInstanceOf('stdClass', $result); + $this->assertEquals(AUTH_LOGIN_OK, $reason); + + unset($CFG->alternateloginurl); + $CFG->disablelogintoken = true; + + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, 'invalidtoken'); + $events = $sink->get_events(); + $sink->close(); + $this->assertEmpty($events); + $this->assertInstanceOf('stdClass', $result); + $this->assertEquals(AUTH_LOGIN_OK, $reason); + + unset($CFG->disablelogintoken); + // Normal login with valid token. + $reason = null; + $token = \core\session\manager::get_login_token(); + $sink = $this->redirectEvents(); + $result = authenticate_user_login('username1', 'password1', false, $reason, $token); + $events = $sink->get_events(); + $sink->close(); + $this->assertEmpty($events); + $this->assertInstanceOf('stdClass', $result); + $this->assertEquals(AUTH_LOGIN_OK, $reason); + $reason = null; // Capture failed login event. $sink = $this->redirectEvents(); diff --git a/login/change_password_form.php b/login/change_password_form.php index 589b45b668d..5b62a1380e0 100644 --- a/login/change_password_form.php +++ b/login/change_password_form.php @@ -80,9 +80,10 @@ class login_change_password_form extends moodleform { function validation($data, $files) { global $USER; $errors = parent::validation($data, $files); + $reason = null; // ignore submitted username - if (!$user = authenticate_user_login($USER->username, $data['password'], true)) { + if (!$user = authenticate_user_login($USER->username, $data['password'], true, $reason, false)) { $errors['password'] = get_string('invalidlogin'); return $errors; } diff --git a/login/index.php b/login/index.php index b158b37d651..2b482c37fff 100644 --- a/login/index.php +++ b/login/index.php @@ -38,6 +38,7 @@ redirect_if_major_upgrade_required(); $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly $cancel = optional_param('cancel', 0, PARAM_BOOL); // redirect to frontpage, needed for loginhttps $anchor = optional_param('anchor', '', PARAM_RAW); // Used to restore hash anchor to wantsurl. +$logintoken = optional_param('logintoken', '', PARAM_RAW); // Used to validate the request. if ($cancel) { redirect(new moodle_url('/')); @@ -151,7 +152,7 @@ if ($frm and isset($frm->username)) { // Login WITH $frm = false; } else { if (empty($errormsg)) { - $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode); + $user = authenticate_user_login($frm->username, $frm->password, false, $errorcode, $logintoken); } } diff --git a/login/index_form.html b/login/index_form.html index 3b8ae587fe9..11f568ae9d7 100644 --- a/login/index_form.html +++ b/login/index_form.html @@ -57,6 +57,7 @@ if (empty($CFG->authloginviaemail)) { " />
+