mirror of
https://github.com/moodle/moodle.git
synced 2025-01-18 14:03:52 +01:00
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:
parent
cccc00954d
commit
07af783b9a
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user