MDL-67390 Authentication: Update password hashing to SHA-512

Replace the bcrypt password hashing algorithm with SHA-512.
Existing bcrypt hashes will be updated to SHA-512 when a user
logs in next. Support for old md5 hashes has been removed.
Any reamining md5 hashes are replaced with SHA-512 hashes
from strong random passwords.
This commit is contained in:
Matt Porritt 2023-06-25 17:59:59 +10:00
parent cccc00954d
commit 07af783b9a
4 changed files with 115 additions and 76 deletions

View File

@ -3383,5 +3383,39 @@ privatefiles,moodle|/user/files.php';
upgrade_main_savepoint(true, 2023081800.01);
}
if ($oldversion < 2023082200.01) {
// Get all the ids of users who still have md5 hashed passwords.
if ($DB->sql_regex_supported()) {
// If the database supports regex, we can add an exact check for md5.
$condition = 'password ' . $DB->sql_regex() . ' :pattern';
$params = ['pattern' => "^[a-fA-F0-9]{32}$"];
} else {
// Otherwise, we need to use a NOT LIKE condition and rule out bcrypt.
$condition = $DB->sql_like('password', ':pattern', true, false, true);
$params = ['pattern' => '$2y$%'];
}
// Regardless of database regex support we check the hash length which should be enough.
// But extra regex or like matching makes sure.
$sql = "SELECT id FROM {user} WHERE LENGTH(password) = 32 AND $condition";
$userids = $DB->get_fieldset_sql($sql, $params);
// Update the password for each user with a new SHA-512 hash.
// Users won't know this password, but they can reset it. This is a security measure,
// in case the database is compromised or the hash has been leaked elsewhere.
foreach ($userids as $userid) {
$password = base64_encode(random_bytes(24)); // Generate a new password for the user.
$user = new \stdClass();
$user->id = $userid;
$user->password = hash_internal_user_password($password);
$DB->update_record('user', $user, true);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2023082200.01);
}
return true;
}

View File

