Skip to content

Commit 4abffe6

Browse files
committed
feat: add support for encrypted date(time)
1 parent be1386f commit 4abffe6

15 files changed

+716
-16
lines changed

MIGRATING.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## To Laravel Encrypted Casting
44
The main difference between this package and [Laravel Encrypted Casting](https://laravel.com/docs/10.x/eloquent-mutators#encrypted-casting) is that this package serializes the data before encrypting it, while Laravel Encrypted Casting encrypts the data directly. This means that the data is not compatible between the two packages. In order to migrate from this package to Laravel Encrypted Casting, you will need to decrypt the data and then re-encrypt it using Laravel Encrypted Casting. Here is a step-by-step guide on how to do this:
55

6-
[//]: # (TODO: What to do when you need serialized data or encrypted dates?)
6+
[//]: # (TODO: What to do when you need encrypted serialized data?)
77

88
1. Make sure you're running on Laravel 11 or higher.
99
2. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`:
@@ -23,7 +23,20 @@ The main difference between this package and [Laravel Encrypted Casting](https:/
2323
+ 'secret' => 'encrypted',
2424
+ ];
2525
```
26-
4. Set up our custom model encrypter in your `AppServiceProvider`:
26+
4. If you're using encrypted date(time)s, use the custom casts provided by this package:
27+
```diff
28+
- protected $encrypted = [
29+
- 'secret',
30+
- ];
31+
-
32+
- protected $casts = [
33+
- 'secret' => 'datetime',
34+
- ];
35+
+ protected $casts = [
36+
+ 'secret' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class,
37+
+ ];
38+
```
39+
5. Set up our custom model encrypter in your `AppServiceProvider`:
2740
```php
2841
public function boot(): void
2942
{
@@ -32,9 +45,9 @@ public function boot(): void
3245
// ... all your other models that used to extend \Swis\Laravel\Encrypted\EncryptedModel
3346
}
3447
```
35-
5. Run our re-encryption command:
48+
6. Run our re-encryption command:
3649
```bash
3750
php artisan encrypted-data:re-encrypt:models --quietly --no-touch
3851
```
3952
N.B. Use `--help` to see all available options and modify as needed!
40-
6. Remove our custom model encrypter from your `AppServiceProvider` (step 4).
53+
7. Remove our custom model encrypter from your `AppServiceProvider` (step 5).

README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,26 @@ composer require swisnl/laravel-encrypted-data
2121

2222
## Usage
2323

24-
### Models
24+
### Eloquent casts
2525

2626
> [!WARNING]
27-
> Laravel supports [encrypted casts](https://laravel.com/docs/10.x/eloquent-mutators#encrypted-casting) since version 8.12, so new projects should use that instead of the models provided by this package.
28-
>
29-
> Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate.
27+
> Older versions of this package needed a custom model class to encrypt data. This is now deprecated in favor of custom casts. Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate.
3028
>
3129
32-
Extend `\Swis\Laravel\Encrypted\EncryptedModel` in your model and define the encrypted fields. Make sure your database columns are long enough, so your data isn't truncated!
30+
You can use the Eloquent casts provided by this package and everything will be encrypted/decrypted under the hood!
31+
32+
#### Datetime
3333

3434
```php
35-
protected $encrypted = [
36-
'secret',
35+
protected $casts = [
36+
'date' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDate::class,
37+
'datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class,
38+
'immutable_date' => \Swis\Laravel\Encrypted\Casts\AsEncryptedImmutableDate::class,
39+
'immutable_datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedImmutableDateTime::class,
40+
'date_with_custom_format' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDate::format('Y-m-d'),
3741
];
3842
```
3943

40-
You can now simply use the model properties as usual and everything will be encrypted/decrypted under the hood!
41-
4244
### Filesystem
4345

4446
Configure the storage driver in `config/filesystems.php`.
@@ -60,10 +62,9 @@ Due to the encryption, some issues/limitations apply:
6062

6163
1. Encrypted data is — depending on what you encrypt — roughly 30-40% bigger.
6264

63-
### Models
65+
### Casts
6466

65-
1. You can't query or order columns that are encrypted in your SQL-statements, but you can query or sort the results using collection methods;
66-
2. All data is being serialized before it is encrypted — and unserialized after it is decrypted — so everything is stored exactly as how Laravel would insert it into the database. You can use [Eloquent Mutators](https://laravel.com/docs/9.x/eloquent-mutators) as you normally would.
67+
1. You can't query or order columns that are encrypted in your SQL-statements, but you can query or sort the results using collection methods.
6768

6869
### Filesystem
6970

src/Casts/AsEncryptedDate.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Swis\Laravel\Encrypted\Casts;
6+
7+
use Carbon\CarbonImmutable;
8+
use Illuminate\Contracts\Database\Eloquent\Castable;
9+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
10+
use Illuminate\Support\Carbon;
11+
12+
class AsEncryptedDate implements Castable
13+
{
14+
/**
15+
* @param string[] $arguments
16+
*/
17+
public static function castUsing(array $arguments): CastsAttributes
18+
{
19+
return new EncryptedDateTime($arguments, fn (#[\SensitiveParameter] Carbon|CarbonImmutable|null $value) => $value?->startOfDay());
20+
}
21+
22+
/**
23+
* Specify the format to use when the model is serialized to an array or JSON.
24+
*/
25+
public static function format(string $format): string
26+
{
27+
return static::class.':'.$format;
28+
}
29+
}

src/Casts/AsEncryptedDateTime.php

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 Swis\Laravel\Encrypted\Casts;
6+
7+
use Illuminate\Contracts\Database\Eloquent\Castable;
8+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
9+
10+
class AsEncryptedDateTime implements Castable
11+
{
12+
/**
13+
* @param string[] $arguments
14+
*/
15+
public static function castUsing(array $arguments): CastsAttributes
16+
{
17+
return new EncryptedDateTime($arguments);
18+
}
19+
20+
/**
21+
* Specify the format to use when the model is serialized to an array or JSON.
22+
*/
23+
public static function format(string $format): string
24+
{
25+
return static::class.':'.$format;
26+
}
27+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Swis\Laravel\Encrypted\Casts;
6+
7+
use Carbon\CarbonImmutable;
8+
use Illuminate\Contracts\Database\Eloquent\Castable;
9+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
10+
use Illuminate\Support\Carbon;
11+
12+
class AsEncryptedImmutableDate implements Castable
13+
{
14+
/**
15+
* @param string[] $arguments
16+
*/
17+
public static function castUsing(array $arguments): CastsAttributes
18+
{
19+
return new EncryptedDateTime($arguments, fn (#[\SensitiveParameter] Carbon|CarbonImmutable|null $value) => $value?->startOfDay()->toImmutable());
20+
}
21+
22+
/**
23+
* Specify the format to use when the model is serialized to an array or JSON.
24+
*/
25+
public static function format(string $format): string
26+
{
27+
return static::class.':'.$format;
28+
}
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Swis\Laravel\Encrypted\Casts;
6+
7+
use Carbon\CarbonImmutable;
8+
use Illuminate\Contracts\Database\Eloquent\Castable;
9+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
10+
use Illuminate\Support\Carbon;
11+
12+
class AsEncryptedImmutableDateTime implements Castable
13+
{
14+
/**
15+
* @param string[] $arguments
16+
*/
17+
public static function castUsing(array $arguments): CastsAttributes
18+
{
19+
return new EncryptedDateTime($arguments, fn (#[\SensitiveParameter] Carbon|CarbonImmutable|null $value) => $value?->toImmutable());
20+
}
21+
22+
/**
23+
* Specify the format to use when the model is serialized to an array or JSON.
24+
*/
25+
public static function format(string $format): string
26+
{
27+
return static::class.':'.$format;
28+
}
29+
}

src/Casts/EncryptedDateTime.php

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Swis\Laravel\Encrypted\Casts;
6+
7+
use Carbon\CarbonImmutable;
8+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Support\Carbon;
11+
use Illuminate\Support\Facades\Crypt;
12+
use Illuminate\Support\Facades\Date;
13+
14+
/**
15+
* @internal
16+
*
17+
* @implements \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Carbon|\Carbon\CarbonImmutable, string|\Illuminate\Support\Carbon|\Carbon\CarbonImmutable>
18+
*/
19+
class EncryptedDateTime implements CastsAttributes
20+
{
21+
/**
22+
* @param array<int, string> $arguments
23+
* @param \Closure(Carbon|CarbonImmutable|null): (Carbon|CarbonImmutable|null)|null $modifier
24+
*/
25+
public function __construct(protected array $arguments, protected ?\Closure $modifier = null)
26+
{
27+
}
28+
29+
/**
30+
* @param array<string, mixed> $attributes
31+
*/
32+
public function get(Model $model, string $key, mixed $value, array $attributes): Carbon|CarbonImmutable|null
33+
{
34+
if ($value === null) {
35+
return null;
36+
}
37+
38+
$value = ($model::$encrypter ?? Crypt::getFacadeRoot())->decrypt($value, false);
39+
40+
return with($this->parse($model, $key, $value, $attributes), $this->modifier);
41+
}
42+
43+
/**
44+
* @param \Illuminate\Support\Carbon|\Carbon\CarbonImmutable|string|null $value
45+
* @param array<string, mixed> $attributes
46+
*/
47+
public function set(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): ?string
48+
{
49+
if ($value === null) {
50+
return null;
51+
}
52+
53+
$value = is_string($value) ? $value : $value->format($model->getDateFormat());
54+
55+
return ($model::$encrypter ?? Crypt::getFacadeRoot())->encrypt($value, false);
56+
}
57+
58+
/**
59+
* @param string|null $value
60+
* @param array<string, mixed> $attributes
61+
*/
62+
public function serialize(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): ?string
63+
{
64+
if ($value === null) {
65+
return null;
66+
}
67+
68+
return !empty($this->arguments[0]) ? Date::parse($value)->format($this->arguments[0]) : $value;
69+
}
70+
71+
/**
72+
* @param string|null $original
73+
* @param string|null $value
74+
*/
75+
public function compare(Model $model, string $key, mixed $original, mixed $value): bool
76+
{
77+
if (!empty(($model::$encrypter ?? Crypt::getFacadeRoot())->getPreviousKeys())) {
78+
return false;
79+
}
80+
81+
$original = $this->get($model, $key, $original, []);
82+
$value = $this->get($model, $key, $value, []);
83+
84+
if ($original === $value) {
85+
return true;
86+
}
87+
88+
if ($original === null || $value === null) {
89+
return false;
90+
}
91+
92+
return $original->equalTo($value);
93+
}
94+
95+
/**
96+
* @param string|int $value
97+
* @param array<string, mixed> $attributes
98+
*/
99+
protected function parse(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): Carbon|CarbonImmutable|null
100+
{
101+
if (is_numeric($value)) {
102+
return Date::createFromTimestamp($value, date_default_timezone_get());
103+
}
104+
105+
if ($this->isStandardDateFormat($value)) {
106+
return Date::instance(Carbon::createFromFormat('Y-m-d', $value)->startOfDay());
107+
}
108+
109+
try {
110+
return Date::createFromFormat($model->getDateFormat(), $value);
111+
} catch (\InvalidArgumentException) {
112+
return Date::parse($value);
113+
}
114+
}
115+
116+
protected function isStandardDateFormat(#[\SensitiveParameter] string $value): bool
117+
{
118+
return (bool) preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value);
119+
}
120+
}

src/Commands/ReEncryptModels.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Swis\Laravel\Encrypted\Commands;
46

57
use Illuminate\Console\Command;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Swis\Laravel\Encrypted\Tests\Casts;
4+
5+
use Illuminate\Support\Carbon;
6+
use PHPUnit\Framework\TestCase;
7+
use Swis\Laravel\Encrypted\Casts\AsEncryptedDate;
8+
use Swis\Laravel\Encrypted\Tests\_mocks\DummyEncrypter;
9+
use Swis\Laravel\Encrypted\Tests\_mocks\Model;
10+
11+
class AsEncryptedDateTest extends TestCase
12+
{
13+
protected function setUp(): void
14+
{
15+
parent::setUp();
16+
Model::$encrypter = new DummyEncrypter();
17+
}
18+
19+
public function testGetReturnsNullForNull(): void
20+
{
21+
$cast = AsEncryptedDate::castUsing([]);
22+
$model = new Model();
23+
24+
$result = $cast->get($model, 'date', null, []);
25+
26+
$this->assertNull($result);
27+
}
28+
29+
public function testGetAppliesStartOfDayClosure(): void
30+
{
31+
$cast = AsEncryptedDate::castUsing([]);
32+
$model = new Model();
33+
$date = '2024-07-02 15:30:45';
34+
35+
$result = $cast->get($model, 'date', $date, []);
36+
37+
$this->assertInstanceOf(Carbon::class, $result);
38+
$this->assertEquals('2024-07-02 00:00:00', $result->toDateTimeString());
39+
}
40+
41+
public function testFormatReturnsExpectedString(): void
42+
{
43+
$this->assertEquals(AsEncryptedDate::class.':Y-m-d', AsEncryptedDate::format('Y-m-d'));
44+
}
45+
}

0 commit comments

Comments
 (0)