DEV Community

Cover image for Strapi Custom Fields (encryptable field)
Edwin Luijten
Edwin Luijten

Posted on

Strapi Custom Fields (encryptable field)

We are going to create a custom field in order to encrypt the value when saved, and decrypt when fetched.

Preface

DO NOT USE ENCRYPTION TO STORE USER PASSWORDS, FOR THIS YOU USE HASHING.

ONLY STORE PII DATA WHEN NEEDED AND ONLY THE BARE MINIMUM. CONSULT THE RULES AROUND PII DATA THAT APPLY IN THE REGIONS YOU OPERATE IN.

Requirements:

  • A Strapi installation (version 4.4+)
  • Understanding of Typescript (Should be fairly easy for plain javascript as well, we are not using types heavily).

Generating the plugin boilerplate
These steps can also be found in the official documentation.

  1. Navigate to the root of a Strapi project.
  2. Run yarn strapi generate or npm run strapi generate in a terminal window to start the interactive CLI.
  3. Choose "plugin" from the list, press Enter, and give the plugin a name in kebab-case (e.g. encryptable-field)
  4. Choose either JavaScript or TypeScript for the plugin language.
  5. Enable the plugin by adding it to the plugins configurations file:

    // config/plugins.ts
    export default {
      'encryptable-field': {
        enabled: true,
        resolve: './src/plugins/encryptable-field'
      },
    }
    
  6. (TypeScript-specific) Run npm install or yarn in the newly-created plugin directory.

  7. (TypeScript-specific) Run yarn build or npm run build in the plugin directory. This step transpiles the TypeScript files and outputs the JavaScript files to a dist directory that is unique to the plugin.

  8. Run yarn build or npm run build at the project root.

  9. Run yarn develop or npm run develop at the project root.

Note: if your changes are not visible after a code change, run step 7, 8 and 9 again.

Now, let's build out our plugin.

Server

Plugin registration

// plugins/encryptable-field/server/register.ts
import { Strapi } from '@strapi/strapi';
import pluginId from '../admin/src/pluginId';

export default ({ strapi }: { strapi: Strapi }) => {
  strapi.customFields.register({
    name: pluginId,
    plugin: pluginId,
    type: 'text',
  });
};

Enter fullscreen mode Exit fullscreen mode

Encryption service

The encryption service holds the logic for encryption and decryption as well as a function to retrieve relevant fields to encrypt/decrypt.

// plugins/encryptable-field/server/services/service.ts
import { Strapi } from '@strapi/strapi';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const AES_METHOD = 'aes-256-cbc';
const IV_LENGTH = 16;
const KEY = process.env.ENCRYPTION_KEY || ''; // hex key 32 bytes
const PLUGIN_DSN = 'plugin::encryptable-field.encryptable-field';

export default ({ strapi }: { strapi: Strapi }) => ({

  // Get fields that are of our custom field type.
  getFields(fields: object): string[] {
    const attributes = [];
    for (const attribute in fields) {
      if (fields[attribute]['customField'] ===  PLUGIN_DSN) {
        attributes.push(attribute);
      }
    }

    return attributes
  },

  encrypt(value: string): string {
    const iv = randomBytes(IV_LENGTH);
    const cipher = createCipheriv(AES_METHOD, Buffer.from(KEY), iv);

    let encrypted = cipher.update(value);
    encrypted = Buffer.concat([encrypted, cipher.final()]);

    return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
  },

  decrypt(value: string): string {
    const textParts = value.split(':');
    const firstPart = textParts.shift();

    if (!firstPart) throw Error('Malformed payload');

    const iv = Buffer.from(firstPart, 'hex');
    const encryptedText = Buffer.from(textParts.join(':'), 'hex');
    const decipher = createDecipheriv(AES_METHOD, Buffer.from(KEY), iv);

    let decrypted = decipher.update(encryptedText);
    decrypted = Buffer.concat([decrypted, decipher.final()]);

    return decrypted.toString();
  }
});
Enter fullscreen mode Exit fullscreen mode

Admin

Custom Field with options
We will create a new input field with the following options:

  • field hint
  • required
  • regex validation

Configuration

// plugins/encryptable-field/admin/src/index.tsx
import React from 'react';
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginId from './pluginId';
import getTrad from './utils/getTrad';