@ -4594,13 +4594,13 @@ function complete_user_login($user, array $extrauserinfo = []) {
}
/**
* Check a password hash to see if it was hashed using the legacy hash algorithm (md5).
* Check a password hash to see if it was hashed using the legacy hash algorithm (bcrypt).
*
* @param string $password String to check.
* @return boolean True if the $password matches the format of an md5 sum.
* @return bool True if the $password matches the format of a bcrypt hash.
*/
function password_is_legacy_hash($password) {
return (bool) preg_match('/^[0-9a-f]{32}$/', $password);
function password_is_legacy_hash(#[\SensitiveParameter] string $password): bool {
return (bool) preg_match('/^\$2y\$[\d]{2}\$[A-Za-z0-9\.\/]{53}$/', $password);
}
/**
@ -4612,73 +4612,59 @@ function password_is_legacy_hash($password) {
* @param string $password Plain text password.
* @return bool True if password is valid.
*/
function validate_internal_user_password($user, $password) {
global $CFG;
function validate_internal_user_password(stdClass $user, #[\SensitiveParameter] string $password): bool {
if ($user->password === AUTH_PASSWORD_NOT_CACHED) {
// Internal password is not used at all, it can not validate.
return 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);
}
// First check if the password is valid, that is the password matches the stored hash.
$validated = password_verify($password, $user->password);
// 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.
$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).$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.
$validated = true;
} else {
for ($i=1; $i<=20; $i++) { // 20 alternative salts should be enough, right?
$alt = 'passwordsaltalt'.$i;
if (!empty($CFG->$alt)) {
if ($user->password === md5($password.$CFG->$alt) or $user->password === md5(addslashes($password).$CFG->$alt)) {
$validated = true;
break;
}
}
}
}
if ($validated) {
// If the password matches the existing md5 hash, update to the
// current hash algorithm while we have access to the user's password.
// If the password is valid, next check if the hash is legacy (bcrypt).
// If it is, we update the hash to the new algorithm.
if ($validated && password_is_legacy_hash($user->password)) {
update_internal_user_password($user, $password);
return true;
} else if ($validated) {
// If the password is valid, but the hash is not legacy, we can just return true.
return true;
} else {
// If the password is not valid, we return false.
return false;
}
return $validated;
}
/**
* 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.
* @param bool $fasthash If true, use a low number of rounds when generating the hash
* This is 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.
*
* @throws moodle_exception If a problem occurs while generating the hash.
*/
function hash_internal_user_password($password, $fasthash = false) {
global $CFG;
function hash_internal_user_password(#[\SensitiveParameter] string $password, $fasthash = false): string {
// Set the cost factor to 5000 for fast hashing, otherwise use default cost.
$rounds = $fasthash ? 5000 : 10000;
// Set the cost factor to 4 for fast hashing, otherwise use default cost.
$options = ($fasthash) ? array('cost' => 4) : array();
// First generate a cryptographically suitable salt.
$randombytes = random_bytes(16);
$salt = substr(strtr(base64_encode($randombytes), '+', '.'), 0, 16);
$generatedhash = password_hash($password, PASSWORD_DEFAULT, $options);
// Now construct the password string with the salt and number of rounds.
// The password string is in the format $algorithm$rounds$salt$hash. ($6 is the SHA512 algorithm).
$generatedhash = crypt($password, implode('$', [
'',
// The SHA512 Algorithm
'6',
"rounds={$rounds}",
$salt,
'',
]));
if ($generatedhash === false || $generatedhash === null) {
throw new moodle_exception('Failed to generate password hash.');
@ -4708,7 +4694,11 @@ function hash_internal_user_password($password, $fasthash = false) {
* be generated quickly.
* @return bool Always returns true.
*/
function update_internal_user_password($user, $password, $fasthash = false) {
function update_internal_user_password(
stdClass $user,
#[\SensitiveParameter] string $password,
bool $fasthash = false
): bool {
global $CFG, $DB;
// Figure out what the hashed password should be.
@ -4733,7 +4723,7 @@ function update_internal_user_password($user, $password, $fasthash = false) {
} else if (isset($user->password)) {
// If verification fails then it means the password has changed.
$passwordchanged = !password_verify($password, $user->password);
$algorithmchanged = password_needs_rehash($user->password, PASSWORD_DEFAULT);
$algorithmchanged = password_is_legacy_hash($user->password);
} else {
// While creating new user, password in unset in $user object, to avoid
// saving it with user_create()

View File

@ -2665,35 +2665,48 @@ EOF;
}
/**
* Test function password_is_legacy_hash().
* Test function password_is_legacy_hash.
* @covers ::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));
// Well formed bcrypt hashes should be matched.
foreach (array('some', 'strings', 'to_check!') as $password) {
$bcrypt = password_hash($password, '2y');
$this->assertTrue(password_is_legacy_hash($bcrypt));
}
// 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));
// Strings that are not bcrypt should not be matched.
$sha512 = '$6$rounds=5000$somesalt$9nEA35u5h4oDrUdcVFUwXDSwIBiZtuKDHiaI/kxnBSslH4wVXeAhVsDn1UFxBxrnRJva/8dZ8IouaijJdd4cF';
foreach (array('', AUTH_PASSWORD_NOT_CACHED, $sha512) as $notbcrypt) {
$this->assertFalse(password_is_legacy_hash($notbcrypt));
}
}
/**
* Test function validate_internal_user_password().
* Test function validate_internal_user_password.
* @covers ::validate_internal_user_password
*/
public function test_validate_internal_user_password() {
// Test bcrypt hashes.
$validhashes = array(
$this->resetAfterTest(true);
// Test bcrypt hashes (these will be updated but will still count as valid).
$bcrypthashes = [
'pw' => '$2y$10$LOSDi5eaQJhutSRun.OVJ.ZSxQZabCMay7TO1KmzMkDMPvU40zGXK',
'abc' => '$2y$10$VWTOhVdsBbWwtdWNDRHSpewjd3aXBQlBQf5rBY/hVhw8hciarFhXa',
'C0mP1eX_&}<?@*&%` |\"' => '$2y$10$3PJf.q.9ywNJlsInPbqc8.IFeSsvXrGvQLKRFBIhVu1h1I3vpIry6',
'ĩńťėŕňăţĩōŋāĹ' => '$2y$10$3A2Y8WpfRAnP3czJiSv6N.6Xp0T8hW3QZz2hUCYhzyWr1kGP1yUve'
);
'ĩńťėŕňăţĩōŋāĹ' => '$2y$10$3A2Y8WpfRAnP3czJiSv6N.6Xp0T8hW3QZz2hUCYhzyWr1kGP1yUve',
];
// Test sha512 hashes.
$sha512hashes = [
'pw2' => '$6$rounds=10000$0rDIzh/4.MMf9Dm8$Zrj6Ulc1JFj0RFXwMJFsngRSNGlqkPlV1wwRVv7wBLrMeQeMZrwsBO62zy63D//6R5sNGVYQwPB0K8jPCScxB/',
'abc2' => '$6$rounds=10000$t0L6PklgpijV4tMB$1vpCRKCImsVqTPMiZTi6zLGbs.hpAU8I2BhD/IFliBnHJkFZCWEBfTCq6pEzo0Q8nXsryrgeZ.qngcW.eifuW.',
'C0mP1eX_&}<?@*&%` |\"2' => '$6$rounds=10000$3TAyVAXN0zmFZ4il$KF8YzduX6Gu0C2xHsY83zoqQ/rLVsb9mLe417wDObo9tO00qeUC68/y2tMq4FL2ixnMPH3OMwzGYo8VJrm8Eq1',
'ĩńťėŕňăţĩōŋāĹ2' => '$6$rounds=10000$SHR/6ctTkfXOy5NP$YPv42hjDjohVWD3B0boyEYTnLcBXBKO933ijHmkPXNL7BpqAcbYMLfTl9rjsPmCt.1GZvEJZ8ikkCPYBC5Sdp.',
];
$validhashes = array_merge($bcrypthashes, $sha512hashes);
foreach ($validhashes as $password => $hash) {
$user = new \stdClass();
$user->auth = 'manual';
$user = $this->getDataGenerator()->create_user(array('auth' => 'manual', 'password' => $password));
$user->password = $hash;
// The correct password should be validated.
$this->assertTrue(validate_internal_user_password($user, $password));
@ -2703,7 +2716,8 @@ EOF;
}
/**
* Test function hash_internal_user_password().
* Test function hash_internal_user_password.
* @covers ::hash_internal_user_password
*/
public function test_hash_internal_user_password() {
$passwords = array('pw', 'abc123', 'C0mP1eX_&}<?@*&%` |\"', 'ĩńťėŕňăţĩōŋāĹ');
@ -2718,17 +2732,18 @@ EOF;
$user->password = $hash;
$this->assertTrue(validate_internal_user_password($user, $password));
// They should not be in md5 format.
// They should not be in bycrypt format.
$this->assertFalse(password_is_legacy_hash($hash));
// Check that cost factor in hash is correctly set.
$this->assertMatchesRegularExpression('/\$10\$/', $hash);
$this->assertMatchesRegularExpression('/\$04\$/', $fasthash);
$this->assertMatchesRegularExpression('/\$6\$rounds=10000\$.{103}/', $hash);
$this->assertMatchesRegularExpression('/\$6\$rounds=5000\$.{103}/', $fasthash);
}
}
/**
* Test function update_internal_user_password().
* Test function update_internal_user_password.
* @covers ::update_internal_user_password
*/
public function test_update_internal_user_password() {
global $DB;
@ -2745,8 +2760,8 @@ EOF;
}
$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));
// Manually set the user's password to the bcrypt of the string 'password'.
$DB->set_field('user', 'password', '$2y$10$HhNAYmQcU1GqU/psOmZjfOWlhPEcxx9aEgSJqBfEtYVyq1jPKqMAi', ['id' => $user->id]);
$sink = $this->redirectEvents();
// Update the password.
@ -2755,7 +2770,7 @@ EOF;
$sink->close();
$event = array_pop($events);
// Password should have been updated to a bcrypt hash.
// Password should have been updated to a SHA512 hash.
$this->assertFalse(password_is_legacy_hash($user->password));
// Verify event information.

View File

@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2023082200.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2023082200.01; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '4.3dev+ (Build: 20230822)'; // Human-friendly version name