425 lines
12 KiB
JavaScript
Raw Normal View History

'use strict';
const crypto = require('crypto');
2022-08-24 09:10:30 +02:00
const { isNil } = require('lodash/fp');
const { omit, difference, isEmpty, map, isArray, uniq } = require('lodash/fp');
const { ValidationError, NotFoundError } = require('@strapi/utils').errors;
const constants = require('./constants');
2021-08-30 14:00:53 +02:00
/**
* @typedef {'read-only'|'full-access'|'custom'} TokenType
2021-08-30 14:00:53 +02:00
*/
2021-08-27 09:44:29 +02:00
/**
* @typedef ApiToken
*
2021-08-30 14:00:53 +02:00
* @property {number|string} id
2021-08-27 09:44:29 +02:00
* @property {string} name
2022-08-19 16:41:39 +02:00
* @property {string} description
2021-08-27 09:44:29 +02:00
* @property {string} accessKey
2022-08-23 13:45:27 +02:00
* @property {number} lastUsedAt
2022-08-19 16:33:58 +02:00
* @property {number} lifespan
* @property {number} expiresAt
2021-08-30 14:00:53 +02:00
* @property {TokenType} type
2022-08-19 16:41:39 +02:00
* @property {(number|ApiTokenPermission)[]} permissions
*/
/**
* @typedef ApiTokenPermission
*
* @property {number|string} id
* @property {string} action
2022-08-19 16:41:39 +02:00
* @property {ApiToken|number} token
2021-08-27 09:44:29 +02:00
*/
2021-09-02 10:47:06 +02:00
/** @constant {Array<string>} */
2022-08-19 16:33:58 +02:00
const SELECT_FIELDS = [
'id',
'name',
'description',
2022-08-23 13:45:27 +02:00
'lastUsedAt',
2022-08-19 16:33:58 +02:00
'type',
'lifespan',
'expiresAt',
'createdAt',
'updatedAt',
];
2021-09-02 10:47:06 +02:00
/** @constant {Array<string>} */
const POPULATE_FIELDS = ['permissions'];
2022-08-23 15:04:24 +02:00
// TODO: we need to ensure the permissions are actually valid registered permissions!
2022-08-19 16:53:51 +02:00
/**
* Assert that a token's permissions attribute is valid for its type
*
* @param {ApiToken} token
*/
const assertCustomTokenPermissionsValidity = (attributes) => {
// Ensure non-custom tokens doesn't have permissions
if (attributes.type !== constants.API_TOKEN_TYPE.CUSTOM && !isEmpty(attributes.permissions)) {
2022-08-11 10:48:40 +02:00
throw new ValidationError('Non-custom tokens should not reference permissions');
}
// Custom type tokens should always have permissions attached to them
2022-08-09 10:49:44 +02:00
if (attributes.type === constants.API_TOKEN_TYPE.CUSTOM && isEmpty(attributes.permissions)) {
2022-08-11 10:48:40 +02:00
throw new ValidationError('Missing permissions attribute for custom token');
}
};
/**
2022-08-19 16:53:51 +02:00
* Flatten a token's database permissions objects to an array of strings
*
* @param {ApiToken} token
*
* @returns {ApiToken}
*/
const flattenTokenPermissions = (token) => {
2022-08-19 16:42:55 +02:00
if (!token) return token;
return {
...token,
permissions: isArray(token.permissions) ? map('action', token.permissions) : token.permissions,
};
};
/**
2022-08-19 16:53:51 +02:00
* Get a token
*
2022-08-19 16:42:55 +02:00
* @param {Object} whereParams
* @param {string|number} whereParams.id
* @param {string} whereParams.name
* @param {number} whereParams.lastUsedAt
* @param {string} whereParams.description
* @param {string} whereParams.accessKey
*
* @returns {Promise<Omit<ApiToken, 'accessKey'> | null>}
*/
const getBy = async (whereParams = {}) => {
if (Object.keys(whereParams).length === 0) {
return null;
}
const token = await strapi
.query('admin::api-token')
.findOne({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: whereParams });
if (!token) return token;
return flattenTokenPermissions(token);
2022-08-19 16:42:55 +02:00
};
/**
2022-08-19 16:53:51 +02:00
* Check if token exists
*
* @param {Object} whereParams
2022-08-19 16:41:39 +02:00
* @param {string|number} whereParams.id
* @param {string} whereParams.name
* @param {number} whereParams.lastUsedAt
* @param {string} whereParams.description
* @param {string} whereParams.accessKey
*
* @returns {Promise<boolean>}
*/
const exists = async (whereParams = {}) => {
const apiToken = await getBy(whereParams);
return !!apiToken;
};
/**
2022-08-19 16:53:51 +02:00
* Return a secure sha512 hash of an accessKey
*
* @param {string} accessKey
*
* @returns {string}
*/
2022-08-08 23:33:39 +02:00
const hash = (accessKey) => {
return crypto
.createHmac('sha512', strapi.config.get('admin.apiToken.salt'))
.update(accessKey)
.digest('hex');
};
2022-08-19 16:33:58 +02:00
/**
* @param {number} lifespan
*
2022-08-24 16:26:40 +02:00
* @returns { { lifespan: null | number, expiresAt: null | number } }
2022-08-19 16:33:58 +02:00
*/
const getExpirationFields = (lifespan) => {
2022-08-23 10:51:53 +02:00
// it must be nil or a finite number >= 0
2022-08-24 09:10:30 +02:00
const isValidNumber = Number.isFinite(lifespan) && lifespan > 0;
2022-08-23 10:51:53 +02:00
if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null');
2022-08-19 16:33:58 +02:00
}
return {
lifespan: lifespan || null,
expiresAt: lifespan ? Date.now() + lifespan : null,
};
};
/**
2022-08-19 16:53:51 +02:00
* Create a token and its permissions
*
* @param {Object} attributes
2021-08-30 14:00:53 +02:00
* @param {TokenType} attributes.type
* @param {string} attributes.name
2022-08-19 16:33:58 +02:00
* @param {number} attributes.lifespan
2022-08-19 16:41:39 +02:00
* @param {string[]} attributes.permissions
* @param {string} attributes.description
*
2021-08-27 09:44:29 +02:00
* @returns {Promise<ApiToken>}
*/
2022-08-08 23:33:39 +02:00
const create = async (attributes) => {
const accessKey = crypto.randomBytes(128).toString('hex');
assertCustomTokenPermissionsValidity(attributes);
// Create the token
const apiToken = await strapi.query('admin::api-token').create({
2021-09-02 10:47:06 +02:00
select: SELECT_FIELDS,
2022-08-05 12:31:16 +02:00
populate: POPULATE_FIELDS,
data: {
...omit('permissions', attributes),
accessKey: hash(accessKey),
2022-08-19 16:33:58 +02:00
...getExpirationFields(attributes.lifespan),
},
});
const result = { ...apiToken, accessKey };
2022-08-10 17:35:15 +02:00
// If this is a custom type token, create and the related permissions
2022-08-08 17:06:38 +02:00
if (attributes.type === constants.API_TOKEN_TYPE.CUSTOM) {
2022-08-11 10:48:40 +02:00
// TODO: createMany doesn't seem to create relation properly, implement a better way rather than a ton of queries
2022-08-10 17:35:15 +02:00
// const permissionsCount = await strapi.query('admin::token-permission').createMany({
// populate: POPULATE_FIELDS,
// data: attributes.permissions.map(action => ({ action, token: apiToken })),
// });
2022-08-18 11:53:30 +02:00
await Promise.all(
uniq(attributes.permissions).map((action) =>
2022-08-10 17:35:15 +02:00
strapi.query('admin::token-permission').create({
data: { action, token: apiToken },
})
2022-08-18 11:53:30 +02:00
)
);
2022-08-10 17:35:15 +02:00
const currentPermissions = await strapi.entityService.load(
'admin::api-token',
apiToken,
'permissions'
);
2022-08-10 17:35:15 +02:00
if (currentPermissions) {
Object.assign(result, { permissions: map('action', currentPermissions) });
}
}
return result;
};
2022-08-18 13:31:02 +02:00
/**
* @param {string|number} id
*
* @returns {Promise<ApiToken>}
*/
const regenerate = async (id) => {
const accessKey = crypto.randomBytes(128).toString('hex');
2022-08-18 14:03:59 +02:00
const apiToken = await strapi.query('admin::api-token').update({
2022-08-18 13:31:02 +02:00
select: ['id', 'accessKey'],
2022-08-18 14:03:59 +02:00
where: { id },
2022-08-18 13:31:02 +02:00
data: {
accessKey: hash(accessKey),
},
});
2022-08-22 12:33:00 +02:00
if (!apiToken) {
throw new NotFoundError('The provided token id does not exist');
}
2022-08-18 13:31:02 +02:00
return {
...apiToken,
accessKey,
};
};
/**
* @returns {void}
*/
const checkSaltIsDefined = () => {
if (!strapi.config.get('admin.apiToken.salt')) {
// TODO V5: stop reading API_TOKEN_SALT
if (process.env.API_TOKEN_SALT) {
2022-03-04 15:48:49 +01:00
process.emitWarning(`[deprecated] In future versions, Strapi will stop reading directly from the environment variable API_TOKEN_SALT. Please set apiToken.salt in config/admin.js instead.
For security reasons, keep storing the secret in an environment variable and use env() to read it in config/admin.js (ex: \`apiToken: { salt: env('API_TOKEN_SALT') }\`). See https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/configurations/optional/environment.html#configuration-using-environment-variables.`);
strapi.config.set('admin.apiToken.salt', process.env.API_TOKEN_SALT);
} else {
throw new Error(
2022-03-18 17:55:22 +01:00
`Missing apiToken.salt. Please set apiToken.salt in config/admin.js (ex: you can generate one using Node with \`crypto.randomBytes(16).toString('base64')\`).
For security reasons, prefer storing the secret in an environment variable and read it in config/admin.js. See https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/configurations/optional/environment.html#configuration-using-environment-variables.`
);
}
}
};
/**
2022-08-19 16:53:51 +02:00
* Return a list of all tokens and their permissions
*
2021-09-02 10:47:06 +02:00
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const list = async () => {
const tokens = await strapi.query('admin::api-token').findMany({
2021-09-02 10:47:06 +02:00
select: SELECT_FIELDS,
populate: POPULATE_FIELDS,
2021-08-27 08:39:08 +02:00
orderBy: { name: 'ASC' },
});
if (!tokens) return tokens;
return tokens.map((token) => flattenTokenPermissions(token));
};
2021-08-31 15:31:54 +02:00
/**
2022-08-19 16:53:51 +02:00
* Revoke (delete) a token
*
2021-08-31 15:31:54 +02:00
* @param {string|number} id
*
2021-09-02 10:47:06 +02:00
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
2021-08-31 15:31:54 +02:00
*/
2022-08-08 23:33:39 +02:00
const revoke = async (id) => {
return strapi
.query('admin::api-token')
.delete({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: { id } });
2021-08-31 15:31:54 +02:00
};
/**
2022-08-19 16:53:51 +02:00
* Retrieve a token by id
*
* @param {string|number} id
*
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
2022-08-08 23:33:39 +02:00
const getById = async (id) => {
return getBy({ id });
};
/**
2022-08-19 16:53:51 +02:00
* Retrieve a token by name
*
* @param {string} name
*
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
2022-08-08 23:33:39 +02:00
const getByName = async (name) => {
return getBy({ name });
};
/**
2022-08-19 16:53:51 +02:00
* Update a token and its permissions
*
* @param {string|number} id
* @param {Object} attributes
* @param {TokenType} attributes.type
* @param {string} attributes.name
2022-08-19 16:36:28 +02:00
* @param {number} attributes.lastUsedAt
2022-08-19 16:41:39 +02:00
* @param {string[]} attributes.permissions
* @param {string} attributes.description
*
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const update = async (id, attributes) => {
// retrieve token without permissions
2022-08-11 10:48:40 +02:00
const originalToken = await strapi.query('admin::api-token').findOne({ where: { id } });
2022-08-05 12:31:16 +02:00
2022-08-11 10:48:40 +02:00
if (!originalToken) {
throw new NotFoundError('Token not found');
2022-08-05 12:31:16 +02:00
}
2022-08-18 10:22:09 +02:00
const changingTypeToCustom =
attributes.type === constants.API_TOKEN_TYPE.CUSTOM &&
originalToken.type !== constants.API_TOKEN_TYPE.CUSTOM;
2022-08-18 10:22:09 +02:00
// if we're updating the permissions on any token type, or changing from non-custom to custom, ensure they're still valid
// if neither type nor permissions are changing, we don't need to validate again or else we can't allow partial update
if (attributes.permissions || changingTypeToCustom) {
assertCustomTokenPermissionsValidity({
...originalToken,
...attributes,
type: attributes.type || originalToken.type,
});
}
2022-08-11 10:48:40 +02:00
const updatedToken = await strapi.query('admin::api-token').update({
select: SELECT_FIELDS,
populate: POPULATE_FIELDS,
where: { id },
data: omit('permissions', attributes),
});
2022-08-05 12:31:16 +02:00
2022-08-18 10:22:09 +02:00
// custom tokens need to have their permissions updated as well
if (updatedToken.type === constants.API_TOKEN_TYPE.CUSTOM && attributes.permissions) {
const currentPermissionsResult = await strapi.entityService.load(
'admin::api-token',
updatedToken,
'permissions'
2022-08-10 17:35:15 +02:00
);
const currentPermissions = map('action', currentPermissionsResult || []);
const newPermissions = uniq(attributes.permissions);
const actionsToDelete = difference(currentPermissions, newPermissions);
const actionsToAdd = difference(newPermissions, currentPermissions);
2022-08-10 17:35:15 +02:00
2022-08-11 18:04:59 +02:00
// TODO: improve efficiency here
// method using a loop -- works but very inefficient
2022-08-18 11:53:30 +02:00
await Promise.all(
actionsToDelete.map((action) =>
2022-08-11 13:00:21 +02:00
strapi.query('admin::token-permission').delete({
where: { action, token: id },
})
2022-08-18 11:53:30 +02:00
)
);
2022-08-10 17:35:15 +02:00
2022-08-11 18:04:59 +02:00
// TODO: improve efficiency here
// using a loop -- works but very inefficient
2022-08-18 11:53:30 +02:00
await Promise.all(
actionsToAdd.map((action) =>
2022-08-10 17:35:15 +02:00
strapi.query('admin::token-permission').create({
data: { action, token: id },
})
2022-08-18 11:53:30 +02:00
)
);
2022-08-10 17:35:15 +02:00
}
2022-08-11 10:48:40 +02:00
// if type is not custom, make sure any old permissions get removed
2022-08-18 10:22:09 +02:00
else if (updatedToken.type !== constants.API_TOKEN_TYPE.CUSTOM) {
2022-08-11 10:48:40 +02:00
await strapi.query('admin::token-permission').delete({
where: { token: id },
});
}
2022-08-05 12:31:16 +02:00
2022-08-11 10:48:40 +02:00
// retrieve permissions
const permissionsFromDb = await strapi.entityService.load(
'admin::api-token',
updatedToken,
'permissions'
);
return {
...updatedToken,
permissions: permissionsFromDb ? permissionsFromDb.map((p) => p.action) : undefined,
2022-08-11 10:48:40 +02:00
};
};
module.exports = {
create,
2022-08-18 13:31:02 +02:00
regenerate,
exists,
checkSaltIsDefined,
hash,
list,
2021-08-31 15:31:54 +02:00
revoke,
2021-09-06 15:14:45 +02:00
getById,
update,
getByName,
getBy,
};