Commit ec2d8ceb authored by Simon Coggins's avatar Simon Coggins
Browse files

MDL-35332 lib: Improve security of hashed passwords

parent 63197378
......@@ -618,7 +618,7 @@ if ($formdata = $mform2->is_cancelled()) {
// Do not mess with passwords of remote users.
} else if (!$isinternalauth) {
$existinguser->password = 'not cached';
$existinguser->password = AUTH_PASSWORD_NOT_CACHED;
$upt->track('password', '-', 'normal', false);
// clean up prefs
unset_user_preference('create_password', $existinguser);
......@@ -626,6 +626,8 @@ if ($formdata = $mform2->is_cancelled()) {
} else if (!empty($user->password)) {
if ($updatepasswords) {
// Check for passwords that we want to force users to reset next
// time they log in.
$errmsg = null;
$weak = !check_password_policy($user->password, $errmsg);
if ($resetpasswords == UU_PWRESET_ALL or ($resetpasswords == UU_PWRESET_WEAK and $weak)) {
......@@ -638,7 +640,12 @@ if ($formdata = $mform2->is_cancelled()) {
unset_user_preference('auth_forcepasswordchange', $existinguser);
}
unset_user_preference('create_password', $existinguser); // no need to create password any more
$existinguser->password = hash_internal_user_password($user->password);
// Use a low cost factor when generating bcrypt hash otherwise
// hashing would be slow when uploading lots of users. Hashes
// will be automatically updated to a higher cost factor the first
// time the user logs in.
$existinguser->password = hash_internal_user_password($user->password, true);
$upt->track('password', $user->password, 'normal', false);
} else {
// do not print password when not changed
......@@ -771,10 +778,14 @@ if ($formdata = $mform2->is_cancelled()) {
}
$forcechangepassword = true;
}
$user->password = hash_internal_user_password($user->password);
// Use a low cost factor when generating bcrypt hash otherwise
// hashing would be slow when uploading lots of users. Hashes
// will be automatically updated to a higher cost factor the first
// time the user logs in.
$user->password = hash_internal_user_password($user->password, true);
}
} else {
$user->password = 'not cached';
$user->password = AUTH_PASSWORD_NOT_CACHED;
$upt->track('password', '-', 'normal', false);
}
......
......@@ -221,6 +221,9 @@ class auth_plugin_db extends auth_plugin_base {
if ($this->is_internal()) {
$puser = $DB->get_record('user', array('id'=>$user->id), '*', MUST_EXIST);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
if (update_internal_user_password($puser, $newpassword)) {
$user->password = $puser->password;
return true;
......
......@@ -59,6 +59,9 @@ class auth_plugin_email extends auth_plugin_base {
*/
function user_update_password($user, $newpassword) {
$user = get_complete_user_data('id', $user->id);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
return update_internal_user_password($user, $newpassword);
}
......
......@@ -529,6 +529,9 @@ class auth_plugin_ldap extends auth_plugin_base {
profile_save_data($user);
$this->update_user_record($user->username);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
update_internal_user_password($user, $plainslashedpassword);
$user = $DB->get_record('user', array('id'=>$user->id));
......
......@@ -82,6 +82,9 @@ class auth_plugin_manual extends auth_plugin_base {
*/
function user_update_password($user, $newpassword) {
$user = get_complete_user_data('id', $user->id);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
return update_internal_user_password($user, $newpassword);
}
......
......@@ -59,6 +59,9 @@ class auth_plugin_none extends auth_plugin_base {
*/
function user_update_password($user, $newpassword) {
$user = get_complete_user_data('id', $user->id);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
return update_internal_user_password($user, $newpassword);
}
......
......@@ -85,6 +85,9 @@ class auth_plugin_webservice extends auth_plugin_base {
*/
function user_update_password($user, $newpassword) {
$user = get_complete_user_data('id', $user->id);
// This will also update the stored hash to the latest algorithm
// if the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm).
return update_internal_user_password($user, $newpassword);
}
......
......@@ -1052,7 +1052,7 @@ abstract class restore_dbops {
// Most external plugins do not store passwords locally
if (!empty($userauth->preventpassindb)) {
$user->password = 'not cached';
$user->password = AUTH_PASSWORD_NOT_CACHED;
// If Moodle is responsible for storing/validating pwd and reset functionality is available, mark
} else if ($userauth->isinternal and $userauth->canresetpwd) {
......
......@@ -63,28 +63,7 @@ $CFG->dboptions = array(
//=========================================================================
// 2. SECRET PASSWORD SALT
//=========================================================================
// User password salt is very important security feature, it is created
// automatically in installer, you have to uncomment and modify value
// on the next line if you are creating config.php manually.
//
// $CFG->passwordsaltmain = 'a_very_long_random_string_of_characters#@6&*1';
//
// After changing the main salt you have to copy old value into one
// of the following settings - this allows migration to the new salt
// during the next login of each user.
//
// $CFG->passwordsaltalt1 = '';
// $CFG->passwordsaltalt2 = '';
// $CFG->passwordsaltalt3 = '';
// ....
// $CFG->passwordsaltalt19 = '';
// $CFG->passwordsaltalt20 = '';
//=========================================================================
// 3. WEB SITE LOCATION
// 2. WEB SITE LOCATION
//=========================================================================
// Now you need to tell Moodle where it is located. Specify the full
// web address to where moodle has been installed. If your web site
......@@ -98,7 +77,7 @@ $CFG->wwwroot = 'http://example.com/moodle';
//=========================================================================
// 4. DATA FILES LOCATION
// 3. DATA FILES LOCATION
//=========================================================================
// Now you need a place where Moodle can save uploaded files. This
// directory should be readable AND WRITEABLE by the web server user
......@@ -114,7 +93,7 @@ $CFG->dataroot = '/home/example/moodledata';
//=========================================================================
// 5. DATA FILES PERMISSIONS
// 4. DATA FILES PERMISSIONS
//=========================================================================
// The following parameter sets the permissions of new directories
// created by Moodle within the data directory. The format is in
......@@ -128,7 +107,7 @@ $CFG->directorypermissions = 02777;
//=========================================================================
// 6. DIRECTORY LOCATION (most people can just ignore this setting)
// 5. DIRECTORY LOCATION (most people can just ignore this setting)
//=========================================================================
// A very few webhosts use /admin as a special URL for you to access a
// control panel or something. Unfortunately this conflicts with the
......@@ -140,7 +119,7 @@ $CFG->admin = 'admin';
//=========================================================================
// 7. OTHER MISCELLANEOUS SETTINGS (ignore these for new installations)
// 6. OTHER MISCELLANEOUS SETTINGS (ignore these for new installations)
//=========================================================================
//
// These are additional tweaks for which no GUI exists in Moodle yet.
......@@ -471,7 +450,7 @@ $CFG->admin = 'admin';
// $CFG->svgicons = false;
//
//=========================================================================
// 8. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
// 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
//=========================================================================
//
// Force a debugging mode regardless the settings in the site administration
......@@ -512,7 +491,7 @@ $CFG->admin = 'admin';
// $CFG->showcrondebugging = true;
//
//=========================================================================
// 9. FORCED SETTINGS
// 8. FORCED SETTINGS
//=========================================================================
// It is possible to specify normal admin settings here, the point is that
// they can not be changed through the standard admin settings pages any more.
......@@ -527,12 +506,35 @@ $CFG->admin = 'admin';
// 'otherplugin' => array('mysetting' => 'myvalue', 'thesetting' => 'thevalue'));
//
//=========================================================================
// 10. PHPUNIT SUPPORT
// 9. PHPUNIT SUPPORT
//=========================================================================
// $CFG->phpunit_prefix = 'phpu_';
// $CFG->phpunit_dataroot = '/home/example/phpu_moodledata';
// $CFG->phpunit_directorypermissions = 02777; // optional
//
//
//=========================================================================
// 10. SECRET PASSWORD SALT
//=========================================================================
// A single site-wide password salt is no longer required *unless* you are
// upgrading an older version of Moodle (prior to 2.5), or if you are using
// a PHP version below 5.3.7. If upgrading, keep any values from your old
// config.php file. If you are using PHP < 5.3.7 set to a long random string
// below:
//
// $CFG->passwordsaltmain = 'a_very_long_random_string_of_characters#@6&*1';
//
// You may also have some alternative salts to allow migration from previously
// used salts.
//
// $CFG->passwordsaltalt1 = '';
// $CFG->passwordsaltalt2 = '';
// $CFG->passwordsaltalt3 = '';
// ....
// $CFG->passwordsaltalt19 = '';
// $CFG->passwordsaltalt20 = '';
//
//
//=========================================================================
// 11. BEHAT SUPPORT
//=========================================================================
......
......@@ -216,7 +216,11 @@ function cron_run() {
// note: we can not send emails to suspended accounts
foreach ($newusers as $newuser) {
if (setnew_password_and_mail($newuser)) {
// Use a low cost factor when generating bcrypt hash otherwise
// hashing would be slow when emailing lots of users. Hashes
// will be automatically updated to a higher cost factor the first
// time the user logs in.
if (setnew_password_and_mail($newuser, true)) {
unset_user_preference('create_password', $newuser);
set_user_preference('auth_forcepasswordchange', 1, $newuser);
} else {
......
......@@ -753,7 +753,7 @@
<FIELD NAME="suspended" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="suspended flag prevents users to log in"/>
<FIELD NAME="mnethostid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="username" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="password" TYPE="char" LENGTH="32" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="password" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="idnumber" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="firstname" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="lastname" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
......
......@@ -1564,6 +1564,18 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2012120300.07);
}
if ($oldversion < 2013020900.00) {
// Changing precision of field password on table user to (255).
$table = new xmldb_table('user');
$field = new xmldb_field('password', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null, 'username');
// Launch change of precision for field password.
$dbman->change_field_precision($table, $field);
// Main savepoint reached.
upgrade_main_savepoint(true, 2013020900.00);
}
return true;
}
......@@ -233,7 +233,10 @@ function install_generate_configphp($database, $cfg) {
}
$configphp .= '$CFG->directorypermissions = ' . $chmod . ';' . PHP_EOL . PHP_EOL;
$configphp .= '$CFG->passwordsaltmain = '.var_export(complex_random_string(), true) . ';' . PHP_EOL . PHP_EOL;
// A site-wide salt is only needed if bcrypt is not properly supported by the current version of PHP.
if (password_compat_not_supported()) {
$configphp .= '$CFG->passwordsaltmain = '.var_export(complex_random_string(), true) . ';' . PHP_EOL . PHP_EOL;
}
$configphp .= 'require_once(dirname(__FILE__) . \'/lib/setup.php\');' . PHP_EOL . PHP_EOL;
$configphp .= '// There is no php closing tag in this file,' . PHP_EOL;
......
......@@ -493,6 +493,11 @@ define('USER_CAN_IGNORE_FILE_SIZE_LIMITS', -1);
define('COURSE_DISPLAY_SINGLEPAGE', 0); // display all sections on one page
define('COURSE_DISPLAY_MULTIPAGE', 1); // split pages into a page per section
/**
* Authentication constants.
*/
define('AUTH_PASSWORD_NOT_CACHED', 'not cached'); // String used in password field when password is not stored.
/// PARAMETER HANDLING ////////////////////////////////////////////////////
/**
......@@ -3845,6 +3850,7 @@ function create_user_record($username, $password, $auth = 'manual') {
if (!empty($CFG->{'auth_'.$newuser->auth.'_forcechangepassword'})){
set_user_preference('auth_forcepasswordchange', 1, $user);
}
// Set the password.
update_internal_user_password($user, $password);
// fetch full user record for the event, the complete user data contains too much info
......@@ -4197,7 +4203,10 @@ function authenticate_user_login($username, $password, $ignorelockout=false, &$f
$user->auth = $auth;
}
update_internal_user_password($user, $password); // just in case salt or encoding were changed (magic quotes too one day)
// If the existing hash is using an out-of-date algorithm (or the
// legacy md5 algorithm), then we should update to the current
// hash algorithm while we have access to the user's password.
update_internal_user_password($user, $password);
if ($authplugin->is_synchronised_with_external()) { // update user record from external DB
$user = update_user_record($username);
......@@ -4307,28 +4316,81 @@ function complete_user_login($user) {
}
/**
* Compare password against hash stored in internal user table.
* If necessary it also updates the stored hash to new format.
* Check a password hash to see if it was hashed using the
* legacy hash algorithm (md5).
*
* @param string $password String to check.
* @return boolean True if the $password matches the format of an md5 sum.
*/
function password_is_legacy_hash($password) {
return (bool) preg_match('/^[0-9a-f]{32}$/', $password);
}
/**
* Checks whether the password compatibility library will work with the current
* version of PHP. This cannot be done using PHP version numbers since the fix
* has been backported to earlier versions in some distributions.
*
* See https://github.com/ircmaxell/password_compat/issues/10 for
* more details.
*
* @return bool True if the library is NOT supported.
*/
function password_compat_not_supported() {
$hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
// Create a one off application cache to store bcrypt support status as
// the support status doesn't change and crypt() is slow.
$cache = cache::make_from_params(cache_store::MODE_APPLICATION, 'core', 'password_compat');
if (!$bcryptsupport = $cache->get('bcryptsupport')) {
$test = crypt('password', $hash);
// Cache string instead of boolean to avoid MDL-37472.
if ($test == $hash) {
$bcryptsupport = 'supported';
} else {
$bcryptsupport = 'not supported';
}
$cache->set('bcryptsupport', $bcryptsupport);
}
// Return true if bcrypt *not* supported.
return ($bcryptsupport !== 'supported');
}
/**
* Compare password against hash stored in user object to determine if it is valid.
*
* If necessary it also updates the stored hash to the current format.
*
* @param stdClass $user (password property may be updated)
* @param string $password plain text password
* @return bool is password valid?
* @param stdClass $user (Password property may be updated).
* @param string $password Plain text password.
* @return bool True if password is valid.
*/
function validate_internal_user_password($user, $password) {
global $CFG;
require_once($CFG->libdir.'/password_compat/lib/password.php');
if (!isset($CFG->passwordsaltmain)) {
$CFG->passwordsaltmain = '';
if ($user->password === AUTH_PASSWORD_NOT_CACHED) {
// Internal password is not used at all, it can not validate.
return false;
}
$validated = false;
// If hash isn't a legacy (md5) hash, validate using the library function.
if (!password_is_legacy_hash($user->password)) {
return password_verify($password, $user->password);
}
if ($user->password === 'not cached') {
// internal password is not used at all, it can not validate
// Otherwise we need to check for a legacy (md5) hash instead. If the hash
// is valid we can then update it to the new algorithm.
} else if ($user->password === md5($password.$CFG->passwordsaltmain)
$sitesalt = isset($CFG->passwordsaltmain) ? $CFG->passwordsaltmain : '';
$validated = false;
if ($user->password === md5($password.$sitesalt)
or $user->password === md5($password)
or $user->password === md5(addslashes($password).$CFG->passwordsaltmain)
or $user->password === md5(addslashes($password).$sitesalt)
or $user->password === md5(addslashes($password))) {
// note: we are intentionally using the addslashes() here because we
// need to accept old password hashes of passwords with magic quotes
......@@ -4347,7 +4409,8 @@ function validate_internal_user_password($user, $password) {
}
if ($validated) {
// force update of password hash using latest main password salt and encoding if needed
// If the password matches the existing md5 hash, update to the
// current hash algorithm while we have access to the user's password.
update_internal_user_password($user, $password);
}
......@@ -4355,39 +4418,85 @@ function validate_internal_user_password($user, $password) {
}
/**
* Calculate hashed value from password using current hash mechanism.
* Calculate hash for a plain text password.
*
* @param string $password Plain text password to be hashed.
* @param bool $fasthash If true, use a low cost factor when generating the hash
* This is much faster to generate but makes the hash
* less secure. It is used when lots of hashes need to
* be generated quickly.
* @return string The hashed password.
*
* @param string $password
* @return string password hash
* @throws moodle_exception If a problem occurs while generating the hash.
*/
function hash_internal_user_password($password) {
function hash_internal_user_password($password, $fasthash = false) {
global $CFG;
require_once($CFG->libdir.'/password_compat/lib/password.php');
if (isset($CFG->passwordsaltmain)) {
return md5($password.$CFG->passwordsaltmain);
} else {
return md5($password);
// Use the legacy hashing algorithm (md5) if PHP is not new enough
// to support bcrypt properly
if (password_compat_not_supported()) {
if (isset($CFG->passwordsaltmain)) {
return md5($password.$CFG->passwordsaltmain);
} else {
return md5($password);
}
}
// Set the cost factor to 4 for fast hashing, otherwise use default cost.
$options = ($fasthash) ? array('cost' => 4) : array();
$generatedhash = password_hash($password, PASSWORD_DEFAULT, $options);
if ($generatedhash === false) {
throw new moodle_exception('Failed to generate password hash.');
}
return $generatedhash;
}
/**
* Update password hash in user object.
* Update password hash in user object (if necessary).
*
* @param stdClass $user (password property may be updated)
* @param string $password plain text password
* @return bool always returns true
* The password is updated if:
* 1. The password has changed (the hash of $user->password is different
* to the hash of $password).
* 2. The existing hash is using an out-of-date algorithm (or the legacy
* md5 algorithm).
*
* Updating the password will modify the $user object and the database
* record to use the current hashing algorithm.
*
* @param stdClass $user User object (password property may be updated).
* @param string $password Plain text password.
* @return bool Always returns true.
*/
function update_internal_user_password($user, $password) {
global $DB;
global $CFG, $DB;
require_once($CFG->libdir.'/password_compat/lib/password.php');
// Use the legacy hashing algorithm (md5) if PHP doesn't support
// bcrypt properly.
$legacyhash = password_compat_not_supported();
// Figure out what the hashed password should be.
$authplugin = get_auth_plugin($user->auth);
if ($authplugin->prevent_local_passwords()) {
$hashedpassword = 'not cached';
$hashedpassword = AUTH_PASSWORD_NOT_CACHED;
} else {
$hashedpassword = hash_internal_user_password($password);
}
if ($user->password !== $hashedpassword) {
if ($legacyhash) {
$passwordchanged = ($user->password !== $hashedpassword);
$algorithmchanged = false;
} else {
// If verification fails then it means the password has changed.
$passwordchanged = !password_verify($password, $user->password);
$algorithmchanged = password_needs_rehash($user->password, PASSWORD_DEFAULT);
}
if ($passwordchanged || $algorithmchanged) {
$DB->set_field('user', 'password', $hashedpassword, array('id'=>$user->id));
$user->password = $hashedpassword;
}
......@@ -5588,9 +5697,10 @@ function generate_email_supportuser() {
* @global object
* @global object
* @param user $user A {@link $USER} object
* @param boolean $fasthash If true, use a low cost factor when generating the hash for speed.
* @return boolean|string Returns "true" if mail was sent OK and "false" if there was an error
*/
function setnew_password_and_mail($user) {
function setnew_password_and_mail($user, $fasthash = false) {
global $CFG, $DB;
// we try to send the mail in language the user understands,
......@@ -5604,7 +5714,8 @@ function setnew_password_and_mail($user) {
$newpassword = generate_password();
$DB->set_field('user', 'password', hash_internal_user_password($newpassword), array('id'=>$user->id));
$hashedpassword = hash_internal_user_password($newpassword, $fasthash);
$DB->set_field('user', 'password', $hashedpassword, array('id'=>$user->id));
$a = new stdClass();
$a->firstname = fullname($user, true);
......
<?php
/**
* A Compatibility library with PHP 5.5's simplified password hashing API.
*
* @author Anthony Ferrara <ircmaxell@php.net>
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @copyright 2012 The Authors
*/
if (!defined('PASSWORD_BCRYPT')) {
define('PASSWORD_BCRYPT', 1);
define('PASSWORD_DEFAULT', PASSWORD_BCRYPT);
/**
* Hash the password using the specified algorithm
*
* @param string $password The password to hash
* @param int $algo The algorithm to use (Defined by PASSWORD_* constants)
* @param array $options The options for the algorithm to use
*
* @return string|false The hashed password, or false on error.
*/
function password_hash($password, $algo, array $options = array()) {
if (!function_exists('crypt')) {
trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING);
return null;
}
if (!is_string($password)) {
trigger_error("password_hash(): Password must be a string", E_USER_WARNING);
return null;
}
if (!is_int($algo)) {
trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING);
return null;
}
switch ($algo) {
case PASSWORD_BCRYPT:
// Note that this is a C constant, but not exposed to PHP, so we don't define it here.
$cost = 10;
if (isset($options['cost'])) {
$cost = $options['cost'];
if ($cost < 4 || $cost > 31) {
trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING);
return null;
}
}
$required_salt_len = 22;
$hash_format = sprintf("$2y$%02d$", $cost);
break;
default:
trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING);
return null;
}
if (isset($options['salt'])) {
switch (gettype($options['salt'])) {
case 'NULL':
case 'boolean':
case 'integer':
case 'double':
case 'string':
$salt = (string) $options['salt'];
break;
case 'object':
if (method_exists($options['salt'], '__tostring')) {
$salt = (string) $options['salt'];
break;
}
case 'array':