I want to have some form-fields encrypted when they are added/edited and decrypted when they are looked up by cake. Here is the code that works for me in v2.7.2:
<
Edit: @npm was right about the virtual properties not working. Now i'm angry at myself for giving a bad answer. serves me right for not checking it before I posted.
To make it right, I've implemented a version using behaviors to decrypt the fields as they are read, and encrypt them as they are written to the database.
Note: This code does not currently incorporate any custom finders, so it will not support searching by the encrypted field.
eg.
$this->Patient->findByPatientFirstname('bob'); // this will not work
Behavior
/src/Model/Behavior/EncryptBehavior.php
<?php
/**
*
*/
namespace Cake\ORM\Behavior;
use ArrayObject;
use Cake\Collection\Collection;
use Cake\Datasource\EntityInterface;
use Cake\Datasource\ResultSetInterface;
use Cake\Event\Event;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Inflector;
use Cake\Utility\Security;
use Cake\Log\Log;
/**
* Encrypt Behavior
*/
class EncryptBehavior extends Behavior
{
/**
* Default config
*
* These are merged with user-provided configuration when the behavior is used.
*
* @var array
*/
protected $_defaultConfig = [
'key' => 'YOUR_KEY_KERE', /* set them in the EntityTable, not here */
'fields' => []
];
/**
* Before save listener.
* Transparently manages setting the lft and rght fields if the parent field is
* included in the parameters to be saved.
*
* @param \Cake\Event\Event $event The beforeSave event that was fired
* @param \Cake\ORM\Entity $entity the entity that is going to be saved
* @return void
* @throws \RuntimeException if the parent to set for the node is invalid
*/
public function beforeSave(Event $event, Entity $entity)
{
$isNew = $entity->isNew();
$config = $this->config();
$values = $entity->extract($config['fields'], true);
$fields = array_keys($values);
$securityKey = $config['key'];
foreach($fields as $field){
if( isset($values[$field]) && !empty($values[$field]) ){
$entity->set($field, Security::encrypt($values[$field], $securityKey));
}
}
}
/**
* Callback method that listens to the `beforeFind` event in the bound
* table. It modifies the passed query
*
* @param \Cake\Event\Event $event The beforeFind event that was fired.
* @param \Cake\ORM\Query $query Query
* @param \ArrayObject $options The options for the query
* @return void
*/
public function beforeFind(Event $event, Query $query, $options)
{
$query->formatResults(function ($results){
return $this->_rowMapper($results);
}, $query::PREPEND);
}
/**
* Modifies the results from a table find in order to merge the decrypted fields
* into the results.
*
* @param \Cake\Datasource\ResultSetInterface $results Results to map.
* @return \Cake\Collection\Collection
*/
protected function _rowMapper($results)
{
return $results->map(function ($row) {
if ($row === null) {
return $row;
}
$hydrated = !is_array($row);
$fields = $this->_config['fields'];
$key = $this->_config['key'];
foreach ($fields as $field) {
$row[$field] = Security::decrypt($row[$field], $key);
}
if ($hydrated) {
$row->clean();
}
return $row;
});
}
}
Table
/src/Model/Table/PatientsTable.php
<?php
namespace App\Model\Table;
use App\Model\Entity\Patient;
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Core\Configure;
/**
* Patients Model
*
*/
class PatientsTable extends Table
{
/**
* Initialize method
*
* @param array $config The configuration for the Table.
* @return void
*/
public function initialize(array $config)
{
parent::initialize($config);
$this->table('patients');
$this->displayField('id');
$this->primaryKey('id');
// will encrypt these fields automatically
$this->addBehavior('Encrypt',[
'key' => Configure::read('Security.key'),
'fields' => [
'patient_surname',
'patient_firstname'
]
]);
}
}
I feel your pain. the ORM layer in cakephp 3 is radically different from cake2. They split the entity model and the table ORM into two different classes, and afterFind has been removed. I would take a look at using virtual properties. I think it might be suitable for your use case.
Example below.
<?php
namespace App\Model\Entity;
use Cake\ORM\Entity;
use Cake\Utility\Security;
use Cake\Core\Configure;
class Patient extends Entity
{
protected function _setPatientSurname($str)
{
$this->set('patient_surname', Security::encrypt($str, Configure::read('Security.key'));
}
protected function _setPatientFirstname($str)
{
$this->set('patient_firstname', Security::encrypt($str, Configure::read('Security.key'));
}
protected function _getPatientSurname()
{
return Security::decrypt($this->patient_surname, Configure::read('Security.key'));
}
protected function _getPatientFirstname()
{
return Security::decrypt($this->patient_first_name, Configure::read('Security.key'));
}
}
There's more than one way to solve this (please note that the following code is untested example code! You should get a grasp on the new basics first before using any of this).
One would be a custom database type, which would encrypt when binding the values to the database statement, and decrypt when results are being fetched. That's the option that I would prefer.
Here's simple example, assuming the db columns can hold binary data.
src/Database/Type/CryptedType.php
This should be rather self explantory, encrypt when casting to database, decrypt when casting to PHP.
<?php
namespace App\Database\Type;
use Cake\Database\Driver;
use Cake\Database\Type;
use Cake\Utility\Security;
class CryptedType extends Type
{
public function toDatabase($value, Driver $driver)
{
return Security::encrypt($value, Security::salt());
}
public function toPHP($value, Driver $driver)
{
if ($value === null) {
return null;
}
return Security::decrypt($value, Security::salt());
}
}
src/config/bootstrap.php
Register the custom type.
use Cake\Database\Type;
Type::map('crypted', 'App\Database\Type\CryptedType');
src/Model/Table/PatientsTable.php
Finally map the cryptable columns to the registered type, and that's it, from now on everything's being handled automatically.
// ...
use Cake\Database\Schema\Table as Schema;
class PatientsTable extends Table
{
// ...
protected function _initializeSchema(Schema $table)
{
$table->columnType('patient_surname', 'crypted');
$table->columnType('patient_first_name', 'crypted');
return $table;
}
// ...
}
See Cookbook > Database Access & ORM > Database Basics > Adding Custom Types
A less dry and tighter coupled approach, and basically a port of your 2.x code, would be to use the beforeSave
callback/event, and a result formatter. The result formatter could for example be attached in the beforeFind
event/callback.
In beforeSave
just set/get the values to/from the passed entity instance, you can utilize Entity::has()
, Entity::get()
and Entity::set()
, or even use array access since entities implement ArrayAccess
.
The result formatter is basically an after find hook, and you can use it to easily iterate over results, and modify them.
Here's a basic example, which shouldn't need much further explanation:
// ...
use Cake\Event\Event;
use Cake\ORM\Query;
class PatientsTable extends Table
{
// ...
public $encryptedFields = [
'patient_surname',
'patient_first_name'
];
public function beforeSave(Event $event, Entity $entity, \ArrayObject $options)
{
foreach($this->encryptedFields as $fieldName) {
if($entity->has($fieldName)) {
$entity->set(
$fieldName,
Security::encrypt($entity->get($fieldName), Security::salt())
);
}
}
return true;
}
public function beforeFind(Event $event, Query $query, \ArrayObject $options, boolean $primary)
{
$query->formatResults(
function ($results) {
/* @var $results \Cake\Datasource\ResultSetInterface|\Cake\Collection\CollectionInterface */
return $results->map(function ($row) {
/* @var $row array|\Cake\DataSource\EntityInterface */
foreach($this->encryptedFields as $fieldName) {
if(isset($row[$fieldName])) {
$row[$fieldName] = Security::decrypt($row[$fieldName], Security::salt());
}
}
return $row;
});
}
);
}
// ...
}
To decouple this a little, you could also move this into a behavior so that you can easily share it across multiple models.
See also