diff --git a/src/Database/Behaviors/Encryptable.php b/src/Database/Behaviors/Encryptable.php new file mode 100644 index 000000000..9f525a234 --- /dev/null +++ b/src/Database/Behaviors/Encryptable.php @@ -0,0 +1,158 @@ +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; + } +} diff --git a/src/Database/Model.php b/src/Database/Model.php index 073566e0c..7e436569c 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -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 { diff --git a/tests/Database/Behaviors/EncryptableBehaviorTest.php b/tests/Database/Behaviors/EncryptableBehaviorTest.php new file mode 100644 index 000000000..54dfd4a86 --- /dev/null +++ b/tests/Database/Behaviors/EncryptableBehaviorTest.php @@ -0,0 +1,71 @@ +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'; +}