diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..5894540 --- /dev/null +++ b/MIGRATING.md @@ -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?) + +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). diff --git a/README.md b/README.md index bad5f90..3880b52 100644 --- a/README.md +++ b/README.md @@ -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. @@ -33,7 +33,7 @@ N.B. Using Laravel 6-8? Please use version [1.x](https://github.com/swisnl/larav 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', ]; @@ -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', @@ -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 diff --git a/src/Commands/ReEncryptModels.php b/src/Commands/ReEncryptModels.php new file mode 100644 index 0000000..71f9f9b --- /dev/null +++ b/src/Commands/ReEncryptModels.php @@ -0,0 +1,167 @@ +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> + */ + 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([$this, 'normalizeModelClass']) + ->each(function (string $modelClass): void { + if (!class_exists($modelClass)) { + throw new \InvalidArgumentException(sprintf('Model class %s does not exist.', $modelClass)); + } + }); + } + + if (!empty($except = $this->option('except'))) { + $except = array_map([$this, 'normalizeModelClass'], $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()) ? $modelClass : $this->getModelsNamespace().$modelClass; + } +} diff --git a/src/EncryptedDataServiceProvider.php b/src/EncryptedDataServiceProvider.php index 5e31eba..d1c8b2b 100644 --- a/src/EncryptedDataServiceProvider.php +++ b/src/EncryptedDataServiceProvider.php @@ -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 { @@ -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 diff --git a/src/EncryptedModel.php b/src/EncryptedModel.php index 24053cd..13b3db3 100644 --- a/src/EncryptedModel.php +++ b/src/EncryptedModel.php @@ -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 { /** @@ -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} * diff --git a/src/ModelEncrypter.php b/src/ModelEncrypter.php new file mode 100644 index 0000000..d0e34aa --- /dev/null +++ b/src/ModelEncrypter.php @@ -0,0 +1,50 @@ +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(); + } +}