diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 61e1ff2e2..f493defa6 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -270,6 +270,40 @@ final class Book } ``` +### Hashed properties + +The {`#[Tempest\Database\Hashed]`} attribute will hash the model's property during serialization. If the property was already hashed, Tempest will detect that and avoid re-hashing it. + +```php +final class User +{ + public PrimaryKey $id; + + public string $email; + + #[Hashed] + public ?string $password; +} +``` + +Hashing requires the `SIGNING_KEY` environment variable to be set, as it's used as the hashing key. + +### Encrypted properties + +The {`#[Tempest\Database\Encrypted]`} attribute will encrypt the model's property during serialization and decrypt it during deserialization. If the property was already encrypted, Tempest will detect that and avoid re-encrypting it. + +```php +final class User +{ + // ... + + #[Encrypted] + public ?string $accessToken; +} +``` + +The encryption key is taken from the `SIGNING_KEY` environment variable. + ### DTO properties Sometimes, you might want to store data objects as-is in a table, without there needing to be a relation to another table. To do so, it's enough to add a serializer and caster to the data object's class, and Tempest will know that these objects aren't meant to be treated as database models. Next, you can store the object's data as a json field on the table (see [migrations](#migrations) for more info). diff --git a/packages/cryptography/src/Password/GenericPasswordHasher.php b/packages/cryptography/src/Password/GenericPasswordHasher.php index 5d36987a6..4869d1ce4 100644 --- a/packages/cryptography/src/Password/GenericPasswordHasher.php +++ b/packages/cryptography/src/Password/GenericPasswordHasher.php @@ -44,9 +44,14 @@ public function needsRehash(#[\SensitiveParameter] string $hash): bool return password_needs_rehash($hash, $this->algorithm->value, $this->config->options); } - public function analyze(#[\SensitiveParameter] string $hash): Hash + public function analyze(#[\SensitiveParameter] string $hash): ?Hash { $info = password_get_info($hash); + + if ($info['algo'] === null) { + return null; + } + $algorithm = HashingAlgorithm::from($info['algo']); return new Hash( diff --git a/packages/cryptography/src/Password/PasswordHasher.php b/packages/cryptography/src/Password/PasswordHasher.php index fa6827950..d8ffaccaf 100644 --- a/packages/cryptography/src/Password/PasswordHasher.php +++ b/packages/cryptography/src/Password/PasswordHasher.php @@ -29,5 +29,5 @@ public function needsRehash(#[\SensitiveParameter] string $hash): bool; /** * Returns informations about the given hash, such as the algorithm used and its options. */ - public function analyze(#[\SensitiveParameter] string $hash): Hash; + public function analyze(#[\SensitiveParameter] string $hash): ?Hash; } diff --git a/packages/database/src/Casters/EncryptedCaster.php b/packages/database/src/Casters/EncryptedCaster.php new file mode 100644 index 000000000..6c2a7977b --- /dev/null +++ b/packages/database/src/Casters/EncryptedCaster.php @@ -0,0 +1,31 @@ +encrypter->decrypt($input); + } catch (EncryptionException|JsonException) { + return $input; + } + } +} diff --git a/packages/database/src/Encrypted.php b/packages/database/src/Encrypted.php new file mode 100644 index 000000000..31e20cf1e --- /dev/null +++ b/packages/database/src/Encrypted.php @@ -0,0 +1,27 @@ + EncryptedSerializer::class; + } + + public string $caster { + get => EncryptedCaster::class; + } +} diff --git a/packages/database/src/Hashed.php b/packages/database/src/Hashed.php new file mode 100644 index 000000000..cc294cde3 --- /dev/null +++ b/packages/database/src/Hashed.php @@ -0,0 +1,20 @@ + HashedSerializer::class; + } +} diff --git a/packages/database/src/Serializers/EncryptedSerializer.php b/packages/database/src/Serializers/EncryptedSerializer.php new file mode 100644 index 000000000..6d0e904d2 --- /dev/null +++ b/packages/database/src/Serializers/EncryptedSerializer.php @@ -0,0 +1,31 @@ +encrypter->encrypt($input); + } + + return $input; + } +} diff --git a/packages/database/src/Serializers/HashedSerializer.php b/packages/database/src/Serializers/HashedSerializer.php new file mode 100644 index 000000000..b6b2b2b77 --- /dev/null +++ b/packages/database/src/Serializers/HashedSerializer.php @@ -0,0 +1,26 @@ +passwordHasher->analyze($input)) { + return $this->passwordHasher->hash($input); + } + + return $input; + } +} diff --git a/packages/mapper/src/CasterFactory.php b/packages/mapper/src/CasterFactory.php index ee7c26273..616bf42d9 100644 --- a/packages/mapper/src/CasterFactory.php +++ b/packages/mapper/src/CasterFactory.php @@ -49,6 +49,10 @@ public function forProperty(PropertyReflector $property): ?Caster return get($castWith->className); } + if ($casterAttribute = $property->getAttribute(ProvidesCaster::class)) { + return get($casterAttribute->caster); + } + // Resolve caster from manual additions foreach ($this->casters as [$for, $casterClass]) { if (is_callable($for) && $for($property) || is_string($for) && $type->matches($for) || $type->getName() === $for) { diff --git a/packages/mapper/src/ProvidesCaster.php b/packages/mapper/src/ProvidesCaster.php new file mode 100644 index 000000000..921357c6a --- /dev/null +++ b/packages/mapper/src/ProvidesCaster.php @@ -0,0 +1,14 @@ + */ + public string $caster { + get; + } +} diff --git a/packages/mapper/src/ProvidesSerializer.php b/packages/mapper/src/ProvidesSerializer.php new file mode 100644 index 000000000..86efbfb38 --- /dev/null +++ b/packages/mapper/src/ProvidesSerializer.php @@ -0,0 +1,14 @@ + */ + public string $serializer { + get; + } +} diff --git a/packages/mapper/src/SerializerFactory.php b/packages/mapper/src/SerializerFactory.php index 1d7a8349e..64ff9385f 100644 --- a/packages/mapper/src/SerializerFactory.php +++ b/packages/mapper/src/SerializerFactory.php @@ -86,6 +86,10 @@ public function forProperty(PropertyReflector $property): ?Serializer return get($serializeWith->className); } + if ($serializerAttribute = $property->getAttribute(ProvidesSerializer::class)) { + return get($serializerAttribute->serializer); + } + // Resolve serializer from manual additions foreach ($this->serializers as [$for, $serializerClass]) { if (! $this->serializerMatches($for, $type)) { diff --git a/packages/support/src/Json/Exception/JsonCouldNotBeDecoded.php b/packages/support/src/Json/Exception/JsonCouldNotBeDecoded.php index 4ee4fede1..9b2c4ac99 100644 --- a/packages/support/src/Json/Exception/JsonCouldNotBeDecoded.php +++ b/packages/support/src/Json/Exception/JsonCouldNotBeDecoded.php @@ -5,6 +5,7 @@ namespace Tempest\Support\Json\Exception; use InvalidArgumentException; +use Tempest\Support\Json\Exception\JsonException; final class JsonCouldNotBeDecoded extends InvalidArgumentException implements JsonException { diff --git a/tests/Integration/Database/EncryptedAttributeTest.php b/tests/Integration/Database/EncryptedAttributeTest.php new file mode 100644 index 000000000..c0dc00698 --- /dev/null +++ b/tests/Integration/Database/EncryptedAttributeTest.php @@ -0,0 +1,154 @@ + $this->container->get(Encrypter::class); + } + + #[Test] + public function encrypts_value_on_insert(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithEncryptedDataTable::class); + + $user = query(UserWithEncryptedData::class)->create( + email: 'test@example.com', + secret: 'sensitive information', // @mago-expect security/no-literal-password + ); + + $this->assertSame('sensitive information', $user->secret); + + $encrypted = new Query('SELECT secret FROM users WHERE email = ?', ['test@example.com'])->fetchFirst(); + + $this->assertNotSame('sensitive information', $encrypted['secret']); + } + + #[Test] + public function encrypts_value_on_update(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithEncryptedDataTable::class); + + $user = query(UserWithEncryptedData::class)->create( + email: 'test@example.com', + secret: 'original secret', // @mago-expect security/no-literal-password + ); + + $user->update(secret: 'new secret')->refresh(); // @mago-expect security/no-literal-password + + $this->assertSame('new secret', $user->secret); + + $encrypted = new Query('SELECT secret FROM users WHERE email = ?', ['test@example.com'])->fetchFirst(); + + $this->assertNotSame('original secret', $encrypted['secret']); + $this->assertNotSame('new secret', $encrypted['secret']); + } + + #[Test] + public function does_not_re_encrypt_already_encrypted_values(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithEncryptedDataTable::class); + + $user = query(UserWithEncryptedData::class)->create( + email: 'test@example.com', + secret: $this->encrypter->encrypt('sensitive data'), + ); + + $this->assertSame('sensitive data', $user->secret); + } + + #[Test] + public function handles_null_values(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithNullableEncryptedDataTable::class); + + $user = query(UserWithNullableEncryptedData::class)->create( + email: 'test@example.com', + secret: null, + ); + + $this->assertNull($user->secret); + } + + #[Test] + public function handles_empty_strings(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithEncryptedDataTable::class); + + $user = query(UserWithEncryptedData::class)->create( + email: 'test@example.com', + secret: '', + ); + + $this->assertSame('', $user->secret); + } +} + +#[Table('users')] +final class UserWithEncryptedData +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $email, + #[Encrypted] + public string $secret, + ) {} +} + +#[Table('users')] +final class UserWithNullableEncryptedData +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $email, + #[Encrypted] + public ?string $secret, + ) {} +} + +final class CreateUserWithEncryptedDataTable implements MigratesUp +{ + public string $name = '2024_create_users_with_encrypted_data_table'; + + public function up(): CreateTableStatement + { + return CreateTableStatement::forModel(UserWithEncryptedData::class) + ->primary() + ->string('email') + ->text('secret'); + } +} + +final class CreateUserWithNullableEncryptedDataTable implements MigratesUp +{ + public string $name = '2024_create_users_with_nullable_encrypted_data_table'; + + public function up(): CreateTableStatement + { + return CreateTableStatement::forModel(UserWithNullableEncryptedData::class) + ->primary() + ->string('email') + ->text('secret', nullable: true); + } +} diff --git a/tests/Integration/Database/HashedAttributeTest.php b/tests/Integration/Database/HashedAttributeTest.php new file mode 100644 index 000000000..375964f8d --- /dev/null +++ b/tests/Integration/Database/HashedAttributeTest.php @@ -0,0 +1,142 @@ + $this->container->get(PasswordHasher::class); + } + + #[Test] + public function hashes_value_on_insert(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithHashTable::class); + + $user = query(UserWithHash::class)->create( + email: 'test@example.com', + password: 'plaintext-password', // @mago-expect security/no-literal-password + ); + + // The current behavior when creating a model is to not refresh it. + // In this case, it might be a potential security issue? + $this->assertSame('plaintext-password', $user->password); + + $user->refresh(); + + $this->assertNotSame('plaintext-password', $user->password); + $this->assertTrue($this->hasher->verify('plaintext-password', $user->password)); + } + + #[Test] + public function hashes_value_on_update(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithHashTable::class); + + $user = query(UserWithHash::class)->create( + email: 'test@example.com', + password: 'original-password', // @mago-expect security/no-literal-password + )->refresh(); + + $originalHash = $user->password; + + $user->update(password: 'new-password')->refresh(); // @mago-expect security/no-literal-password + + $this->assertNotSame('new-password', $user->password); + $this->assertNotSame($originalHash, $user->password); + + $this->assertTrue($this->hasher->verify('new-password', $user->password)); + $this->assertFalse($this->hasher->verify('original-password', $user->password)); + } + + #[Test] + public function does_not_rehash_already_hashed_values(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithHashTable::class); + + $user = query(UserWithHash::class)->create( + email: 'test@example.com', + password: $alreadyHashed = $this->hasher->hash('plaintext-password'), + )->refresh(); + + $this->assertSame($alreadyHashed, $user->password); + $this->assertTrue($this->hasher->verify('plaintext-password', $user->password)); + } + + #[Test] + public function handles_null_values(): void + { + $this->migrate(CreateMigrationsTable::class, CreateUserWithNullablePasswordTable::class); + + $user = query(UserWithNullablePassword::class)->create( + email: 'test@example.com', + password: null, + )->refresh(); + + $this->assertNull($user->password); + } +} + +final class UserWithHash +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $email, + #[Hashed] + public string $password, + ) {} +} + +final class UserWithNullablePassword +{ + use IsDatabaseModel; + + public PrimaryKey $id; + + public function __construct( + public string $email, + #[Hashed] + public ?string $password, + ) {} +} + +final class CreateUserWithHashTable implements MigratesUp +{ + public string $name = '2024_create_users_table'; + + public function up(): CreateTableStatement + { + return CreateTableStatement::forModel(UserWithHash::class) + ->primary() + ->varchar('email') + ->text('password'); + } +} + +final class CreateUserWithNullablePasswordTable implements MigratesUp +{ + public string $name = '2024_create_users_with_nullable_password_table'; + + public function up(): CreateTableStatement + { + return CreateTableStatement::forModel(UserWithNullablePassword::class) + ->primary() + ->varchar('email') + ->text('password', nullable: true); + } +}