diff --git a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php index b6c04518c..71a7f0f44 100644 --- a/src/core/src/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/core/src/Database/Eloquent/Concerns/HasAttributes.php @@ -7,10 +7,16 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; +use Hyperf\Collection\Collection as BaseCollection; use Hyperf\Contract\Castable; use Hyperf\Contract\CastsAttributes; use Hyperf\Contract\CastsInboundAttributes; +use Hypervel\Encryption\Contracts\Encrypter; +use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Facades\Hash; +use Hypervel\Support\Str; +use RuntimeException; trait HasAttributes { @@ -24,6 +30,158 @@ trait HasAttributes */ protected static array $castsCache = []; + /** + * The encrypter instance that is used to encrypt attributes. + */ + protected static ?Encrypter $encrypter = null; + + /** + * Set the encrypter instance that will be used to encrypt attributes. + */ + public static function encryptUsing(?Encrypter $encrypter): void + { + static::$encrypter = $encrypter; + } + + /** + * Get the current encrypter being used by the model. + */ + public static function currentEncrypter(): Encrypter + { + return static::$encrypter ?? Crypt::getFacadeRoot(); + } + + /** + * Determine whether a value is an encrypted castable for inbound manipulation. + */ + protected function isEncryptedCastable(string $key): bool + { + return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + } + + /** + * Decrypt the given encrypted string. + */ + public function fromEncryptedString(string $value): mixed + { + return static::currentEncrypter()->decrypt($value, false); + } + + /** + * Cast the given attribute to an encrypted string. + */ + protected function castAttributeAsEncryptedString(string $key, mixed $value): string + { + return static::currentEncrypter()->encrypt($value, false); + } + + /** + * Cast the given attribute to a hashed string. + * + * @throws RuntimeException + */ + protected function castAttributeAsHashedString(string $key, mixed $value): ?string + { + 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; + } + + /** + * Get the type of cast for a model attribute. + */ + protected function getCastType(string $key): string + { + $castType = $this->getCasts()[$key]; + + if ($this->isCustomDateTimeCast($castType)) { + return 'custom_datetime'; + } + + if ($this->isImmutableCustomDateTimeCast($castType)) { + return 'immutable_custom_datetime'; + } + + if ($this->isDecimalCast($castType)) { + return 'decimal'; + } + + return trim(strtolower($castType)); + } + + /** + * Determine if the cast type is an immutable custom date time cast. + */ + protected function isImmutableCustomDateTimeCast(string $cast): bool + { + return str_starts_with($cast, 'immutable_date:') + || str_starts_with($cast, 'immutable_datetime:'); + } + + /** + * Cast an attribute to a native PHP type. + */ + protected function castAttribute(string $key, mixed $value): mixed + { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { + return null; + } + + // If the key is one of the encrypted castable types, we'll first decrypt + // the value and update the cast type so we may leverage the following + // logic for casting this value to any additionally specified types. + if ($this->isEncryptedCastable($key)) { + $value = $this->fromEncryptedString($value); + + $castType = Str::after($castType, 'encrypted:'); + } + + return match ($castType) { + 'int', 'integer' => (int) $value, + 'real', 'float', 'double' => $this->fromFloat($value), + 'decimal' => $this->asDecimal($value, (int) explode(':', $this->getCasts()[$key], 2)[1]), + 'string' => (string) $value, + 'bool', 'boolean' => (bool) $value, + 'object' => $this->fromJson($value, true), + 'array', 'json', 'json:unicode' => $this->fromJson($value), + 'collection' => new BaseCollection($this->fromJson($value)), + 'date' => $this->asDate($value), + 'datetime', 'custom_datetime' => $this->asDateTime($value), + 'immutable_date' => $this->asDate($value)->toImmutable(), + 'immutable_custom_datetime', 'immutable_datetime' => $this->asDateTime($value)->toImmutable(), + 'timestamp' => $this->asTimestamp($value), + default => $this->castAttributeDefault($key, $value), + }; + } + + /** + * Handle default cast attribute types (enum and class casts). + */ + protected function castAttributeDefault(string $key, mixed $value): mixed + { + if ($this->isEnumCastable($key)) { + return $this->getEnumCastableAttributeValue($key, $value); + } + + if ($this->isClassCastable($key)) { + return $this->getClassCastableAttributeValue($key, $value); + } + + return $value; + } + /** * Resolve the custom caster class for a given key. */ @@ -81,6 +239,119 @@ protected function casts(): array return []; } + /** + * Set a given attribute on the model. + * + * @return static + */ + public function setAttribute(string $key, mixed $value) + { + // First we will check for the presence of a mutator for the set operation + // which simply lets the developers tweak the attribute as it is set on + // the model, such as "json_encoding" an listing of data for storage. + if ($this->hasSetMutator($key)) { + return $this->setMutatedAttributeValue($key, $value); + } + + // If an attribute is listed as a "date", we'll convert it from a DateTime + // instance into a form proper for storage on the database tables using + // the connection grammar's date format. We will auto set the values. + if ($value && $this->isDateAttribute($key)) { + $value = $this->fromDateTime($value); + } + + if ($this->isEnumCastable($key)) { + $this->setEnumCastableAttribute($key, $value); + + return $this; + } + + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if ($this->isJsonCastable($key) && ! is_null($value)) { + $value = $this->castAttributeAsJson($key, $value); + } + + // If this attribute contains a JSON ->, we'll set the proper value in the + // attribute's underlying array. This takes care of properly nesting an + // attribute in the array's value in the case of deeply nested items. + if (str_contains($key, '->')) { + return $this->fillJsonAttribute($key, $value); + } + + if (! is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + + if (! is_null($value) && $this->hasCast($key, 'hashed')) { + $value = $this->castAttributeAsHashedString($key, $value); + } + + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Cast the given attribute to JSON. + */ + protected function castAttributeAsJson(string $key, mixed $value): string + { + $value = $this->asJson($value, $this->getJsonCastFlags($key)); + + if ($value === false) { + throw new \Hyperf\Database\Model\JsonEncodingException( + "Unable to encode attribute [{$key}] for model [" . static::class . '] to JSON: ' . json_last_error_msg() + ); + } + + return $value; + } + + /** + * Get the JSON casting flags for the given attribute. + */ + protected function getJsonCastFlags(string $key): int + { + $flags = 0; + + if ($this->hasCast($key, ['json:unicode'])) { + $flags |= JSON_UNESCAPED_UNICODE; + } + + return $flags; + } + + /** + * Encode the given value as JSON. + */ + protected function asJson(mixed $value, int $flags = 0): string|false + { + return json_encode($value, $flags); + } + + /** + * Determine whether a value is JSON castable for inbound manipulation. + */ + protected function isJsonCastable(string $key): bool + { + return $this->hasCast($key, [ + 'array', + 'json', + 'json:unicode', + 'object', + 'collection', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + ]); + } + /** * Return a timestamp as DateTime object with time set to 00:00:00. * diff --git a/src/core/src/Database/Eloquent/Model.php b/src/core/src/Database/Eloquent/Model.php index fb6c06e37..1dc4288a2 100644 --- a/src/core/src/Database/Eloquent/Model.php +++ b/src/core/src/Database/Eloquent/Model.php @@ -84,6 +84,41 @@ abstract class Model extends BaseModel implements UrlRoutable, HasBroadcastChann use HasTimestamps; use TransformsToResource; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * The resolved builder class names by model. * diff --git a/src/core/src/Database/Eloquent/Relations/MorphPivot.php b/src/core/src/Database/Eloquent/Relations/MorphPivot.php index cdfa47a3c..adb525110 100644 --- a/src/core/src/Database/Eloquent/Relations/MorphPivot.php +++ b/src/core/src/Database/Eloquent/Relations/MorphPivot.php @@ -20,6 +20,41 @@ class MorphPivot extends BaseMorphPivot use HasObservers; use HasTimestamps; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * Delete the pivot model record from the database. * diff --git a/src/core/src/Database/Eloquent/Relations/Pivot.php b/src/core/src/Database/Eloquent/Relations/Pivot.php index 085b717ef..936dcbb73 100644 --- a/src/core/src/Database/Eloquent/Relations/Pivot.php +++ b/src/core/src/Database/Eloquent/Relations/Pivot.php @@ -20,6 +20,41 @@ class Pivot extends BasePivot use HasObservers; use HasTimestamps; + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'encrypted', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'encrypted:object', + 'float', + 'hashed', + 'immutable_date', + 'immutable_datetime', + 'immutable_custom_datetime', + 'int', + 'integer', + 'json', + 'json:unicode', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * Delete the pivot model record from the database. * diff --git a/src/hashing/src/Argon2IdHasher.php b/src/hashing/src/Argon2IdHasher.php index 913a55495..a10ff04b2 100644 --- a/src/hashing/src/Argon2IdHasher.php +++ b/src/hashing/src/Argon2IdHasher.php @@ -26,6 +26,14 @@ public function check(string $value, ?string $hashedValue, array $options = []): return password_verify($value, $hashedValue); } + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } + /** * Get the algorithm that should be used for hashing. */ diff --git a/src/hashing/src/ArgonHasher.php b/src/hashing/src/ArgonHasher.php index 78dc033d1..6579624c7 100644 --- a/src/hashing/src/ArgonHasher.php +++ b/src/hashing/src/ArgonHasher.php @@ -94,6 +94,51 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Verify the hashed value's options are valid (memory, time, threads are not greater than configured). + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['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/hashing/src/BcryptHasher.php b/src/hashing/src/BcryptHasher.php index ef746d5f9..4671d353a 100644 --- a/src/hashing/src/BcryptHasher.php +++ b/src/hashing/src/BcryptHasher.php @@ -70,6 +70,43 @@ public function needsRehash(string $hashedValue, array $options = []): bool ]); } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + return $this->isUsingCorrectAlgorithm($hashedValue) && $this->isUsingValidOptions($hashedValue); + } + + /** + * Verify the hashed value's algorithm. + */ + protected function isUsingCorrectAlgorithm(string $hashedValue): bool + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Verify the hashed value's options are valid (cost is not greater than configured). + */ + protected function isUsingValidOptions(string $hashedValue): bool + { + ['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/src/hashing/src/HashManager.php b/src/hashing/src/HashManager.php index d3fe0ac3e..a65f18478 100644 --- a/src/hashing/src/HashManager.php +++ b/src/hashing/src/HashManager.php @@ -76,6 +76,23 @@ public function isHashed(string $value): bool return password_get_info($value)['algo'] !== null; } + /** + * Verify that the configuration of the given hash is less than or equal + * to the current hasher configuration. + * + * @internal + */ + public function verifyConfiguration(string $hashedValue): bool + { + $driver = $this->driver(); + + if (method_exists($driver, 'verifyConfiguration')) { + return $driver->verifyConfiguration($hashedValue); + } + + return true; + } + /** * Get the default driver name. */ diff --git a/src/support/src/Facades/Hash.php b/src/support/src/Facades/Hash.php index e75a75ebe..d4e7ed66b 100644 --- a/src/support/src/Facades/Hash.php +++ b/src/support/src/Facades/Hash.php @@ -15,6 +15,7 @@ * @method static bool check(string $value, string|null $hashedValue, array $options = []) * @method static bool needsRehash(string $hashedValue, array $options = []) * @method static bool isHashed(string $value) + * @method static bool verifyConfiguration(string $hashedValue) * @method static string getDefaultDriver() * @method static mixed driver(string|null $driver = null) * @method static \Hypervel\Hashing\HashManager extend(string $driver, \Closure $callback) diff --git a/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php new file mode 100644 index 000000000..32ca47956 --- /dev/null +++ b/tests/Core/Database/Eloquent/Concerns/HasAttributesCastsTest.php @@ -0,0 +1,368 @@ +setRawAttributes([ + 'birth_date' => '2000-01-15', + ]); + + $date = $model->birth_date; + + $this->assertInstanceOf(CarbonImmutable::class, $date); + $this->assertSame('2000-01-15', $date->format('Y-m-d')); + } + + public function testImmutableDatetimeCastReturnsImmutableCarbon(): void + { + $model = new ImmutableDateModel(); + $model->setRawAttributes([ + 'created_at' => '2000-01-15 10:30:00', + ]); + + $datetime = $model->created_at; + + $this->assertInstanceOf(CarbonImmutable::class, $datetime); + $this->assertSame('2000-01-15 10:30:00', $datetime->format('Y-m-d H:i:s')); + } + + public function testImmutableCustomDatetimeCastReturnsImmutableCarbon(): void + { + $model = new ImmutableDateModel(); + // Use standard datetime format that asDateTime can parse + $model->setRawAttributes([ + 'formatted_at' => '2000-01-15 10:30:00', + ]); + + $datetime = $model->formatted_at; + + $this->assertInstanceOf(CarbonImmutable::class, $datetime); + $this->assertSame('2000-01-15', $datetime->format('Y-m-d')); + } + + public function testJsonUnicodeCastPreservesUnicodeOnSet(): void + { + $model = new JsonUnicodeModel(); + $model->data = ['name' => '日本語', 'emoji' => '😀']; + + $rawAttributes = $model->getAttributes(); + + // Should contain unescaped unicode + $this->assertStringContainsString('日本語', $rawAttributes['data']); + $this->assertStringContainsString('😀', $rawAttributes['data']); + // Should NOT contain escaped unicode like \u65e5 + $this->assertStringNotContainsString('\u', $rawAttributes['data']); + } + + public function testJsonUnicodeCastPreservesUnicodeOnGet(): void + { + $model = new JsonUnicodeModel(); + $model->setRawAttributes([ + 'data' => '{"name":"日本語","emoji":"😀"}', + ]); + + $data = $model->data; + + $this->assertSame('日本語', $data['name']); + $this->assertSame('😀', $data['emoji']); + } + + public function testEncryptedCastEncryptsOnSet(): void + { + $model = new EncryptedModel(); + $model->secret = 'my-secret-value'; + + $rawAttributes = $model->getAttributes(); + + // Should be encrypted (not plain text) + $this->assertNotSame('my-secret-value', $rawAttributes['secret']); + // Should be base64-encoded JSON (typical encryption format) + $this->assertMatchesRegularExpression('/^[A-Za-z0-9+\/=]+$/', $rawAttributes['secret']); + } + + public function testEncryptedCastDecryptsOnGet(): void + { + $encrypter = new Encrypter(str_repeat('a', 16)); + $encrypted = $encrypter->encrypt('my-secret-value', false); + + $model = new EncryptedModel(); + $model->setRawAttributes([ + 'secret' => $encrypted, + ]); + + $this->assertSame('my-secret-value', $model->secret); + } + + public function testEncryptedArrayCast(): void + { + $model = new EncryptedModel(); + $model->secret_array = ['key' => 'value', 'nested' => ['a' => 1]]; + + // Verify it's encrypted in raw attributes + $rawAttributes = $model->getAttributes(); + $this->assertNotSame(['key' => 'value', 'nested' => ['a' => 1]], $rawAttributes['secret_array']); + + // Verify it decrypts correctly + $this->assertSame(['key' => 'value', 'nested' => ['a' => 1]], $model->secret_array); + } + + public function testEncryptedCollectionCast(): void + { + $model = new EncryptedModel(); + $model->secret_collection = ['item1', 'item2', 'item3']; + + // Verify it's encrypted in raw attributes + $rawAttributes = $model->getAttributes(); + $this->assertIsString($rawAttributes['secret_collection']); + + // Verify it decrypts to collection + $collection = $model->secret_collection; + $this->assertInstanceOf(\Hyperf\Collection\Collection::class, $collection); + $this->assertSame(['item1', 'item2', 'item3'], $collection->all()); + } + + public function testEncryptedObjectCast(): void + { + $model = new EncryptedModel(); + $model->secret_object = ['key' => 'value']; + + // Verify it decrypts to stdClass + $object = $model->secret_object; + $this->assertInstanceOf(stdClass::class, $object); + $this->assertSame('value', $object->key); + } + + public function testHashedCastHashesPlainTextOnSet(): void + { + $model = new HashedModel(); + $model->password = 'secret123'; + + $rawAttributes = $model->getAttributes(); + + // Should be hashed (starts with $2y$ for bcrypt) + $this->assertStringStartsWith('$2y$', $rawAttributes['password']); + // Should verify correctly + $this->assertTrue(password_verify('secret123', $rawAttributes['password'])); + } + + public function testHashedCastPreservesAlreadyHashedValues(): void + { + $hasher = new BcryptHasher(['rounds' => 4]); + $existingHash = $hasher->make('secret123'); + + $model = new HashedModel(); + $model->password = $existingHash; + + $rawAttributes = $model->getAttributes(); + + // Should keep the existing hash unchanged + $this->assertSame($existingHash, $rawAttributes['password']); + } + + /** + * Note: Testing verifyConfiguration failure would require mocking the Hash facade + * with a specific configuration. The verifyConfiguration logic is tested separately + * in HasherTest. Here we just verify the cast works with valid hashes. + */ + public function testHashedCastWorksWithValidExistingHash(): void + { + // Create a hash with the default hasher (bcrypt cost 10) + $hasher = new BcryptHasher(['rounds' => 10]); + $existingHash = $hasher->make('secret123'); + + $model = new HashedModel(); + $model->password = $existingHash; + + $rawAttributes = $model->getAttributes(); + + // Should keep the existing hash unchanged (since it's already hashed with valid config) + $this->assertSame($existingHash, $rawAttributes['password']); + } + + public function testEncryptedCastHandlesNullOnSet(): void + { + $model = new EncryptedModel(); + $model->secret = null; + + $rawAttributes = $model->getAttributes(); + + // Null should remain null, not be encrypted + $this->assertNull($rawAttributes['secret']); + } + + public function testEncryptedCastHandlesNullOnGet(): void + { + $model = new EncryptedModel(); + $model->setRawAttributes([ + 'secret' => null, + ]); + + // Null should remain null, not throw decryption error + $this->assertNull($model->secret); + } + + public function testHashedCastHandlesNullOnSet(): void + { + $model = new HashedModel(); + $model->password = null; + + $rawAttributes = $model->getAttributes(); + + // Null should remain null, not be hashed + $this->assertNull($rawAttributes['password']); + } + + public function testEncryptUsingAllowsCustomEncrypter(): void + { + // Create a different encrypter with a different key + $customKey = str_repeat('b', 16); + $customEncrypter = new Encrypter($customKey); + + // Set it on the model class + EncryptedModel::encryptUsing($customEncrypter); + + $model = new EncryptedModel(); + $model->secret = 'custom-encrypted-value'; + + // Verify the value was encrypted + $rawAttributes = $model->getAttributes(); + $this->assertNotSame('custom-encrypted-value', $rawAttributes['secret']); + + // Verify it can be decrypted with the custom encrypter + $decrypted = $customEncrypter->decrypt($rawAttributes['secret'], false); + $this->assertSame('custom-encrypted-value', $decrypted); + + // Verify the model getter also decrypts correctly + $this->assertSame('custom-encrypted-value', $model->secret); + } + + public function testCurrentEncrypterReturnsCustomEncrypterWhenSet(): void + { + $customEncrypter = new Encrypter(str_repeat('c', 16)); + EncryptedModel::encryptUsing($customEncrypter); + + $this->assertSame($customEncrypter, EncryptedModel::currentEncrypter()); + } + + public function testEncryptUsingCanBeResetAndNewEncrypterSet(): void + { + $firstEncrypter = new Encrypter(str_repeat('d', 16)); + $secondEncrypter = new Encrypter(str_repeat('e', 16)); + + // Set first encrypter + EncryptedModel::encryptUsing($firstEncrypter); + $this->assertSame($firstEncrypter, EncryptedModel::currentEncrypter()); + + // Reset and set second encrypter + EncryptedModel::encryptUsing(null); + EncryptedModel::encryptUsing($secondEncrypter); + + // Should now use second encrypter + $this->assertSame($secondEncrypter, EncryptedModel::currentEncrypter()); + $this->assertNotSame($firstEncrypter, EncryptedModel::currentEncrypter()); + } +} + +class ImmutableDateModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'birth_date' => 'immutable_date', + 'created_at' => 'immutable_datetime', + 'formatted_at' => 'immutable_datetime:d/m/Y', + ]; + } +} + +class JsonUnicodeModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'data' => 'json:unicode', + ]; + } +} + +class EncryptedModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'secret' => 'encrypted', + 'secret_array' => 'encrypted:array', + 'secret_collection' => 'encrypted:collection', + 'secret_json' => 'encrypted:json', + 'secret_object' => 'encrypted:object', + ]; + } +} + +class HashedModel extends Model +{ + use HasUuids; + + protected ?string $table = 'test_models'; + + protected function casts(): array + { + return [ + 'password' => 'hashed', + ]; + } +} diff --git a/tests/Hashing/HasherTest.php b/tests/Hashing/HasherTest.php index d5bbf8cb0..cd1205a49 100644 --- a/tests/Hashing/HasherTest.php +++ b/tests/Hashing/HasherTest.php @@ -127,6 +127,108 @@ public function testIsHashedWithNonHashedValue() $this->assertFalse($this->hashManager->isHashed('foo')); } + public function testBcryptVerifyConfigurationWithValidHash() + { + $hasher = new BcryptHasher(['rounds' => 10]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithLowerCost() + { + // Hash created with cost 4 + $lowCostHasher = new BcryptHasher(['rounds' => 4]); + $hash = $lowCostHasher->make('password'); + + // Verify with hasher configured for cost 10 - should pass (lower is ok) + $higherCostHasher = new BcryptHasher(['rounds' => 10]); + $this->assertTrue($higherCostHasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithHigherCost() + { + // Hash created with cost 12 + $highCostHasher = new BcryptHasher(['rounds' => 12]); + $hash = $highCostHasher->make('password'); + + // Verify with hasher configured for cost 10 - should fail (higher than configured) + $lowerCostHasher = new BcryptHasher(['rounds' => 10]); + $this->assertFalse($lowerCostHasher->verifyConfiguration($hash)); + } + + public function testBcryptVerifyConfigurationWithWrongAlgorithm() + { + $argonHasher = new ArgonHasher(); + $argonHash = $argonHasher->make('password'); + + $bcryptHasher = new BcryptHasher(); + $this->assertFalse($bcryptHasher->verifyConfiguration($argonHash)); + } + + public function testArgonVerifyConfigurationWithValidHash() + { + $hasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithLowerOptions() + { + // Hash created with lower options + $lowOptionsHasher = new ArgonHasher(['memory' => 512, 'time' => 1, 'threads' => 1]); + $hash = $lowOptionsHasher->make('password'); + + // Verify with hasher configured for higher options - should pass + $higherOptionsHasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $this->assertTrue($higherOptionsHasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithHigherMemory() + { + // Hash created with higher memory + $highMemoryHasher = new ArgonHasher(['memory' => 2048, 'time' => 2, 'threads' => 2]); + $hash = $highMemoryHasher->make('password'); + + // Verify with hasher configured for lower memory - should fail + $lowerMemoryHasher = new ArgonHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $this->assertFalse($lowerMemoryHasher->verifyConfiguration($hash)); + } + + public function testArgonVerifyConfigurationWithWrongAlgorithm() + { + $bcryptHasher = new BcryptHasher(); + $bcryptHash = $bcryptHasher->make('password'); + + $argonHasher = new ArgonHasher(); + $this->assertFalse($argonHasher->verifyConfiguration($bcryptHash)); + } + + public function testArgon2idVerifyConfigurationWithValidHash() + { + $hasher = new Argon2IdHasher(['memory' => 1024, 'time' => 2, 'threads' => 2]); + $hash = $hasher->make('password'); + + $this->assertTrue($hasher->verifyConfiguration($hash)); + } + + public function testArgon2idVerifyConfigurationWithWrongAlgorithm() + { + // Use Argon2i hash with Argon2id hasher + $argonHasher = new ArgonHasher(); + $argonHash = $argonHasher->make('password'); + + $argon2idHasher = new Argon2IdHasher(); + $this->assertFalse($argon2idHasher->verifyConfiguration($argonHash)); + } + + public function testHashManagerVerifyConfigurationDelegatesToDriver() + { + $hash = $this->hashManager->make('password'); + $this->assertTrue($this->hashManager->verifyConfiguration($hash)); + } + protected function getContainer() { $container = Mockery::mock(ContainerInterface::class);