diff --git a/admin/tool/uploaduser/index.php b/admin/tool/uploaduser/index.php index f249f1dd9ca34..084d8dcd2911b 100644 --- a/admin/tool/uploaduser/index.php +++ b/admin/tool/uploaduser/index.php @@ -618,7 +618,7 @@ // 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 @@ } 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 @@ 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 @@ } $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); } diff --git a/auth/db/auth.php b/auth/db/auth.php index 57c46e43957c5..f245d60febbba 100644 --- a/auth/db/auth.php +++ b/auth/db/auth.php @@ -221,6 +221,9 @@ function user_update_password($user, $newpassword) { 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; diff --git a/auth/email/auth.php b/auth/email/auth.php index e50c09e44ee87..777fe9f186653 100644 --- a/auth/email/auth.php +++ b/auth/email/auth.php @@ -59,6 +59,9 @@ function user_login ($username, $password) { */ 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); } diff --git a/auth/ldap/auth.php b/auth/ldap/auth.php index 6cd7fd7a73f63..9bdd5b37b6432 100644 --- a/auth/ldap/auth.php +++ b/auth/ldap/auth.php @@ -529,6 +529,9 @@ function user_signup($user, $notify=true) { 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)); diff --git a/auth/manual/auth.php b/auth/manual/auth.php index 29cb59ae2ef09..0c521e86cf657 100644 --- a/auth/manual/auth.php +++ b/auth/manual/auth.php @@ -82,6 +82,9 @@ function user_login($username, $password) { */ 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); } diff --git a/auth/none/auth.php b/auth/none/auth.php index f0771d8f58308..00eaf04c1a72a 100644 --- a/auth/none/auth.php +++ b/auth/none/auth.php @@ -59,6 +59,9 @@ function user_login ($username, $password) { */ 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); } diff --git a/auth/webservice/auth.php b/auth/webservice/auth.php index 15a7598056981..59e1603ddb676 100644 --- a/auth/webservice/auth.php +++ b/auth/webservice/auth.php @@ -85,6 +85,9 @@ function user_login_webservice($username, $password) { */ 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); } diff --git a/backup/util/dbops/restore_dbops.class.php b/backup/util/dbops/restore_dbops.class.php index 82c48972713a1..148afa346c4df 100644 --- a/backup/util/dbops/restore_dbops.class.php +++ b/backup/util/dbops/restore_dbops.class.php @@ -1052,7 +1052,7 @@ public static function create_included_users($basepath, $restoreid, $userid) { // 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) { diff --git a/config-dist.php b/config-dist.php index 88a2c5b274f20..0cdcc5f0243bb 100644 --- a/config-dist.php +++ b/config-dist.php @@ -63,28 +63,7 @@ //========================================================================= -// 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 @@ //========================================================================= -// 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 @@ //========================================================================= -// 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 @@ //========================================================================= -// 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 @@ //========================================================================= -// 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->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->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 @@ // '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 //========================================================================= diff --git a/lib/cronlib.php b/lib/cronlib.php index 79e808bd4d3e9..0ae0ae3a58fea 100644 --- a/lib/cronlib.php +++ b/lib/cronlib.php @@ -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 { diff --git a/lib/db/install.xml b/lib/db/install.xml index d4eb4fc21e01c..1602999f3d5db 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -753,7 +753,7 @@ - + diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 3dcc1d17e6f02..12efe345dc85d 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -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; } diff --git a/lib/installlib.php b/lib/installlib.php index 7b8cca2c3124f..30e129770f19a 100644 --- a/lib/installlib.php +++ b/lib/installlib.php @@ -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; diff --git a/lib/moodlelib.php b/lib/moodlelib.php index cd7b102685c71..0df4307793948 100644 --- a/lib/moodlelib.php +++ b/lib/moodlelib.php @@ -493,6 +493,11 @@ 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); diff --git a/lib/password_compat/lib/password.php b/lib/password_compat/lib/password.php new file mode 100644 index 0000000000000..6732e9fe62904 --- /dev/null +++ b/lib/password_compat/lib/password.php @@ -0,0 +1,220 @@ + + * @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': + case 'resource': + default: + trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + return null; + } + if (strlen($salt) < $required_salt_len) { + trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", strlen($salt), $required_salt_len), E_USER_WARNING); + return null; + } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { + $salt = str_replace('+', '.', base64_encode($salt)); + } + } else { + $buffer = ''; + $raw_length = (int) ($required_salt_len * 3 / 4 + 1); + $buffer_valid = false; + if (function_exists('mcrypt_create_iv')) { + $buffer = mcrypt_create_iv($raw_length, MCRYPT_DEV_URANDOM); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { + $buffer = openssl_random_pseudo_bytes($raw_length); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && file_exists('/dev/urandom')) { + $f = @fopen('/dev/urandom', 'r'); + if ($f) { + $read = strlen($buffer); + while ($read < $raw_length) { + $buffer .= fread($f, $raw_length - $read); + $read = strlen($buffer); + } + fclose($f); + if ($read >= $raw_length) { + $buffer_valid = true; + } + } + } + if (!$buffer_valid || strlen($buffer) < $raw_length) { + $bl = strlen($buffer); + for ($i = 0; $i < $raw_length; $i++) { + if ($i < $bl) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } + } + } + $salt = str_replace('+', '.', base64_encode($buffer)); + + } + $salt = substr($salt, 0, $required_salt_len); + + $hash = $hash_format . $salt; + + $ret = crypt($password, $hash); + + if (!is_string($ret) || strlen($ret) <= 13) { + return false; + } + + return $ret; + } + + /** + * Get information about the password hash. Returns an array of the information + * that was used to generate the password hash. + * + * array( + * 'algo' => 1, + * 'algoName' => 'bcrypt', + * 'options' => array( + * 'cost' => 10, + * ), + * ) + * + * @param string $hash The password hash to extract info from + * + * @return array The array of information about the hash. + */ + function password_get_info($hash) { + $return = array( + 'algo' => 0, + 'algoName' => 'unknown', + 'options' => array(), + ); + if (substr($hash, 0, 4) == '$2y$' && strlen($hash) == 60) { + $return['algo'] = PASSWORD_BCRYPT; + $return['algoName'] = 'bcrypt'; + list($cost) = sscanf($hash, "$2y$%d$"); + $return['options']['cost'] = $cost; + } + return $return; + } + + /** + * Determine if the password hash needs to be rehashed according to the options provided + * + * If the answer is true, after validating the password using password_verify, rehash it. + * + * @param string $hash The hash to test + * @param int $algo The algorithm used for new password hashes + * @param array $options The options array passed to password_hash + * + * @return boolean True if the password needs to be rehashed. + */ + function password_needs_rehash($hash, $algo, array $options = array()) { + $info = password_get_info($hash); + if ($info['algo'] != $algo) { + return true; + } + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = isset($options['cost']) ? $options['cost'] : 10; + if ($cost != $info['options']['cost']) { + return true; + } + break; + } + return false; + } + + /** + * Verify a password against a hash using a timing attack resistant approach + * + * @param string $password The password to verify + * @param string $hash The hash to verify against + * + * @return boolean If the password matches the hash + */ + function password_verify($password, $hash) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); + return false; + } + $ret = crypt($password, $hash); + if (!is_string($ret) || strlen($ret) != strlen($hash) || strlen($ret) <= 13) { + return false; + } + + $status = 0; + for ($i = 0; $i < strlen($ret); $i++) { + $status |= (ord($ret[$i]) ^ ord($hash[$i])); + } + + return $status === 0; + } +} diff --git a/lib/password_compat/readme_moodle.txt b/lib/password_compat/readme_moodle.txt new file mode 100644 index 0000000000000..9740c2eb8900d --- /dev/null +++ b/lib/password_compat/readme_moodle.txt @@ -0,0 +1,37 @@ +Description of password_compat import into Moodle: +================================================== + +Imported from: https://github.com/ircmaxell/password_compat/commit/2a7b6355d27c65f7e0de1fbbc0016b5b6cd8226b +Copyright: (c) 2012 Anthony Ferrara +License: MIT License + +Removed: +* README.md, LICENSE.md and composer.json files. +* bootstrap.php and phpunit.xml.dist files from test directory. + +Added: +* None. + +Our changes: +* Moved tests from test/Unit/ to tests/ directory. +* Removed tabs and trailing whitespace from test files. +* Added markTestSkipped() check to tests so they only run if password_compat is supported + +Moodle commit history: +====================== + +MDL-35332 Initial commit + + +Library description: +==================== + +Compatibility with the password_* functions being worked on for PHP 5.5. + +This library requires PHP >= 5.3.7 due to a PHP security issue prior to that +version. + +See the RFC (https://wiki.php.net/rfc/password_hash) for more information. + +Latest code available from https://github.com/ircmaxell/password_compat/ +under MIT license. diff --git a/lib/password_compat/tests/PasswordGetInfoTest.php b/lib/password_compat/tests/PasswordGetInfoTest.php new file mode 100644 index 0000000000000..215cd3c46ddba --- /dev/null +++ b/lib/password_compat/tests/PasswordGetInfoTest.php @@ -0,0 +1,36 @@ +dirroot . '/lib/password_compat/lib/password.php'); + +class PasswordGetInfoTest extends PHPUnit_Framework_TestCase { + + protected function setUp() { + if (password_compat_not_supported()) { + // Skip test if password_compat is not supported. + $this->markTestSkipped('password_compat not supported'); + } + } + + public static function provideInfo() { + return array( + array('foo', array('algo' => 0, 'algoName' => 'unknown', 'options' => array())), + array('$2y$', array('algo' => 0, 'algoName' => 'unknown', 'options' => array())), + array('$2y$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi', array('algo' => PASSWORD_BCRYPT, 'algoName' => 'bcrypt', 'options' => array('cost' => 7))), + array('$2y$10$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi', array('algo' => PASSWORD_BCRYPT, 'algoName' => 'bcrypt', 'options' => array('cost' => 10))), + + ); + } + + public function testFuncExists() { + $this->assertTrue(function_exists('password_get_info')); + } + + /** + * @dataProvider provideInfo + */ + public function testInfo($hash, $info) { + $this->assertEquals($info, password_get_info($hash)); + } + +} diff --git a/lib/password_compat/tests/PasswordHashTest.php b/lib/password_compat/tests/PasswordHashTest.php new file mode 100644 index 0000000000000..010ea341ec35c --- /dev/null +++ b/lib/password_compat/tests/PasswordHashTest.php @@ -0,0 +1,94 @@ +dirroot . '/lib/password_compat/lib/password.php'); + +class PasswordHashTest extends PHPUnit_Framework_TestCase { + + protected function setUp() { + if (password_compat_not_supported()) { + // Skip test if password_compat is not supported. + $this->markTestSkipped('password_compat not supported'); + } + } + + public function testFuncExists() { + $this->assertTrue(function_exists('password_hash')); + } + + public function testStringLength() { + $this->assertEquals(60, strlen(password_hash('foo', PASSWORD_BCRYPT))); + } + + public function testHash() { + $hash = password_hash('foo', PASSWORD_BCRYPT); + $this->assertEquals($hash, crypt('foo', $hash)); + } + + public function testKnownSalt() { + $hash = password_hash("rasmuslerdorf", PASSWORD_BCRYPT, array("cost" => 7, "salt" => "usesomesillystringforsalt")); + $this->assertEquals('$2y$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi', $hash); + } + + public function testRawSalt() { + $hash = password_hash("test", PASSWORD_BCRYPT, array("salt" => "123456789012345678901" . chr(0))); + $this->assertEquals('$2y$10$MTIzNDU2Nzg5MDEyMzQ1Nej0NmcAWSLR.oP7XOR9HD/vjUuOj100y', $hash); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidAlgo() { + password_hash('foo', array()); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidAlgo2() { + password_hash('foo', 2); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidPassword() { + password_hash(array(), 1); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidSalt() { + password_hash('foo', PASSWORD_BCRYPT, array('salt' => array())); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidBcryptCostLow() { + password_hash('foo', PASSWORD_BCRYPT, array('cost' => 3)); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidBcryptCostHigh() { + password_hash('foo', PASSWORD_BCRYPT, array('cost' => 32)); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidBcryptCostInvalid() { + password_hash('foo', PASSWORD_BCRYPT, array('cost' => 'foo')); + } + + /** + * @expectedException PHPUnit_Framework_Error + */ + public function testInvalidBcryptSaltShort() { + password_hash('foo', PASSWORD_BCRYPT, array('salt' => 'abc')); + } + +} diff --git a/lib/password_compat/tests/PasswordNeedsRehashTest.php b/lib/password_compat/tests/PasswordNeedsRehashTest.php new file mode 100644 index 0000000000000..f6fa0e36b1d45 --- /dev/null +++ b/lib/password_compat/tests/PasswordNeedsRehashTest.php @@ -0,0 +1,36 @@ +dirroot . '/lib/password_compat/lib/password.php'); + +class PasswordNeedsRehashTest extends PHPUnit_Framework_TestCase { + + protected function setUp() { + if (password_compat_not_supported()) { + // Skip test if password_compat is not supported. + $this->markTestSkipped('password_compat not supported'); + } + } + + public static function provideCases() { + return array( + array('foo', 0, array(), false), + array('foo', 1, array(), true), + array('$2y$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi', PASSWORD_BCRYPT, array(), true), + array('$2y$07$usesomesillystringfore2udlvp1ii2e./u9c8sbjqp8i90dh6hi', PASSWORD_BCRYPT, array('cost' => 7), false), + array('$2y$07$usesomesillystringfore2udlvp1ii2e./u9c8sbjqp8i90dh6hi', PASSWORD_BCRYPT, array('cost' => 5), true), + ); + } + + public function testFuncExists() { + $this->assertTrue(function_exists('password_needs_rehash')); + } + + /** + * @dataProvider provideCases + */ + public function testCases($hash, $algo, $options, $valid) { + $this->assertEquals($valid, password_needs_rehash($hash, $algo, $options)); + } + +} diff --git a/lib/password_compat/tests/PasswordVerifyTest.php b/lib/password_compat/tests/PasswordVerifyTest.php new file mode 100644 index 0000000000000..80b007b61b7fb --- /dev/null +++ b/lib/password_compat/tests/PasswordVerifyTest.php @@ -0,0 +1,39 @@ +dirroot . '/lib/password_compat/lib/password.php'); + +class PasswordVerifyTest extends PHPUnit_Framework_TestCase { + + protected function setUp() { + if (password_compat_not_supported()) { + // Skip test if password_compat is not supported. + $this->markTestSkipped('password_compat not supported'); + } + } + + public function testFuncExists() { + $this->assertTrue(function_exists('password_verify')); + } + + public function testFailedType() { + $this->assertFalse(password_verify(123, 123)); + } + + public function testSaltOnly() { + $this->assertFalse(password_verify('foo', '$2a$07$usesomesillystringforsalt$')); + } + + public function testInvalidPassword() { + $this->assertFalse(password_verify('rasmusler', '$2a$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi')); + } + + public function testValidPassword() { + $this->assertTrue(password_verify('rasmuslerdorf', '$2a$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hi')); + } + + public function testInValidHash() { + $this->assertFalse(password_verify('rasmuslerdorf', '$2a$07$usesomesillystringfore2uDLvp1Ii2e./U9C8sBjqp8I90dH6hj')); + } + +} diff --git a/lib/phpunit/bootstrap.php b/lib/phpunit/bootstrap.php index 94031b9fd1ff7..560d7fef1007e 100644 --- a/lib/phpunit/bootstrap.php +++ b/lib/phpunit/bootstrap.php @@ -189,8 +189,6 @@ ini_set('display_errors', '1'); ini_set('log_errors', '1'); -$CFG->passwordsaltmain = 'phpunit'; // makes login via normal UI impossible - $CFG->noemailever = true; // better not mail anybody from tests, override temporarily if necessary $CFG->cachetext = 0; // disable this very nasty setting diff --git a/lib/setuplib.php b/lib/setuplib.php index 88fd5fdad3d2c..68c63b790b512 100644 --- a/lib/setuplib.php +++ b/lib/setuplib.php @@ -1140,7 +1140,7 @@ function disable_output_buffering() { */ function redirect_if_major_upgrade_required() { global $CFG; - $lastmajordbchanges = 2012110201; + $lastmajordbchanges = 2013020900; if (empty($CFG->version) or (int)$CFG->version < $lastmajordbchanges or during_initial_install() or !empty($CFG->adminsetuppending)) { try { diff --git a/lib/tests/moodlelib_test.php b/lib/tests/moodlelib_test.php index 45f1fa49489d2..703d20e504b4c 100644 --- a/lib/tests/moodlelib_test.php +++ b/lib/tests/moodlelib_test.php @@ -2245,4 +2245,118 @@ public function test_get_config() { set_config('phpunit_test_get_config_4', 'test c', 'mod_forum'); $this->assertFalse($cache->get('mod_forum')); } + + /** + * Test function password_is_legacy_hash(). + */ + public function test_password_is_legacy_hash() { + // Well formed md5s should be matched. + foreach (array('some', 'strings', 'to_check!') as $string) { + $md5 = md5($string); + $this->assertTrue(password_is_legacy_hash($md5)); + } + // Strings that are not md5s should not be matched. + foreach (array('', AUTH_PASSWORD_NOT_CACHED, 'IPW8WTcsWNgAWcUS1FBVHegzJnw5M2jOmYkmfc8z.xdBOyC4Caeum') as $notmd5) { + $this->assertFalse(password_is_legacy_hash($notmd5)); + } + } + + /** + * Test function validate_internal_user_password(). + */ + public function test_validate_internal_user_password() { + if (password_compat_not_supported()) { + // If bcrypt is not properly supported test legacy md5 hashes instead. + // Can't hardcode these as we don't know the site's password salt. + $validhashes = array( + 'pw' => hash_internal_user_password('pw'), + 'abc' => hash_internal_user_password('abc'), + 'C0mP1eX_&} hash_internal_user_password('C0mP1eX_&} hash_internal_user_password('ĩńťėŕňăţĩōŋāĹ') + ); + } else { + // Otherwise test bcrypt hashes. + $validhashes = array( + 'pw' => '$2y$10$LOSDi5eaQJhutSRun.OVJ.ZSxQZabCMay7TO1KmzMkDMPvU40zGXK', + 'abc' => '$2y$10$VWTOhVdsBbWwtdWNDRHSpewjd3aXBQlBQf5rBY/hVhw8hciarFhXa', + 'C0mP1eX_&} '$2y$10$3PJf.q.9ywNJlsInPbqc8.IFeSsvXrGvQLKRFBIhVu1h1I3vpIry6', + 'ĩńťėŕňăţĩōŋāĹ' => '$2y$10$3A2Y8WpfRAnP3czJiSv6N.6Xp0T8hW3QZz2hUCYhzyWr1kGP1yUve' + ); + } + + foreach ($validhashes as $password => $hash) { + $user = new stdClass(); + $user->auth = 'manual'; + $user->password = $hash; + // The correct password should be validated. + $this->assertTrue(validate_internal_user_password($user, $password)); + // An incorrect password should not be validated. + $this->assertFalse(validate_internal_user_password($user, 'badpw')); + } + } + + /** + * Test function hash_internal_user_password(). + */ + public function test_hash_internal_user_password() { + $passwords = array('pw', 'abc123', 'C0mP1eX_&}auth = 'manual'; + $user->password = $hash; + $this->assertTrue(validate_internal_user_password($user, $password)); + + if (password_compat_not_supported()) { + // If bcrypt is not properly supported make sure the passwords are in md5 format. + $this->assertTrue(password_is_legacy_hash($hash)); + } else { + // Otherwise they should not be in md5 format. + $this->assertFalse(password_is_legacy_hash($hash)); + + // Check that cost factor in hash is correctly set. + $this->assertRegExp('/\$10\$/', $hash); + $this->assertRegExp('/\$04\$/', $fasthash); + } + } + } + + /** + * Test function update_internal_user_password(). + */ + public function test_update_internal_user_password() { + global $DB; + $this->resetAfterTest(); + $passwords = array('password', '1234', 'changeme', '****'); + foreach ($passwords as $password) { + $user = $this->getDataGenerator()->create_user(array('auth'=>'manual')); + update_internal_user_password($user, $password); + // The user object should have been updated. + $this->assertTrue(validate_internal_user_password($user, $password)); + // The database field for the user should also have been updated to the + // same value. + $this->assertEquals($user->password, $DB->get_field('user', 'password', array('id' => $user->id))); + } + + $user = $this->getDataGenerator()->create_user(array('auth'=>'manual')); + // Manually set the user's password to the md5 of the string 'password'. + $DB->set_field('user', 'password', '5f4dcc3b5aa765d61d8327deb882cf99', array('id' => $user->id)); + + // Update the password. + update_internal_user_password($user, 'password'); + + if (password_compat_not_supported()) { + // If bcrypt not properly supported the password should remain as an md5 hash. + $expected_hash = hash_internal_user_password('password', true); + $this->assertEquals($user->password, $expected_hash); + $this->assertTrue(password_is_legacy_hash($user->password)); + } else { + // Otherwise password should have been updated to a bcrypt hash. + $this->assertFalse(password_is_legacy_hash($user->password)); + } + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d1aebe24d1bab..e6c34b64dc6b7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -38,6 +38,7 @@ lib/tests lib/ajax/tests lib/form/tests + lib/password_compat/tests lib/filestorage/tests diff --git a/report/security/lang/en/report_security.php b/report/security/lang/en/report_security.php index 84110928ef07e..71cdb0a8d8a4f 100644 --- a/report/security/lang/en/report_security.php +++ b/report/security/lang/en/report_security.php @@ -94,15 +94,6 @@ $string['check_passwordpolicy_error'] = 'Password policy not set.'; $string['check_passwordpolicy_name'] = 'Password policy'; $string['check_passwordpolicy_ok'] = 'Password policy enabled.'; -$string['check_passwordsaltmain_details'] = '

