diff --git a/CHANGELOG.md b/CHANGELOG.md index e020e664..4b552c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ before starting to add changes. Use example [placed in the end of the page](#exa ## [Unreleased] +- [#114](https://github.com/OS2Forms/os2forms/pull/114) + Encrypted computed elements. + ## [3.15.3] 2024-06-25 - [OS-74] Replacing DAWA matrikula select with Datafordeler select diff --git a/composer.json b/composer.json index 15077228..38ab90b0 100644 --- a/composer.json +++ b/composer.json @@ -104,7 +104,8 @@ "2733781 - Add Export to Word Support": "https://www.drupal.org/files/issues/2019-11-22/2733781-47.patch" }, "drupal/webform": { - "Unlock possibility of using Entity print module export to Word": "https://www.drupal.org/files/issues/2020-02-29/3096552-6.patch" + "Unlock possibility of using Entity print module export to Word": "https://www.drupal.org/files/issues/2020-02-29/3096552-6.patch", + "Webform computed element post save alter": "https://www.drupal.org/files/issues/2024-06-25/webform_computed_post_save_field_alter.patch" }, "drupal/user_default_page": { "Warning: in_array() expects parameter 2 to be array, null given in user_default_page_user_logout() (https://www.drupal.org/node/3246986)": "https://www.drupal.org/files/issues/2021-11-01/user_default_page-3246986-2.patch" diff --git a/modules/os2forms_encrypt/os2forms_encrypt.module b/modules/os2forms_encrypt/os2forms_encrypt.module index 516a7fe4..9e43af31 100644 --- a/modules/os2forms_encrypt/os2forms_encrypt.module +++ b/modules/os2forms_encrypt/os2forms_encrypt.module @@ -33,3 +33,15 @@ function os2forms_encrypt_entity_type_alter(array &$entity_types): void { $entity_types['webform_submission']->setStorageClass('Drupal\os2forms_encrypt\WebformOs2FormsEncryptSubmissionStorage'); } } + +/** + * Implements hook_webform_computed_post_save_field_alter(). + * + * Ensure encryption of computed element values. + */ +function os2forms_encrypt_webform_computed_post_save_field_alter(array &$fields): void { + /** @var \Drupal\os2forms_encrypt\Helper\Os2FormsEncryptor $os2formsEncryptor */ + $os2formsEncryptor = \Drupal::service('os2forms_encrypt.encryptor'); + + $fields['value'] = $os2formsEncryptor->encryptValue($fields['value'], $fields['name'], $fields['webform_id']); +} diff --git a/modules/os2forms_encrypt/os2forms_encrypt.services.yml b/modules/os2forms_encrypt/os2forms_encrypt.services.yml new file mode 100644 index 00000000..255a0ef5 --- /dev/null +++ b/modules/os2forms_encrypt/os2forms_encrypt.services.yml @@ -0,0 +1,4 @@ +services: + os2forms_encrypt.encryptor: + class: Drupal\os2forms_encrypt\Helper\Os2FormsEncryptor + arguments: ['@encryption', '@entity_type.manager'] diff --git a/modules/os2forms_encrypt/src/Helper/Os2FormsEncryptor.php b/modules/os2forms_encrypt/src/Helper/Os2FormsEncryptor.php new file mode 100644 index 00000000..365c0f08 --- /dev/null +++ b/modules/os2forms_encrypt/src/Helper/Os2FormsEncryptor.php @@ -0,0 +1,65 @@ +encryptionService = $encryptService; + $this->entityTypeManager = $entityTypeManager; + } + + /** + * Encrypts value if element is configured to be encrypted. + * + * @param string $value + * The value that should be encrypted. + * @param string $element + * The element. + * @param string $webformId + * The webform id. + * + * @return string + * The resulting value. + */ + public function encryptValue(string $value, string $element, string $webformId): string { + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $this->entityTypeManager->getStorage('webform')->load($webformId); + + $config = $webform->getThirdPartySetting('webform_encrypt', 'element'); + $encryption_profile = isset($config[$element]) ? EncryptionProfile::load($config[$element]['encrypt_profile']) : FALSE; + + if (!$encryption_profile) { + return $value; + } + + $encrypted_data = [ + 'data' => base64_encode($this->encryptionService->encrypt($value, $encryption_profile)), + 'encrypt_profile' => $encryption_profile->id(), + ]; + + return serialize($encrypted_data); + } + +} diff --git a/modules/os2forms_encrypt/src/WebformOs2FormsEncryptSubmissionStorage.php b/modules/os2forms_encrypt/src/WebformOs2FormsEncryptSubmissionStorage.php index ac02e348..4416f4ff 100644 --- a/modules/os2forms_encrypt/src/WebformOs2FormsEncryptSubmissionStorage.php +++ b/modules/os2forms_encrypt/src/WebformOs2FormsEncryptSubmissionStorage.php @@ -3,25 +3,37 @@ namespace Drupal\os2forms_encrypt; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Session\AccountInterface; +use Drupal\encrypt\EncryptionProfileInterface; +use Drupal\encrypt\Entity\EncryptionProfile; use Drupal\webform\WebformInterface; +use Drupal\webform\WebformSubmissionInterface; use Drupal\webform_encrypt\WebformEncryptSubmissionStorage; use Drupal\webform_revisions\Controller\WebformRevisionsController; /** - * This class extension WebformEncryptSubmissionStorage. + * This class extends WebformEncryptSubmissionStorage. * - * This is to enabled encryption and decryption for data and checks if webform - * revisions are enabled and runs the same code (copied here as multiple - * inherits is not a thing in PHP). + * This is to encrypt just the data sent to database and check if webform + * revisions are enabled. * - * So the getColumns is an moduleExists check and the rest is copied from - * webform revision storage class. + * The reason we need to tweak the encryption made by + * WebformEncryptSubmissionStorage is that the value of computed elements, + * e.g. Computed Twig, will be attempted computed AFTER encryptio. This can + * cause all sorts of exceptions and type errors on runtime. + * + * This mostly runs the same code (copied here as multiple + * inherits is not a thing in PHP), with minor tweaks. */ class WebformOs2FormsEncryptSubmissionStorage extends WebformEncryptSubmissionStorage { /** * {@inheritdoc} + * + * Overwritten to add if webform_revisions module exists. + * + * @see Drupal\webform\WebformSubmissionStorage::getColumns */ public function getColumns(WebformInterface $webform = NULL, EntityInterface $source_entity = NULL, AccountInterface $account = NULL, $include_elements = TRUE) { if (!\Drupal::moduleHandler()->moduleExists('webform_revisions')) { @@ -172,4 +184,295 @@ public function getColumns(WebformInterface $webform = NULL, EntityInterface $so } } + /** + * {@inheritdoc} + * + * Overwritten to only encrypt data send to database. + * + * @see Drupal\webform\WebformSubmissionStorage::saveData + */ + public function saveData(WebformSubmissionInterface $webform_submission, $delete_first = TRUE) { + // Get submission data rows. + $data_original = $webform_submission->getData(); + + $webform = $webform_submission->getWebform(); + + $encrypted_data = $this->encryptElements($data_original, $webform); + + $webform_submission->setData($encrypted_data); + + $webform_id = $webform_submission->getWebform()->id(); + $sid = $webform_submission->id(); + + $elements = $webform_submission->getWebform()->getElementsInitializedFlattenedAndHasValue(); + $computed_elements = $webform_submission->getWebform()->getElementsComputed(); + + $rows = []; + foreach ($encrypted_data as $name => $item) { + $element = $elements[$name] ?? ['#webform_multiple' => FALSE, '#webform_composite' => FALSE]; + + // Check if this is a computed element which is not + // stored in the database. + $is_computed_element = (isset($computed_elements[$name])) ? TRUE : FALSE; + if ($is_computed_element && empty($element['#store'])) { + continue; + } + + if ($element['#webform_composite']) { + if (is_array($item)) { + $composite_items = (empty($element['#webform_multiple'])) ? [$item] : $item; + foreach ($composite_items as $delta => $composite_item) { + foreach ($composite_item as $property => $value) { + $rows[] = [ + 'webform_id' => $webform_id, + 'sid' => $sid, + 'name' => $name, + 'property' => $property, + 'delta' => $delta, + 'value' => (string) $value, + ]; + } + } + } + } + elseif ($element['#webform_multiple']) { + if (is_array($item)) { + foreach ($item as $delta => $value) { + $rows[] = [ + 'webform_id' => $webform_id, + 'sid' => $sid, + 'name' => $name, + 'property' => '', + 'delta' => $delta, + 'value' => (string) $value, + ]; + } + } + } + else { + $rows[] = [ + 'webform_id' => $webform_id, + 'sid' => $sid, + 'name' => $name, + 'property' => '', + 'delta' => 0, + 'value' => (string) $item, + ]; + } + } + + if ($delete_first) { + // Delete existing submission data rows. + $this->database->delete('webform_submission_data') + ->condition('sid', $sid) + ->execute(); + } + + // Insert new submission data rows. + $query = $this->database + ->insert('webform_submission_data') + ->fields(['webform_id', 'sid', 'name', 'property', 'delta', 'value']); + foreach ($rows as $row) { + $query->values($row); + } + $query->execute(); + + $webform_submission->setData($data_original); + } + + /** + * {@inheritdoc} + * + * Overwritten to avoid webform_submission->getData() before decryption, + * as this may cause issues with computed elements, e.g. computed twig. + * + * @see Drupal\webform_encrypt\WebformEncryptSubmissionStorage::loadData + * @see Drupal\webform\WebformSubmissionStorage::loadData + */ + protected function loadData(array &$webform_submissions) { + + // Load webform submission data. + if ($sids = array_keys($webform_submissions)) { + $submissions_data = []; + + // Initialize all multiple value elements to make sure a value is defined. + $webform_default_data = []; + foreach ($webform_submissions as $sid => $webform_submission) { + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $webform_submissions[$sid]->getWebform(); + $webform_id = $webform->id(); + if (!isset($webform_default_data[$webform_id])) { + $webform_default_data[$webform_id] = []; + $elements = ($webform) ? $webform->getElementsInitializedFlattenedAndHasValue() : []; + foreach ($elements as $element_key => $element) { + if (!empty($element['#webform_multiple'])) { + $webform_default_data[$webform_id][$element_key] = []; + } + } + } + $submissions_data[$sid] = $webform_default_data[$webform_id]; + } + + /** @var \Drupal\Core\Database\StatementInterface $result */ + $result = $this->database->select('webform_submission_data', 'sd') + ->fields('sd', ['webform_id', 'sid', 'name', 'property', 'delta', 'value']) + ->condition('sd.sid', $sids, 'IN') + ->orderBy('sd.sid', 'ASC') + ->orderBy('sd.name', 'ASC') + ->orderBy('sd.property', 'ASC') + ->orderBy('sd.delta', 'ASC') + ->execute(); + while ($record = $result->fetchAssoc()) { + $sid = $record['sid']; + $name = $record['name']; + + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $webform_submissions[$sid]->getWebform(); + $elements = ($webform) ? $webform->getElementsInitializedFlattenedAndHasValue() : []; + $element = $elements[$name] ?? ['#webform_multiple' => FALSE, '#webform_composite' => FALSE]; + + if ($element['#webform_composite']) { + if ($element['#webform_multiple']) { + $submissions_data[$sid][$name][$record['delta']][$record['property']] = $record['value']; + } + else { + $submissions_data[$sid][$name][$record['property']] = $record['value']; + } + } + elseif ($element['#webform_multiple']) { + $submissions_data[$sid][$name][$record['delta']] = $record['value']; + } + else { + $submissions_data[$sid][$name] = $record['value']; + } + } + + foreach ($submissions_data as $sid => $submission_data) { + $this->decryptChildren($submission_data); + $webform_submissions[$sid]->setData($submission_data); + $webform_submissions[$sid]->setOriginalData($submission_data); + } + } + } + + /** + * {@inheritdoc} + * + * Overwritten to avoid encrypting null. + * + * @see Drupal\webform_encrypt\WebformEncryptSubmissionStorage::encryptElements + */ + public function encryptElements(array $data, WebformInterface $webform) { + // Load the configuration. + $config = $webform->getThirdPartySetting('webform_encrypt', 'element'); + + foreach ($data as $element_name => $value) { + $encryption_profile = isset($config[$element_name]) ? EncryptionProfile::load($config[$element_name]['encrypt_profile']) : FALSE; + // If the value is an array and we have a encryption profile. + if ($encryption_profile) { + if (is_array($value)) { + $this->encryptChildren($data[$element_name], $encryption_profile); + } + else { + if (is_null($value)) { + $data[$element_name] = $value; + } + else { + $encrypted_value = $this->encrypt($value, $encryption_profile); + // Save the encrypted data value. + $data[$element_name] = $encrypted_value; + } + } + } + } + return $data; + } + + /** + * {@inheritdoc} + * + * Overwritten to avoid encryption, see saveData. + * + * @see Drupal\webform_encrypt\WebformEncryptSubmissionStorage::doPreSave + * @see Drupal\webform\WebformSubmissionStorage::doPreSave + * @see Drupal\Core\Entity\ContentEntityStorageBase::doPreSave + * @see Drupal\Core\Entity\EntityStorageBase::doPreSave + */ + protected function doPreSave(EntityInterface $entity) { + /** @var \Drupal\webform\WebformSubmissionInterface $entity */ + // Sync the changes made in the fields array to the internal values array. + $entity->updateOriginalValues(); + + if ($entity->getEntityType()->isRevisionable() && !$entity->isNew() && empty($entity->getLoadedRevisionId())) { + // Update the loaded revision id for rare special cases when no loaded + // revision is given when updating an existing entity. This for example + // happens when calling save() in hook_entity_insert(). + $entity->updateLoadedRevisionId(); + } + + $id = $entity->id(); + + // Track the original ID. + if ($entity->getOriginalId() !== NULL) { + $id = $entity->getOriginalId(); + } + + // Track if this entity exists already. + $id_exists = $this->has($id, $entity); + + // A new entity should not already exist. + if ($id_exists && $entity->isNew()) { + throw new EntityStorageException("'{$this->entityTypeId}' entity with ID '$id' already exists."); + } + + // Load the original entity, if any. + if ($id_exists && !isset($entity->original)) { + $entity->original = $this->loadUnchanged($id); + } + + // Allow code to run before saving. + $entity->preSave($this); + $this->invokeHook('presave', $entity); + + if (!$entity->isNew()) { + // If the ID changed then original can't be loaded, throw an exception + // in that case. + if (empty($entity->original) || $entity->id() != $entity->original->id()) { + throw new EntityStorageException("Update existing '{$this->entityTypeId}' entity while changing the ID is not supported."); + } + // Do not allow changing the revision ID when resaving the current + // revision. + if (!$entity->isNewRevision() && $entity->getRevisionId() != $entity->getLoadedRevisionId()) { + throw new EntityStorageException("Update existing '{$this->entityTypeId}' entity revision while changing the revision ID is not supported."); + } + } + + $this->invokeWebformElements('preSave', $entity); + $this->invokeWebformHandlers('preSave', $entity); + + return $id; + } + + /** + * {@inheritdoc} + * + * Overwritten to avoid encrypting null. + * + * @see Drupal\webform_encrypt\WebformEncryptSubmissionStorage::encryptChildren + */ + public function encryptChildren(array &$data, EncryptionProfileInterface $encryption_profile) { + foreach ($data as $key => $value) { + if (is_array($value)) { + $this->encryptChildren($data[$key], $encryption_profile); + } + elseif (is_null($value)) { + $data[$key] = $value; + } + else { + $encrypted_value = $this->encrypt($value, $encryption_profile); + $data[$key] = $encrypted_value; + } + } + } + }