export default {
  register(app) {
    app.customFields.register({
      name: pluginId,
      pluginId: pluginId,
      type: 'string',
      intlLabel: {
        id: getTrad(`${pluginId}.label`),
        defaultMessage: 'Encryptable'
      },
      intlDescription: {
        id: getTrad(`${pluginId}.description`),
        defaultMessage: 'Adds Encryptable fields',
      },
      components: {
        Input: async () => import('./components/EncryptableFieldInput'),
      },
      options: {
        base: [
          {
            intlLabel: {
              id: getTrad(`${pluginId}.options.advanced.regex.hint`),
              defaultMessage: 'Input hint',
            },
            name: 'options.hint',
            type: 'text',
            defaultValue: null,
            description: {
              id: getTrad(`${pluginId}.options.advanced.regex.hint.description`),
              defaultMessage: 'The text of the regular expression hint',
            },
          },
        ],
        advanced: [
          {
            intlLabel: {
              id: getTrad(`${pluginId}.options.advanced.regex`),
              defaultMessage: 'RegExp pattern',
            },
            name: 'regex',
            type: 'text',
            defaultValue: null,
            description: {
              id: getTrad(`${pluginId}.options.advanced.regex.description`),
              defaultMessage: 'The text of the regular expression',
            },
          },
          {
            sectionTitle: {
              id: 'global.settings',
              defaultMessage: 'Settings',
            },
            items: [
              {
                name: 'required',
                type: 'checkbox',
                intlLabel: {
                  id: getTrad(`${pluginId}.options.advanced.requiredField`),
                  defaultMessage: 'Required field',
                },
                description: {
                  id: getTrad(`${pluginId}.options.advanced.requiredField.description`),
                  defaultMessage: 'You won\'t be able to create an entry if this field is empty',
                },
              },
            ],
          },
        ]
      }
    })
  },

  bootstrap(app) {
  },

  async registerTrads(app) {
    const {locales} = app;

    const importedTrads = await Promise.all(
      locales.map(locale => {
        return import(`./translations/${locale}.json`)
          .then(({default: data}) => {
            return {
              data: prefixPluginTranslations(data, pluginId),
              locale,
            };
          })
          .catch(() => {
            return {
              data: {},
              locale,
            };
          });
      })
    );

    return Promise.resolve(importedTrads);
  },
};
Enter fullscreen mode Exit fullscreen mode

The input field

// plugins/encryptable-field/admin/src/EncryptableFieldInput/index.tsx
import {
  Box,
  Field,
  Stack,
  FieldLabel,
  Flex,
  FieldInput,
  FieldHint,
  FieldError,
} from '@strapi/design-system';
import PropTypes from 'prop-types';
import React, { useRef } from 'react';
import { useIntl } from 'react-intl';
import getTrad from '../../utils/getTrad';

const encryptableFieldInput = ({
  description,
  placeholder,
  disabled,
  error,
  intlLabel,
  labelAction,
  name,
  onChange,
  required,
  value,
  attribute,
}): JSX.Element => {
  const { formatMessage } = useIntl();
  const reference = useRef(null);

  return (
    <Box>
      <Field
        id={name}
        name={name}
        hint={attribute.options?.hint ?? description ?? ''}
        error={error}
        required={required}
      >
        <Stack spacing={1}>
          <Flex>
            <FieldLabel action={labelAction} required={required}>
              {formatMessage(intlLabel)}
            </FieldLabel>
          </Flex>
          <FieldInput
            ref={reference}
            id="encryptable-field-value"
            disabled={disabled}
            required={required}
            name={name}
            aria-label={formatMessage({
              id: getTrad('input.aria-label'),
              defaultMessage: 'Encryptable input',
            })}
            value={value}
            placeholder={placeholder}
            onChange={onChange}
            hint={description}
          />
          <FieldHint />
          <FieldError />
        </Stack>
      </Field>
    </Box>
  );
};

encryptableFieldInput.defaultProps = {
  description: null,
  disabled: false,
  error: null,
  labelAction: null,
  required: false,
  value: '',
};

encryptableFieldInput.propTypes = {
  intlLabel: PropTypes.object.isRequired,
  onChange: PropTypes.func.isRequired,
  attribute: PropTypes.object.isRequired,
  name: PropTypes.string.isRequired,
  description: PropTypes.object,
  disabled: PropTypes.bool,
  error: PropTypes.string,
  labelAction: PropTypes.object,
  required: PropTypes.bool,
  value: PropTypes.string,
};

export default encryptableFieldInput;
Enter fullscreen mode Exit fullscreen mode

Hooking it up to the lifecycle events

In order to actually get it to work we need to hook it up to the database lifecycle events.

// plugins/encryptable-field/server/bootstrap.ts
import { Strapi } from '@strapi/strapi';
import { ENCRYPTABLE_FIELD } from './index';
import { Subscriber } from '@strapi/database/lib/lifecycles/subscribers';

export default ({ strapi }: { strapi: Strapi }) => {
  const encryptionService = strapi.plugin(ENCRYPTABLE_FIELD).service('service');

  strapi.db.lifecycles.subscribe((<Subscriber>{
    beforeCreate(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event.params.data[attr] = encryptionService.encrypt(event.params.data[attr])),
      );
    },

    beforeUpdate(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event.params.data[attr] = encryptionService.encrypt(event.params.data[attr])),
      );
    },

    afterFindOne(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event['result'][attr] = encryptionService.decrypt(event['result'][attr])),
      );
    },

    afterFindMany(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      event['result'].forEach((result, i) =>
        attributes.forEach(
          (attr) =>
            (event['result'][i][attr] = encryptionService.decrypt(event['result'][i][attr])),
        ),
      );
    },
  }) as Subscriber);
};
Enter fullscreen mode Exit fullscreen mode

Aaand~ that's it, you now have a custom field that allows you to store values encrypted and they will be decrypted when queried.

To verify if it works, comment out the afterFindOne and afterFindMany hooks. You should now see the encrypted values.

Custom Field

Source Code

Top comments (0)