Setting a password salt greatly reduces the risk of password theft.

-

To set a password salt, add the following line to your config.php file:

-$CFG->passwordsaltmain = \'some long random string here with lots of characters\'; -

The random string of characters should be a mix of letters, numbers and other characters. A string length of at least 40 characters is recommended.

-

Please refer to the password salting documentation if you wish to change the password salt. Once set, do NOT delete your password salt otherwise you will no longer be able to login to your site!

'; -$string['check_passwordsaltmain_name'] = 'Password salt'; -$string['check_passwordsaltmain_ok'] = 'Password salt is OK'; -$string['check_passwordsaltmain_warning'] = 'No password salt has been set'; -$string['check_passwordsaltmain_weak'] = 'Password salt is weak'; $string['check_riskadmin_detailsok'] = '

Please verify the following list of system administrators:

{$a}'; $string['check_riskadmin_detailswarning'] = '

Please verify the following list of system administrators:

{$a->admins}

It is recommended to assign administrator role in the system context only. The following users have (unsupported) admin role assignments in other contexts:

{$a->unsupported}'; diff --git a/report/security/locallib.php b/report/security/locallib.php index a4e16d688ae14..d6e6bef2a2c9a 100644 --- a/report/security/locallib.php +++ b/report/security/locallib.php @@ -48,7 +48,6 @@ function report_security_get_issue_list() { 'report_security_check_openprofiles', 'report_security_check_google', 'report_security_check_passwordpolicy', - 'report_security_check_passwordsaltmain', 'report_security_check_emailchangeconfirmation', 'report_security_check_cookiesecure', 'report_security_check_configrw', @@ -471,35 +470,6 @@ function report_security_check_configrw($detailed=false) { return $result; } -function report_security_check_passwordsaltmain($detailed=false) { - global $CFG; - - $result = new stdClass(); - $result->issue = 'report_security_check_passwordsaltmain'; - $result->name = get_string('check_passwordsaltmain_name', 'report_security'); - $result->info = null; - $result->details = null; - $result->status = null; - $result->link = null; - - if (empty($CFG->passwordsaltmain)) { - $result->status = REPORT_SECURITY_WARNING; - $result->info = get_string('check_passwordsaltmain_warning', 'report_security'); - } else if ($CFG->passwordsaltmain === 'some long random string here with lots of characters' - || trim($CFG->passwordsaltmain) === '' || preg_match('/^([a-z0-9]{0,10})$/i', $CFG->passwordsaltmain)) { - $result->status = REPORT_SECURITY_WARNING; - $result->info = get_string('check_passwordsaltmain_weak', 'report_security'); - } else { - $result->status = REPORT_SECURITY_OK; - $result->info = get_string('check_passwordsaltmain_ok', 'report_security'); - } - - if ($detailed) { - $result->details = get_string('check_passwordsaltmain_details', 'report_security', get_docs_url('report/security/report_security_check_passwordsaltmain')); - } - - return $result; -} /** * Lists all users with XSS risk, it would be great to combine this with risk trusts in user table, diff --git a/version.php b/version.php index b867e43de2a61..2c24264ab852d 100644 --- a/version.php +++ b/version.php @@ -30,7 +30,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2013020800.00; // YYYYMMDD = weekly release date of this DEV branch +$version = 2013020900.00; // YYYYMMDD = weekly release date of this DEV branch // RR = release increments - 00 in DEV branches // .XX = incremental changes