Skip to content

Commit

Permalink
MDL-63183 auth: Login protection
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Damyon Wiese authored and Jenkins committed Nov 6, 2018
1 parent 15c87e0 commit b427ab4
Show file tree
Hide file tree
Showing 14 changed files with 204 additions and 9 deletions.
4 changes: 4 additions & 0 deletions auth/classes/output/login.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -104,6 +106,7 @@ public function __construct(array $authsequence, $username = '') {

// Identity providers.
$this->identityproviders = \auth_plugin_base::get_identity_providers($authsequence);
$this->logintoken = \core\session\manager::get_login_token();
}

/**
Expand Down Expand Up @@ -136,6 +139,7 @@ public function export_for_template(renderer_base $output) {
$data->rememberusername = $this->rememberusername;
$data->signupurl = $this->signupurl->out(false);
$data->username = $this->username;
$data->logintoken = $this->logintoken;

return $data;
}
Expand Down
3 changes: 2 additions & 1 deletion auth/ldap/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -1725,7 +1725,8 @@ function ntlmsso_finish() {

// 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);

Expand Down
3 changes: 2 additions & 1 deletion auth/shibboleth/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
5 changes: 4 additions & 1 deletion auth/upgrade.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
This files describes API changes in /auth/* - plugins,
information provided here is intended especially for developers.

=== 3.5 ===
=== 3.5.3 ===

* Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token().

=== 3.5 ===
* The auth_db and auth_ldap plugins' implementations of update_user_record() have been removed and both now
call the new implementation added in the base class.
* Self registration plugins should use core_privacy\local\sitepolicy\manager instead of directly checking
Expand Down
1 change: 1 addition & 0 deletions blocks/login/block_login.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class="form-check-input" value="1" '.$checked.'/> ';
$this->content->text .= '<div class="form-group">';
$this->content->text .= '<input type="submit" class="btn btn-primary btn-block" value="'.get_string('login').'" />';
$this->content->text .= '</div>';
$this->content->text .= '<input type="hidden" name="logintoken" value="'.s(\core\session\manager::get_login_token()).'" />';

$this->content->text .= "</form>\n";

Expand Down
5 changes: 5 additions & 0 deletions config-dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,11 @@
//
// $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!!!
//=========================================================================
Expand Down
101 changes: 101 additions & 0 deletions lib/classes/session/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -923,4 +926,102 @@ public static function keepalive($identifier = 'sessionerroruser', $component =
)));
}

/**
* 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;
}
}
18 changes: 17 additions & 1 deletion lib/moodlelib.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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");

Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion lib/templates/loginform.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"rememberusername": true,
"signupurl": "http://localhost/stable_master/login/signup.php",
"cookieshelpiconformatted": "",
"username": ""
"username": "",
"logintoken": "randomstring"
}
}}
{{#hasinstructions}}
Expand Down Expand Up @@ -95,6 +96,7 @@
<div class="clearer"><!-- --></div>
<input id="anchor" type="hidden" name="anchor" value="" />
<script>document.getElementById('anchor').value = location.hash;</script>
<input type="hidden" name="logintoken" value="{{logintoken}}">
<input type="submit" id="loginbtn" value={{#quote}}{{#str}} login {{/str}}{{/quote}} />
<div class="forgetpass">
<a href="{{forgotpasswordurl}}">{{#str}} forgotten {{/str}}</a>
Expand All @@ -113,6 +115,7 @@
<div class="desc">{{#str}} someallowguest {{/str}}</div>
<form action="{{loginurl}}" method="post" id="guestlogin">
<div class="guestform">
<input type="hidden" name="logintoken" value="{{logintoken}}">
<input type="hidden" name="username" value="guest" />
<input type="hidden" name="password" value="guest" />
<input type="submit" value={{#quote}}{{#str}} loginguest {{/str}}{{/quote}} />
Expand Down
53 changes: 53 additions & 0 deletions lib/tests/authlib_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,59 @@ public function test_authenticate_user_login() {
$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();
Expand Down
3 changes: 2 additions & 1 deletion login/change_password_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ function definition() {
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;
}
Expand Down
3 changes: 2 additions & 1 deletion login/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

$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");
Expand Down Expand Up @@ -137,7 +138,7 @@
$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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion login/token.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@

$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.
Expand Down
6 changes: 5 additions & 1 deletion theme/boost/templates/core/loginform.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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):
{
Expand Down Expand Up @@ -87,7 +88,8 @@
"cookieshelpiconformatted": "",
"errorformatted": "",
"logourl": false,
"sitename": "Beer & Chips"
"sitename": "Beer & Chips",
"logintoken": "randomstring"
}
}}

Expand Down Expand Up @@ -121,6 +123,7 @@
<form class="mt-3" action="{{loginurl}}" method="post" id="login">
<input id="anchor" type="hidden" name="anchor" value="">
<script>document.getElementById('anchor').value = location.hash;</script>
<input type="hidden" name="logintoken" value="{{logintoken}}">
<div class="form-group">
<label for="username" class="sr-only">
{{^canloginbyemail}}
Expand Down Expand Up @@ -165,6 +168,7 @@
<div class="mt-2">
<p>{{#str}}someallowguest{{/str}}</p>
<form action="{{loginurl}}" method="post" id="guestlogin">
<input type="hidden" name="logintoken" value="{{logintoken}}">
<input type="hidden" name="username" value="guest" />
<input type="hidden" name="password" value="guest" />
<button class="btn btn-secondary btn-block" type="submit">{{#str}}loginguest{{/str}}</button>
Expand Down

0 comments on commit b427ab4

Please sign in to comment.