diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 4ef0f5314062..d4f86c52c2be 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -39,6 +39,7 @@ use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; +use RuntimeException; trait HasAttributes { @@ -1316,7 +1317,19 @@ public static function encryptUsing($encrypter) */ protected function castAttributeAsHashedString($key, $value) { - return $value !== null && ! Hash::isHashed($value) ? Hash::make($value) : $value; + if ($value === null) { + return null; + } + + if (! Hash::isHashed($value)) { + return Hash::make($value); + } + + if (! Hash::verifyConfiguration($value)) { + throw new RuntimeException("Could not verify the hashed value's configuration."); + } + + return $value; } /** diff --git a/src/Illuminate/Hashing/Argon2IdHasher.php b/src/Illuminate/Hashing/Argon2IdHasher.php index 9aca47ac9c71..b296af521462 100644 --- a/src/Illuminate/Hashing/Argon2IdHasher.php +++ b/src/Illuminate/Hashing/Argon2IdHasher.php @@ -18,7 +18,7 @@ class Argon2IdHasher extends ArgonHasher */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2id') { + if ($this->verifyAlgorithm && ! $this->verifyAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Argon2id algorithm.'); } @@ -29,6 +29,17 @@ public function check($value, $hashedValue, array $options = []) return password_verify($value, $hashedValue); } + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function verifyAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } + /** * Get the algorithm that should be used for hashing. * diff --git a/src/Illuminate/Hashing/ArgonHasher.php b/src/Illuminate/Hashing/ArgonHasher.php index b999257f4b52..7244c336fdcc 100644 --- a/src/Illuminate/Hashing/ArgonHasher.php +++ b/src/Illuminate/Hashing/ArgonHasher.php @@ -95,7 +95,7 @@ protected function algorithm() */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2i') { + if ($this->verifyAlgorithm && ! $this->verifyAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Argon2i algorithm.'); } @@ -118,6 +118,56 @@ public function needsRehash($hashedValue, array $options = []) ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration($value) + { + return $this->verifyAlgorithm($value) && $this->verifyOptions($value); + } + + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function verifyAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Verify the hashed value's options. + * + * @param string $hashedValue + * @return bool + */ + protected function verifyOptions($hashedValue) + { + ['options' => $options] = $this->info($hashedValue); + + if ( + ! is_int($options['memory_cost'] ?? null) || + ! is_int($options['time_cost'] ?? null) || + ! is_int($options['threads'] ?? null) + ) { + return false; + } + + if ( + $options['memory_cost'] > $this->memory || + $options['time_cost'] > $this->time || + $options['threads'] > $this->threads + ) { + return false; + } + + return true; + } + /** * Set the default password memory factor. * diff --git a/src/Illuminate/Hashing/BcryptHasher.php b/src/Illuminate/Hashing/BcryptHasher.php index f74edab88805..c09f4b100836 100755 --- a/src/Illuminate/Hashing/BcryptHasher.php +++ b/src/Illuminate/Hashing/BcryptHasher.php @@ -67,7 +67,7 @@ public function make($value, array $options = []) */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'bcrypt') { + if ($this->verifyAlgorithm && ! $this->verifyAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Bcrypt algorithm.'); } @@ -88,6 +88,48 @@ public function needsRehash($hashedValue, array $options = []) ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration($value) + { + return $this->verifyAlgorithm($value) && $this->verifyOptions($value); + } + + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function verifyAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Verify the hashed value's options. + * + * @param string $hashedValue + * @return bool + */ + protected function verifyOptions($hashedValue) + { + ['options' => $options] = $this->info($hashedValue); + + if (! is_int($options['cost'] ?? null)) { + return false; + } + + if ($options['cost'] > $this->rounds) { + return false; + } + + return true; + } + /** * Set the default password work factor. * diff --git a/tests/Integration/Database/EloquentModelHashedCastingTest.php b/tests/Integration/Database/EloquentModelHashedCastingTest.php index df3402f8e46f..09cd6bb32db5 100644 --- a/tests/Integration/Database/EloquentModelHashedCastingTest.php +++ b/tests/Integration/Database/EloquentModelHashedCastingTest.php @@ -5,21 +5,13 @@ use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use RuntimeException; class EloquentModelHashedCastingTest extends DatabaseTestCase { - protected $hasher; - - protected function setUp(): void - { - parent::setUp(); - - $this->hasher = $this->mock(Hasher::class); - Hash::swap($this->hasher); - } - protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { Schema::create('hashed_casts', function (Blueprint $table) { @@ -28,46 +20,150 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() }); } - public function testHashed() + public function testHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => 'password', + ]); + + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('2y', password_get_info($subject->password)['algo']); + $this->assertSame(13, password_get_info($subject->password)['options']['cost']); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => $subject->password, + ]); + } + + public function testNotHashedIfAlreadyHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + + $this->assertSame('$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testNotHashedIfNullWithBrcypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => null, + ]); + + $this->assertNull($subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => null, + ]); + } + + public function testPassingHashWithHigherCostThrowsExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 10); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingHashWithLowerCostDoesNotThrowExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 7 rounds; bcrypt; + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + + $this->assertSame('$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithBcrypt() { - $this->hasher->expects('isHashed') - ->with('this is a password') - ->andReturnFalse(); + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); - $this->hasher->expects('make') - ->with('this is a password') - ->andReturn('hashed-password'); + $subject = HashedCast::create([ + // "password"; argon2id; + 'password' => '$argon2i$v=19$m=1024,t=2,p=2$OENON0I5bXo2WDQyQnM2bg$3ma8cKHITsmAjyIYKDLdSvtkMCiEz/s6qWnLAf+Ehek', + ]); + } + + public function testHashedWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); $subject = HashedCast::create([ - 'password' => 'this is a password', + 'password' => 'password', ]); - $this->assertSame('hashed-password', $subject->password); + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('argon2i', password_get_info($subject->password)['algo']); + $this->assertSame(1234, password_get_info($subject->password)['options']['memory_cost']); + $this->assertSame(2, password_get_info($subject->password)['options']['threads']); + $this->assertSame(7, password_get_info($subject->password)['options']['time_cost']); $this->assertDatabaseHas('hashed_casts', [ 'id' => $subject->id, - 'password' => 'hashed-password', + 'password' => $subject->password, ]); } - public function testNotHashedIfAlreadyHashed() + public function testNotHashedIfAlreadyHashedWithArgon() { - $this->hasher->expects('isHashed') - ->with('already-hashed-password') - ->andReturnTrue(); + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); $subject = HashedCast::create([ - 'password' => 'already-hashed-password', + // "password"; 1234 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', ]); - $this->assertSame('already-hashed-password', $subject->password); + $this->assertSame('$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', $subject->password); $this->assertDatabaseHas('hashed_casts', [ 'id' => $subject->id, - 'password' => 'already-hashed-password', + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', ]); } - public function testNotHashedIfNull() + public function testNotHashedIfNullWithArgon() { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + $subject = HashedCast::create([ 'password' => null, ]); @@ -78,6 +174,142 @@ public function testNotHashedIfNull() 'password' => null, ]); } + + public function testPassingHashWithHigherMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithHigherTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 2 threads; 8 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=8,p=2$LmszcGVHd0t6b3JweUxqTQ$sdY25X0Qe86fezr1cEjYQxAHI2SdN67yVs5x0ovffag', + ]); + } + + public function testPassingHashWithHigherThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 3 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=3$OFludXF6bzFpRmdpSHdwSA$J1P4dCGJde6mYe2RZEOFWaztBbDWfxQAM09ZQRMjsw8', + ]); + } + + public function testPassingHashWithLowerMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 3456); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 8); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 3); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgonAndBcrypt() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgon2idAndBcrypt() + { + Config::set('hashing.driver', 'argon2id'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } } class HashedCast extends Model