Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add migration guide and command #3

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
39 changes: 39 additions & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Migrating swisnl/laravel-encrypted-data

## To Laravel Encrypted Casting
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:

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

1. Set up Encrypted Casting, but keep extending `EncryptedModel` from this package:
```diff
- protected $encrypted = [
- 'secret',
- ];
+ protected $casts = [
+ 'secret' => 'encrypted',
+ ];
```
2. Set up our custom model encrypter in your `AppServiceProvider`:
```php
public function boot(): void
{
$modelEncrypter = new \Swis\Laravel\Encrypted\ModelEncrypter();
YourEncryptedModel::encryptUsing($modelEncrypter);
// ... all your other models that extend \Swis\Laravel\Encrypted\EncryptedModel
}
```
3. Run our re-encryption command:
```bash
php artisan encrypted-data:re-encrypt:models --quietly --no-touch
```
N.B. Use `--help` to see all available options and modify as needed!
4. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`:
```diff
- use Swis\Laravel\Encrypted\EncryptedModel
+ use Illuminate\Database\Eloquent\Model

- class YourEncryptedModel extends EncryptedModel
+ class YourEncryptedModel extends Model
```
5. Remove our custom model encrypter from your `AppServiceProvider` (step 2).
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ This package contains several Laravel utilities to work with encrypted data.

Via Composer

``` bash
$ composer require swisnl/laravel-encrypted-data
```bash
composer require swisnl/laravel-encrypted-data
```

