Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
7 changes: 6 additions & 1 deletion packages/cryptography/src/Password/GenericPasswordHasher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/cryptography/src/Password/PasswordHasher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
31 changes: 31 additions & 0 deletions packages/database/src/Casters/EncryptedCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Casters;

use Tempest\Cryptography\Encryption\EncryptedData;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Cryptography\Encryption\Exceptions\EncryptionException;
use Tempest\Mapper\Caster;
use Tempest\Support\Json\Exception\JsonException;

final readonly class EncryptedCaster implements Caster
{
public function __construct(
private Encrypter $encrypter,
) {}

public function cast(mixed $input): ?string
{
if ($input === null) {
return null;
}

try {
return $this->encrypter->decrypt($input);
} catch (EncryptionException|JsonException) {
return $input;
}
}
}
27 changes: 27 additions & 0 deletions packages/database/src/Encrypted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use Attribute;
use Tempest\Database\Casters\EncryptedCaster;
use Tempest\Database\Serializers\EncryptedSerializer;
use Tempest\Database\Serializers\HashedSerializer;
use Tempest\Mapper\ProvidesCaster;
use Tempest\Mapper\ProvidesSerializer;

/**
* The associated property will be encrypted during serialization and decrypted during casting.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Encrypted implements ProvidesSerializer, ProvidesCaster
{
public string $serializer {
get => EncryptedSerializer::class;
}

public string $caster {
get => EncryptedCaster::class;
}
}
20 changes: 20 additions & 0 deletions packages/database/src/Hashed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Tempest\Database;

use Attribute;
use Tempest\Database\Serializers\HashedSerializer;
use Tempest\Mapper\ProvidesSerializer;

/**
* The associated property will be hashed during serialization.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class Hashed implements ProvidesSerializer
{
public string $serializer {
get => HashedSerializer::class;
}
}
31 changes: 31 additions & 0 deletions packages/database/src/Serializers/EncryptedSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Tempest\Database\Serializers;

use Tempest\Cryptography\Encryption\EncryptedData;
use Tempest\Cryptography\Encryption\Encrypter;
use Tempest\Cryptography\Encryption\Exceptions\EncryptionException;
use Tempest\Mapper\Serializer;
use Tempest\Support\Json\Exception\JsonException;

final readonly class EncryptedSerializer implements Serializer
{
public function __construct(
private Encrypter $encrypter,
) {}

public function serialize(mixed $input): array|string
{
if (! is_string($input)) {
return $input;
}

try {
EncryptedData::unserialize($input);
} catch (EncryptionException|JsonException) {
return $this->encrypter->encrypt($input);
}

return $input;
}
}
26 changes: 26 additions & 0 deletions packages/database/src/Serializers/HashedSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Tempest\Database\Serializers;

use Tempest\Cryptography\Password\PasswordHasher;
use Tempest\Mapper\Serializer;

final readonly class HashedSerializer implements Serializer
{
public function __construct(
private PasswordHasher $passwordHasher,
) {}

public function serialize(mixed $input): string
{
if (! is_string($input)) {
return $input;
}

if (! $this->passwordHasher->analyze($input)) {
return $this->passwordHasher->hash($input);
}

return $input;
}
}
4 changes: 4 additions & 0 deletions packages/mapper/src/CasterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions packages/mapper/src/ProvidesCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Tempest\Mapper;

/**
* Implemented on an attribute, this interface specifies that a specific caster must be used to serialize the associated property.
*/
interface ProvidesCaster
{
/** @var class-string<Caster> */
public string $caster {
get;
}
}
14 changes: 14 additions & 0 deletions packages/mapper/src/ProvidesSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Tempest\Mapper;

/**
* Implemented on an attribute, this interface specifies that a specific serializer must be used to serialize the associated property.
*/
interface ProvidesSerializer
{
/** @var class-string<Serializer> */
public string $serializer {
get;
}
}
4 changes: 4 additions & 0 deletions packages/mapper/src/SerializerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Support\Json\Exception;

use InvalidArgumentException;
use Tempest\Support\Json\Exception\JsonException;

final class JsonCouldNotBeDecoded extends InvalidArgumentException implements JsonException
{
Expand Down
Loading
Loading