Skip to content

Commit

Permalink
Merge branch 'develop' into wip/softdelete-pivot
Browse files Browse the repository at this point in the history
  • Loading branch information
mjauvin committed Oct 14, 2023
2 parents e9595b2 + de146af commit 4ae7171
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 1 deletion.
158 changes: 158 additions & 0 deletions src/Database/Behaviors/Encryptable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php namespace Winter\Storm\Database\Behaviors;

use App;
use Illuminate\Contracts\Encryption\Encrypter;
use Winter\Storm\Database\Model;
use Winter\Storm\Exception\ApplicationException;
use Winter\Storm\Extension\ExtensionBase;

/**
* Encryptable model behavior
*
* Usage:
*
* In the model class definition:
*
* public $implement = [
* \Winter\Storm\Database\Behaviors\Encryptable::class,
* ];
*
* /**
* * List of attributes to encrypt.
* * /
* protected array $encryptable = ['api_key', 'api_secret'];
*
* Dynamically attached to third party model:
*
* TargetModel::extend(function ($model) {
* $model->addDynamicProperty('encryptable', ['encrypt_this']);
* $model->extendClassWith(\Winter\Storm\Database\Behaviors\Encryptable::class);
* });
*
* >**NOTE**: Encrypted attributes will be serialized and unserialized
* as a part of the encryption / decryption process. Do not make an
* attribute that is encryptable also jsonable at the same time as the
* jsonable process will attempt to decode a value that has already been
* unserialized by the encrypter.
*
*/
class Encryptable extends ExtensionBase
{
protected Model $model;

/**
* List of attribute names which should be encrypted
*
* protected array $encryptable = [];
*/

/**
* Encrypter instance.
*/
protected ?Encrypter $encrypterInstance = null;

/**
* List of original attribute values before they were encrypted.
*/
protected array $originalEncryptableValues = [];

public function __construct($parent)
{
$this->model = $parent;
$this->bootEncryptable();
}

/**
* Boot the encryptable trait for a model.
*/
public function bootEncryptable(): void
{
$isEncryptable = $this->model->extend(function () {
/** @var Model $this */
return $this->propertyExists('encryptable');
});

if (!$isEncryptable) {
throw new ApplicationException(sprintf(
'You must define an $encryptable property on the %s class to use the Encryptable behavior.',
get_class($this->model)
));
}

/*
* Encrypt required fields when necessary
*/
$this->model->bindEvent('model.beforeSetAttribute', function ($key, $value) {
if (in_array($key, $this->getEncryptableAttributes()) && !is_null($value)) {
return $this->makeEncryptableValue($key, $value);
}
});
$this->model->bindEvent('model.beforeGetAttribute', function ($key) {
if (in_array($key, $this->getEncryptableAttributes()) && array_get($this->model->attributes, $key) != null) {
return $this->getEncryptableValue($key);
}
});
}

/**
* Encrypts an attribute value and saves it in the original locker.
*/
public function makeEncryptableValue(string $key, mixed $value): string
{
$this->originalEncryptableValues[$key] = $value;
return $this->getEncrypter()->encrypt($value);
}

/**
* Decrypts an attribute value
*/
public function getEncryptableValue(string $key): mixed
{
$attributes = $this->model->getAttributes();
return isset($attributes[$key])
? $this->getEncrypter()->decrypt($attributes[$key])
: null;
}

/**
* Returns a collection of fields that will be encrypted.
*/
public function getEncryptableAttributes(): array
{
return $this->model->extend(function () {
return $this->encryptable ?? [];
});
}

/**
* Returns the original values of any encrypted attributes.
*/
public function getOriginalEncryptableValues(): array
{
return $this->originalEncryptableValues;
}

/**
* Returns the original values of any encrypted attributes.
*/
public function getOriginalEncryptableValue(string $attribute): mixed
{
return array_get($this->originalEncryptableValues, $attribute, null);
}

/**
* Provides the encrypter instance.
*/
public function getEncrypter(): Encrypter
{
return (!is_null($this->encrypterInstance)) ? $this->encrypterInstance : App::make('encrypter');
}

/**
* Sets the encrypter instance.
*/
public function setEncrypter(Encrypter $encrypter): void
{
$this->encrypterInstance = $encrypter;
}
}
2 changes: 1 addition & 1 deletion src/Database/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* @author Alexey Bobkov, Samuel Georges
*
* @phpstan-property \Illuminate\Contracts\Events\Dispatcher|null $dispatcher
* @method static void extend(callable $callback, bool $scoped = false, ?object $outerScope = null)
* @method static mixed extend(callable $callback, bool $scoped = false, ?object $outerScope = null)
*/
class Model extends EloquentModel implements ModelInterface
{
Expand Down
71 changes: 71 additions & 0 deletions tests/Database/Behaviors/EncryptableBehaviorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

use Illuminate\Encryption\Encrypter;

class EncryptableBehaviorTest extends DbTestCase
{
const TEST_CRYPT_KEY = 'gBmM1S5bxZ5ePRj5';

/**
* @var \Illuminate\Encryption\Encrypter Encrypter instance.
*/
protected $encrypter;

public function setUp(): void
{
parent::setUp();
$this->createTable();

$this->encrypter = new Encrypter(self::TEST_CRYPT_KEY, 'AES-128-CBC');
}

public function testEncryptableBehavior()
{
$testModel = new TestModelEncryptableBehavior();
$testModel->setEncrypter($this->encrypter);

$testModel->fill(['secret' => 'test']);
$this->assertEquals('test', $testModel->secret);
$this->assertNotEquals('test', $testModel->attributes['secret']);
$payloadOne = json_decode(base64_decode($testModel->attributes['secret']), true);
$this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadOne));

$testModel->secret = '';
$this->assertEquals('', $testModel->secret);
$this->assertNotEquals('', $testModel->attributes['secret']);
$payloadTwo = json_decode(base64_decode($testModel->attributes['secret']), true);
$this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadTwo));
$this->assertNotEquals($payloadOne['value'], $payloadTwo['value']);

$testModel->secret = 0;
$this->assertEquals(0, $testModel->secret);
$this->assertNotEquals(0, $testModel->attributes['secret']);
$payloadThree = json_decode(base64_decode($testModel->attributes['secret']), true);
$this->assertEquals(['iv', 'value', 'mac', 'tag'], array_keys($payloadThree));
$this->assertNotEquals($payloadTwo['value'], $payloadThree['value']);

$testModel->secret = null;
$this->assertNull($testModel->secret);
$this->assertNull($testModel->attributes['secret']);
}

protected function createTable()
{
$this->getBuilder()->create('secrets', function ($table) {
$table->increments('id');
$table->string('secret');
$table->timestamps();
});
}
}

class TestModelEncryptableBehavior extends \Winter\Storm\Database\Model
{
public $implement = [
\Winter\Storm\Database\Behaviors\Encryptable::class,
];

protected $encryptable = ['secret'];
protected $fillable = ['secret'];
protected $table = 'secrets';
}

0 comments on commit 4ae7171

Please sign in to comment.