Skip to content

Commit aca1f9a

Browse files
authored
feat(database): add #[Hashed] and #[Encrypted] attributes (#1514)
1 parent de2334b commit aca1f9a

File tree

15 files changed

+509
-2
lines changed

15 files changed

+509
-2
lines changed

docs/1-essentials/03-database.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,40 @@ final class Book
270270
}
271271
```
272272

273+
### Hashed properties
274+
275+
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.
276+
277+
```php
278+
final class User
279+
{
280+
public PrimaryKey $id;
281+
282+
public string $email;
283+
284+
#[Hashed]
285+
public ?string $password;
286+
}
287+
```
288+
289+
Hashing requires the `SIGNING_KEY` environment variable to be set, as it's used as the hashing key.
290+
291+
### Encrypted properties
292+
293+
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.
294+
295+
```php
296+
final class User
297+
{
298+
// ...
299+
300+
#[Encrypted]
301+
public ?string $accessToken;
302+
}
303+
```
304+
305+
The encryption key is taken from the `SIGNING_KEY` environment variable.
306+
273307
### DTO properties
274308

275309
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).

packages/cryptography/src/Password/GenericPasswordHasher.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,14 @@ public function needsRehash(#[\SensitiveParameter] string $hash): bool
4444
return password_needs_rehash($hash, $this->algorithm->value, $this->config->options);
4545
}
4646

47-
public function analyze(#[\SensitiveParameter] string $hash): Hash
47+
public function analyze(#[\SensitiveParameter] string $hash): ?Hash
4848
{
4949
$info = password_get_info($hash);
50+
51+
if ($info['algo'] === null) {
52+
return null;
53+
}
54+
5055
$algorithm = HashingAlgorithm::from($info['algo']);
5156

5257
return new Hash(

packages/cryptography/src/Password/PasswordHasher.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ public function needsRehash(#[\SensitiveParameter] string $hash): bool;
2929
/**
3030
* Returns informations about the given hash, such as the algorithm used and its options.
3131
*/
32-
public function analyze(#[\SensitiveParameter] string $hash): Hash;
32+
public function analyze(#[\SensitiveParameter] string $hash): ?Hash;
3333
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Casters;
6+
7+
use Tempest\Cryptography\Encryption\EncryptedData;
8+
use Tempest\Cryptography\Encryption\Encrypter;
9+
use Tempest\Cryptography\Encryption\Exceptions\EncryptionException;
10+
use Tempest\Mapper\Caster;
11+
use Tempest\Support\Json\Exception\JsonException;
12+
13+
final readonly class EncryptedCaster implements Caster
14+
{
15+
public function __construct(
16+
private Encrypter $encrypter,
17+
) {}
18+
19+
public function cast(mixed $input): ?string
20+
{
21+
if ($input === null) {
22+
return null;
23+
}
24+
25+
try {
26+
return $this->encrypter->decrypt($input);
27+
} catch (EncryptionException|JsonException) {
28+
return $input;
29+
}
30+
}
31+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
use Tempest\Database\Casters\EncryptedCaster;
9+
use Tempest\Database\Serializers\EncryptedSerializer;
10+
use Tempest\Database\Serializers\HashedSerializer;
11+
use Tempest\Mapper\ProvidesCaster;
12+
use Tempest\Mapper\ProvidesSerializer;
13+
14+
/**
15+
* The associated property will be encrypted during serialization and decrypted during casting.
16+
*/
17+
#[Attribute(Attribute::TARGET_PROPERTY)]
18+
final class Encrypted implements ProvidesSerializer, ProvidesCaster
19+
{
20+
public string $serializer {
21+
get => EncryptedSerializer::class;
22+
}
23+
24+
public string $caster {
25+
get => EncryptedCaster::class;
26+
}
27+
}

packages/database/src/Hashed.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database;
6+
7+
use Attribute;
8+
use Tempest\Database\Serializers\HashedSerializer;
9+
use Tempest\Mapper\ProvidesSerializer;
10+
11+
/**
12+
* The associated property will be hashed during serialization.
13+
*/
14+
#[Attribute(Attribute::TARGET_PROPERTY)]
15+
final class Hashed implements ProvidesSerializer
16+
{
17+
public string $serializer {
18+
get => HashedSerializer::class;
19+
}
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Tempest\Database\Serializers;
4+
5+
use Tempest\Cryptography\Encryption\EncryptedData;
6+
use Tempest\Cryptography\Encryption\Encrypter;
7+
use Tempest\Cryptography\Encryption\Exceptions\EncryptionException;
8+
use Tempest\Mapper\Serializer;
9+
use Tempest\Support\Json\Exception\JsonException;
10+
11+
final readonly class EncryptedSerializer implements Serializer
12+
{
13+
public function __construct(
14+
private Encrypter $encrypter,
15+
) {}
16+
17+
public function serialize(mixed $input): array|string
18+
{
19+
if (! is_string($input)) {
20+
return $input;
21+
}
22+
23+
try {
24+
EncryptedData::unserialize($input);
25+
} catch (EncryptionException|JsonException) {
26+
return $this->encrypter->encrypt($input);
27+
}
28+
29+
return $input;
30+
}
31+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Tempest\Database\Serializers;
4+
5+
use Tempest\Cryptography\Password\PasswordHasher;
6+
use Tempest\Mapper\Serializer;
7+
8+
final readonly class HashedSerializer implements Serializer
9+
{
10+
public function __construct(
11+
private PasswordHasher $passwordHasher,
12+
) {}
13+
14+
public function serialize(mixed $input): string
15+
{
16+
if (! is_string($input)) {
17+
return $input;
18+
}
19+
20+
if (! $this->passwordHasher->analyze($input)) {
21+
return $this->passwordHasher->hash($input);
22+
}
23+
24+
return $input;
25+
}
26+
}

packages/mapper/src/CasterFactory.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ public function forProperty(PropertyReflector $property): ?Caster
4949
return get($castWith->className);
5050
}
5151

52+
if ($casterAttribute = $property->getAttribute(ProvidesCaster::class)) {
53+
return get($casterAttribute->caster);
54+
}
55+
5256
// Resolve caster from manual additions
5357
foreach ($this->casters as [$for, $casterClass]) {
5458
if (is_callable($for) && $for($property) || is_string($for) && $type->matches($for) || $type->getName() === $for) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace Tempest\Mapper;
4+
5+
/**
6+
* Implemented on an attribute, this interface specifies that a specific caster must be used to serialize the associated property.
7+
*/
8+
interface ProvidesCaster
9+
{
10+
/** @var class-string<Caster> */
11+
public string $caster {
12+
get;
13+
}
14+
}

0 commit comments

Comments
 (0)