N.B. Using Laravel 6-8? Please use version [1.x](https://github.com/swisnl/laravel-encrypted-data/tree/1.x) of this package.
Expand All @@ -28,12 +28,12 @@ N.B. Using Laravel 6-8? Please use version [1.x](https://github.com/swisnl/larav
> [!WARNING]
> 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.
>
> We aim to provide a migration path to encrypted casts. See issue [#1](https://github.com/swisnl/laravel-encrypted-data/issues/1) for more information.
> Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate.
>

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!

``` php
```php
protected $encrypted = [
'secret',
];
Expand All @@ -45,7 +45,7 @@ You can now simply use the model properties as usual and everything will be encr

Configure the storage driver in `config/filesystems.php`.

``` php
```php
'disks' => [
'local' => [
'driver' => 'local-encrypted',
Expand Down Expand Up @@ -78,8 +78,8 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re

## Testing

``` bash
$ composer test
```bash
composer test
```

## Contributing
Expand Down
170 changes: 170 additions & 0 deletions src/Commands/ReEncryptModels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace Swis\Laravel\Encrypted\Commands;

use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

class ReEncryptModels extends Command
{
protected $signature = 'encrypted-data:re-encrypt:models
{--model=* : Class names of the models to be re-encrypted}
{--except=* : Class names of the models to be excluded from re-encryption}
{--path=* : Absolute path(s) to directories where models are located}
{--chunk=1000 : The number of models to retrieve per chunk of models to be re-encrypted}
{--quietly : Re-encrypt the models without raising any events}
{--no-touch : Re-encrypt the models without updating timestamps}
{--with-trashed : Re-encrypt trashed models}';

protected $description = 'Re-encrypt models';

public function handle(): int
{
$models = $this->models();

if ($models->isEmpty()) {
$this->warn('No models found.');

return self::FAILURE;
}

if ($this->confirm('The following models will be re-encrypted: '.PHP_EOL.$models->implode(PHP_EOL).PHP_EOL.'Do you want to continue?') === false) {
return self::FAILURE;
}

$models->each(function (string $model) {
$this->line("Re-encrypting {$model}...");
$this->reEncryptModels($model);
});

$this->info('Re-encrypting done!');

return self::SUCCESS;
}

/**
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
*/
protected function reEncryptModels(string $modelClass): void
{
$modelClass::unguarded(function () use ($modelClass) {
$modelClass::query()
->when($this->option('with-trashed') && in_array(SoftDeletes::class, class_uses_recursive($modelClass), true), function ($query) {
$query->withTrashed();
})
->eachById(
function (Model $model) {
if ($this->option('no-touch')) {
$model->timestamps = false;
}

// Set each encrypted attribute to trigger re-encryption
collect($model->getCasts())
->keys()
->filter(fn ($key) => $model->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']))
->each(fn ($key) => $model->setAttribute($key, $model->getAttribute($key)));

if ($this->option('quietly')) {
$model->saveQuietly();
} else {
$model->save();
}
},
$this->option('chunk')
);
});
}

/**
* Determine the models that should be re-encrypted.
*
* @return \Illuminate\Support\Collection<int, class-string<\Illuminate\Database\Eloquent\Model>>
*/
protected function models(): Collection
{
if (!empty($this->option('model')) && !empty($this->option('except'))) {
throw new \InvalidArgumentException('The --models and --except options cannot be combined.');
}

if (!empty($models = $this->option('model'))) {
return collect($models)
->map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass))
->each(function (string $modelClass): void {
if (!class_exists($modelClass)) {
throw new \InvalidArgumentException(sprintf('Model class %s does not exist.', $modelClass));
}
if (!is_a($modelClass, Model::class, true)) {
throw new \InvalidArgumentException(sprintf('Class %s is not a model.', $modelClass));
}
});
}

if (!empty($except = $this->option('except'))) {
$except = array_map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass), $except);
}

return collect(Finder::create()->in($this->getModelsPath())->files()->name('*.php'))
->map(function (SplFileInfo $modelFile): string {
$namespace = $this->laravel->getNamespace();

return $namespace.str_replace(
[DIRECTORY_SEPARATOR, '.php'],
['\\', ''],
Str::after($modelFile->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR)
);
})
->when(!empty($except), fn (Collection $modelClasses): Collection => $modelClasses->reject(fn (string $modelClass) => in_array($modelClass, $except, true)))
->filter(fn (string $modelClass): bool => class_exists($modelClass) && is_a($modelClass, Model::class, true))
->reject(function (string $modelClass): bool {
$model = new $modelClass();

return collect($model->getCasts())
->keys()
->filter(fn (string $key): bool => $model->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']))
->isEmpty();
})
->values();
}

/**
* Get the path where models are located.
*
* @return string[]|string
*/
protected function getModelsPath(): string|array
{
if (!empty($path = $this->option('path'))) {
return collect($path)
->map(fn (string $path): string => base_path($path))
->each(function (string $path): void {
if (!is_dir($path)) {
throw new \InvalidArgumentException(sprintf('The path %s is not a directory.', $path));
}
})
->all();
}

return is_dir($path = app_path('Models')) ? $path : app_path();
}

/**
* Get the namespace of models.
*/
protected function getModelsNamespace(): string
{
return is_dir(app_path('Models')) ? $this->laravel->getNamespace().'Models\\' : $this->laravel->getNamespace();
}

/**
* Make sure the model class is a FQCN.
*/
protected function normalizeModelClass(string $modelClass): string
{
return str_starts_with($modelClass, $this->getModelsNamespace()) || str_starts_with($modelClass, '\\'.$this->getModelsNamespace()) ? ltrim($modelClass, '\\') : $this->getModelsNamespace().$modelClass;
}
}
7 changes: 7 additions & 0 deletions src/EncryptedDataServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
use League\Flysystem\Visibility;
use Swis\Flysystem\Encrypted\EncryptedFilesystemAdapter;
use Swis\Laravel\Encrypted\Commands\ReEncryptModels;

class EncryptedDataServiceProvider extends ServiceProvider
{
Expand All @@ -28,6 +29,12 @@ protected function registerEncrypter(): void
public function boot(): void
{
$this->setupStorageDriver();

if ($this->app->runningInConsole()) {
$this->commands([
ReEncryptModels::class,
]);
}
}

protected function setupStorageDriver(): void
Expand Down
20 changes: 20 additions & 0 deletions src/EncryptedModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

/**
* @deprecated use Laravel's built-in encrypted casting instead, this class will be removed in a future version
* @see ../MIGRATING.md for a step-by-step guide on how to migrate
*/
class EncryptedModel extends Model
{
/**
Expand All @@ -28,6 +32,22 @@ public function setRawAttributes(array $attributes, $sync = false)
return parent::setRawAttributes($this->decryptAttributes($attributes), $sync);
}

/**
* {@inheritdoc}
*
* @param string $key
*
* @return bool
*/
public function originalIsEquivalent($key)
{
if (static::$encrypter instanceof ModelEncrypter && $this->isEncryptedCastable($key)) {
return false;
}

return parent::originalIsEquivalent($key);
}

/**
* {@inheritdoc}
*
Expand Down
50 changes: 50 additions & 0 deletions src/ModelEncrypter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Swis\Laravel\Encrypted;

use Illuminate\Contracts\Encryption\Encrypter;

/**
* @deprecated only use this when migrating from this package to Laravel's built-in encrypted casting
*/
class ModelEncrypter implements Encrypter
{
private ?Encrypter $encrypter;

public function __construct()
{
$this->encrypter = app('encrypted-data.encrypter');
}

public function encrypt($value, $serialize = true)
{
return $this->encrypter->encrypt($value, $serialize);
}

public function decrypt($payload, $unserialize = true)
{
$decrypted = $this->encrypter->decrypt($payload, false);

$unserialized = @unserialize($decrypted);
if ($unserialized === false && $decrypted !== 'b:0;') {
return $decrypted;
}

return $unserialized;
}

public function getKey()
{
return $this->encrypter->getKey();
}

public function getAllKeys()
{
return $this->encrypter->getAllKeys();
}

public function getPreviousKeys()
{
return $this->encrypter->getPreviousKeys();
}
}
Loading