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
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';
}