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.
- Navigate to the root of a Strapi project.
- Run
yarn strapi generate
ornpm run strapi generate
in a terminal window to start the interactive CLI. - Choose "plugin" from the list, press Enter, and give the plugin a name in kebab-case (e.g. encryptable-field)
- Choose either JavaScript or TypeScript for the plugin language.
-
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' }, }
(TypeScript-specific) Run
npm install
oryarn
in the newly-created plugin directory.(TypeScript-specific) Run
yarn build
ornpm 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.Run
yarn build
ornpm run build
at the project root.Run
yarn develop
ornpm 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',
});
};
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();
}
});
Admin
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);
},
};
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;
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);
};
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.
Top comments (0)