From 8f3b93f686becb1fc730d5dbaca14d9aa86ce942 Mon Sep 17 00:00:00 2001 From: Damyon Wiese Date: Wed, 29 Aug 2018 09:38:46 +0800 Subject: [PATCH] MDL-63183 auth: Login protection CSRF protection for the login form. The authenticate_user_login function was extended to validate the token (in \core\session\manager) but by default it does not perform the extra validation. Existing uses of this function from auth plugins and features like "change password" will continue to work without changes. New config value $CFG->disablelogintoken can bypass this check. --- auth/classes/output/login.php | 4 + auth/ldap/auth.php | 3 +- auth/shibboleth/index.php | 3 +- auth/upgrade.txt | 4 + blocks/login/block_login.php | 1 + config-dist.php | 5 + lib/classes/session/manager.php | 101 ++++++++++++++++++ lib/moodlelib.php | 18 +++- lib/templates/loginform.mustache | 5 +- lib/tests/authlib_test.php | 53 +++++++++ login/change_password_form.php | 3 +- login/index.php | 3 +- login/token.php | 3 +- theme/boost/templates/core/loginform.mustache | 6 +- 14 files changed, 204 insertions(+), 8 deletions(-) diff --git a/auth/classes/output/login.php b/auth/classes/output/login.php index 9876ea9b109..e80ad1e3aea 100644 --- a/auth/classes/output/login.php +++ b/auth/classes/output/login.php @@ -70,6 +70,8 @@ class login implements renderable, templatable { public $signupurl; /** @var string The user name to pre-fill the form with. */ public $username; + /** @var string The csrf token to limit login to requests that come from the login form. */ + public $logintoken; /** * Constructor. @@ -104,6 +106,7 @@ class login implements renderable, templatable { // Identity providers. $this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence); + $this->logintoken = \core\session\manager::get_login_token(); } /** @@ -136,6 +139,7 @@ class login implements renderable, templatable { $data->rememberusername = $this->rememberusername; $data->signupurl = $this->signupurl->out(false); $data->username = $this->username; + $data->logintoken = $this->logintoken; return $data; } diff --git a/auth/ldap/auth.php b/auth/ldap/auth.php index a1df7f72bbe..e6e6e53bb27 100644 --- a/auth/ldap/auth.php +++ b/auth/ldap/auth.php @@ -1828,7 +1828,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 07b5ce72d26..b177f7c9b62 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 4d8bf95fb64..17e6b694db0 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.4.6 === + +* Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token(). + === 3.4.2 === * Self registration plugins should use core_privacy\local\sitepolicy\manager instead of directly checking diff --git a/blocks/login/block_login.php b/blocks/login/block_login.php index e17aea1aa10..67d0d0341df 100644 --- a/blocks/login/block_login.php +++ b/blocks/login/block_login.php @@ -86,6 +86,7 @@ class block_login extends block_base { $this->content->text .= '
'; $this->content->text .= ''; $this->content->text .= '
'; + $this->content->text .= ''; $this->content->text .= "\n"; diff --git a/config-dist.php b/config-dist.php index 4b80cef54bb..e817c53675c 100644 --- a/config-dist.php +++ b/config-dist.php @@ -578,6 +578,11 @@ $CFG->admin = 'admin'; // // $CFG->pdfexportfont = 'freesans'; // +// 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 6568e4b9b22..a84424c6945 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. * @@ -923,4 +926,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 5d2bb8f926e..b3ed01dd0a0 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -2484,6 +2484,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() { @@ -4219,9 +4222,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"); @@ -4243,6 +4247,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/templates/loginform.mustache b/lib/templates/loginform.mustache index 616e8606499..7aa51a62cfc 100644 --- a/lib/templates/loginform.mustache +++ b/lib/templates/loginform.mustache @@ -35,7 +35,8 @@ "rememberusername": true, "signupurl": "http://localhost/stable_master/login/signup.php", "cookieshelpiconformatted": "", - "username": "" + "username": "", + "logintoken": "randomstring" } }} {{#hasinstructions}} @@ -95,6 +96,7 @@
+
{{#str}} forgotten {{/str}} @@ -113,6 +115,7 @@
{{#str}} someallowguest {{/str}}
+ 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 66654f30775..50646907b89 100644 --- a/login/change_password_form.php +++ b/login/change_password_form.php @@ -85,9 +85,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 9ab9abb7813..143c7b40a06 100644 --- a/login/index.php +++ b/login/index.php @@ -31,6 +31,7 @@ redirect_if_major_upgrade_required(); $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly $anchor = optional_param('anchor', '', PARAM_RAW); // Used to restore hash anchor to wantsurl. +$logintoken = optional_param('logintoken', '', PARAM_RAW); // Used to validate the request. $context = context_system::instance(); $PAGE->set_url("$CFG->wwwroot/login/index.php"); @@ -137,7 +138,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/token.php b/login/token.php index a67b4b1c3cf..bf6fec5d200 100644 --- a/login/token.php +++ b/login/token.php @@ -47,7 +47,8 @@ if (is_restored_user($username)) { $systemcontext = context_system::instance(); -$user = authenticate_user_login($username, $password); +$reason = null; +$user = authenticate_user_login($username, $password, false, $reason, false); if (!empty($user)) { // Cannot authenticate unless maintenance access is granted. diff --git a/theme/boost/templates/core/loginform.mustache b/theme/boost/templates/core/loginform.mustache index 65d21395962..1e0fcdfe2b8 100644 --- a/theme/boost/templates/core/loginform.mustache +++ b/theme/boost/templates/core/loginform.mustache @@ -39,6 +39,7 @@ * errorformatted - Formatted error, * logourl - Flag, logo url, * sitename - Name of site. + * logintoken - Random token to protect login request. Example context (json): { @@ -87,7 +88,8 @@ "cookieshelpiconformatted": "", "errorformatted": "", "logourl": false, - "sitename": "Beer & Chips" + "sitename": "Beer & Chips", + "logintoken": "randomstring" } }} @@ -124,6 +126,7 @@ +