diff --git a/packages/core/admin/server/bootstrap.js b/packages/core/admin/server/bootstrap.js index c957782d03..0be13606ab 100644 --- a/packages/core/admin/server/bootstrap.js +++ b/packages/core/admin/server/bootstrap.js @@ -44,6 +44,7 @@ module.exports = async () => { const permissionService = getService('permission'); const userService = getService('user'); const roleService = getService('role'); + const apiTokenService = getService('api-token'); await roleService.createRolesIfNoneExist(); await roleService.resetSuperAdminPermissions(); @@ -55,4 +56,6 @@ module.exports = async () => { await userService.displayWarningIfUsersDontHaveRole(); await syncAuthSettings(); + + apiTokenService.createSaltIfNotDefined(); }; diff --git a/packages/core/admin/server/config/api-token.js b/packages/core/admin/server/config/api-token.js new file mode 100644 index 0000000000..9e9659ca77 --- /dev/null +++ b/packages/core/admin/server/config/api-token.js @@ -0,0 +1,7 @@ +'use strict'; + +const { env } = require('../../../utils/lib'); + +module.exports = { + salt: env('API_TOKEN_SALT'), +}; diff --git a/packages/core/admin/server/config/index.js b/packages/core/admin/server/config/index.js index 101859af66..8210021bbc 100644 --- a/packages/core/admin/server/config/index.js +++ b/packages/core/admin/server/config/index.js @@ -6,4 +6,5 @@ module.exports = { forgotPassword: { emailTemplate: forgotPasswordTemplate, }, + 'api-token': require('./api-token'), }; diff --git a/packages/core/admin/server/content-types/api-token.js b/packages/core/admin/server/content-types/api-token.js index c219ee28c9..8a168b622a 100644 --- a/packages/core/admin/server/content-types/api-token.js +++ b/packages/core/admin/server/content-types/api-token.js @@ -1,8 +1,5 @@ 'use strict'; -/** - * Lifecycle callbacks for the `ApiToken` model. - */ module.exports = { collectionName: 'strapi_api_tokens', info: { diff --git a/packages/core/admin/server/content-types/index.js b/packages/core/admin/server/content-types/index.js index 59cf8dabdc..b665381c49 100644 --- a/packages/core/admin/server/content-types/index.js +++ b/packages/core/admin/server/content-types/index.js @@ -4,5 +4,5 @@ module.exports = { permission: { schema: require('./Permission') }, user: { schema: require('./User') }, role: { schema: require('./Role') }, - ['api-token']: { schema: require('./api-token') }, + 'api-token': { schema: require('./api-token') }, }; diff --git a/packages/core/admin/server/controllers/__tests__/api-token.test.js b/packages/core/admin/server/controllers/__tests__/api-token.test.js index 71bda1f50f..f83bd010ec 100644 --- a/packages/core/admin/server/controllers/__tests__/api-token.test.js +++ b/packages/core/admin/server/controllers/__tests__/api-token.test.js @@ -11,7 +11,7 @@ describe('API Token Controller', () => { type: 'read-only', }; - test('Fails if API Token already exist', async () => { + test('Fails if API Token already exists', async () => { const exists = jest.fn(() => true); const badRequest = jest.fn(); const ctx = createContext({ body }, { badRequest }); @@ -19,7 +19,7 @@ describe('API Token Controller', () => { global.strapi = { admin: { services: { - ['api-token']: { + 'api-token': { exists, }, }, @@ -42,7 +42,7 @@ describe('API Token Controller', () => { global.strapi = { admin: { services: { - ['api-token']: { + 'api-token': { exists, create, }, diff --git a/packages/core/admin/server/controllers/api-token.js b/packages/core/admin/server/controllers/api-token.js index d99576d137..96a76e0654 100644 --- a/packages/core/admin/server/controllers/api-token.js +++ b/packages/core/admin/server/controllers/api-token.js @@ -14,12 +14,12 @@ module.exports = { return ctx.badRequest('ValidationError', err); } - if (await apiTokenService.exists({ name: attributes.name })) { + const alreadyExists = await apiTokenService.exists({ name: attributes.name }); + if (alreadyExists) { return ctx.badRequest('Name already taken'); } const apiToken = await apiTokenService.create(attributes); - ctx.created({ data: apiToken }); }, }; diff --git a/packages/core/admin/server/services/__tests__/api-token.test.js b/packages/core/admin/server/services/__tests__/api-token.test.js index 0bfdd429c3..ad51c5209e 100644 --- a/packages/core/admin/server/services/__tests__/api-token.test.js +++ b/packages/core/admin/server/services/__tests__/api-token.test.js @@ -23,6 +23,9 @@ describe('API Token', () => { query() { return { create }; }, + config: { + get: jest.fn(() => ({})), + }, }; const attributes = { @@ -34,10 +37,10 @@ describe('API Token', () => { const res = await apiTokenService.create(attributes); expect(create).toHaveBeenCalledWith({ - select: ['id', 'name', 'description', 'type', 'accessKey'], + select: ['id', 'name', 'description', 'type'], data: { ...attributes, - accessKey: mockedApiToken.hexedString, + accessKey: apiTokenService.hash(mockedApiToken.hexedString), }, }); expect(res).toEqual({ @@ -46,4 +49,46 @@ describe('API Token', () => { }); }); }); + + describe('createSaltIfNotDefined', () => { + test('It does nothing if the salt is alread defined', () => { + const mockedAppendFile = jest.fn(); + const mockedConfigSet = jest.fn(); + + global.strapi = { + config: { + get: jest.fn(() => ({ + server: { + admin: { 'api-token': { salt: 'api-token_tests-salt' } }, + }, + })), + set: mockedConfigSet, + }, + fs: { appendFile: mockedAppendFile }, + }; + + apiTokenService.createSaltIfNotDefined(); + + expect(mockedAppendFile).not.toHaveBeenCalled(); + expect(mockedConfigSet).not.toHaveBeenCalled(); + }); + + test('It creates a new salt, appendit to the .env file and sets it in the configuration', () => { + const mockedAppendFile = jest.fn(); + const mockedConfigSet = jest.fn(); + + global.strapi = { + config: { + get: jest.fn(() => null), + set: mockedConfigSet, + }, + fs: { appendFile: mockedAppendFile }, + }; + + apiTokenService.createSaltIfNotDefined(); + + expect(mockedAppendFile).toHaveBeenCalled(); + expect(mockedConfigSet).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/admin/server/services/api-token.js b/packages/core/admin/server/services/api-token.js index e0965973e3..abe6e95eae 100644 --- a/packages/core/admin/server/services/api-token.js +++ b/packages/core/admin/server/services/api-token.js @@ -13,14 +13,28 @@ const crypto = require('crypto'); */ /** - * @param {Object} attributes - * @param {string} attributes.name - * @param {string} [attributes.description] + * @param {Object} whereParams + * @param {string} whereParams.name + * @param {string} [whereParams.description] * * @returns {Promise} */ -const exists = async (attributes = {}) => { - return (await strapi.query('admin::api-token').count({ where: attributes })) > 0; +const exists = async (whereParams = {}) => { + const apiToken = await strapi.query('admin::api-token').findOne({ where: whereParams }); + + return !!apiToken; +}; + +/** + * @param {string} accessKey + * + * @returns {string} + */ +const hash = accessKey => { + return crypto + .createHash('sha512') + .update(`${strapi.config.get('server.admin.api-token.salt')}${accessKey}`) + .digest('hex'); }; /** @@ -34,16 +48,39 @@ const exists = async (attributes = {}) => { const create = async attributes => { const accessKey = crypto.randomBytes(128).toString('hex'); - return strapi.query('admin::api-token').create({ - select: ['id', 'name', 'description', 'type', 'accessKey'], + const apiToken = await strapi.query('admin::api-token').create({ + select: ['id', 'name', 'description', 'type'], data: { ...attributes, - accessKey, + accessKey: hash(accessKey), }, }); + + return { + ...apiToken, + accessKey, + }; +}; + +/** + * @returns {void} + */ +const createSaltIfNotDefined = () => { + if (strapi.config.get('server.admin.api-token.salt')) { + return; + } + + const salt = crypto.randomBytes(16).toString('hex'); + + if (!process.env.API_TOKEN_SALT) { + strapi.fs.appendFile('.env', `API_TOKEN_SALT=${salt}\n`); + strapi.config.set('server.admin.api-token.salt', salt); + } }; module.exports = { create, exists, + createSaltIfNotDefined, + hash, }; diff --git a/packages/core/admin/server/services/index.js b/packages/core/admin/server/services/index.js index 89c4bfd247..ca39f17210 100644 --- a/packages/core/admin/server/services/index.js +++ b/packages/core/admin/server/services/index.js @@ -12,5 +12,5 @@ module.exports = { condition: require('./condition'), auth: require('./auth'), action: require('./action'), - ['api-token']: require('./api-token'), + 'api-token': require('./api-token'), }; diff --git a/packages/core/admin/server/tests/admin-api-token.test.e2e.js b/packages/core/admin/server/tests/admin-api-token.test.e2e.js index 5ab3a3292d..b438e96c3d 100644 --- a/packages/core/admin/server/tests/admin-api-token.test.e2e.js +++ b/packages/core/admin/server/tests/admin-api-token.test.e2e.js @@ -8,8 +8,10 @@ const { createAuthRequest } = require('../../../../../test/helpers/request'); * * N° Description * ------------------------------------------- - * 1. Creates an api token (wrong body) - * 2. Creates an api token (successfully) + * 1. Fails to creates an api token (missing parameters from the body) + * 2. Fails to creates an api token (invalid `type` in the body) + * 3. Creates an api token (successfully) + * 4. Creates an api token without a description (successfully) */ describe('Admin API Token CRUD (e2e)', () => { @@ -27,7 +29,7 @@ describe('Admin API Token CRUD (e2e)', () => { await strapi.destroy(); }); - test('1. Creates an api token (wrong body)', async () => { + test('1. Fails to creates an api token (missing parameters from the body)', async () => { const body = { name: 'api-token_tests-name', description: 'api-token_tests-description', @@ -50,7 +52,31 @@ describe('Admin API Token CRUD (e2e)', () => { }); }); - test('2. Creates an api token (successfully)', async () => { + test('2. Fails to creates an api token (invalid `type` in the body)', async () => { + const body = { + name: 'api-token_tests-name', + description: 'api-token_tests-description', + type: 'invalid-type', + }; + + const res = await rq({ + url: '/admin/api-tokens', + method: 'POST', + body, + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toMatchObject({ + statusCode: 400, + error: 'Bad Request', + message: 'ValidationError', + data: { + type: ['type must be one of the following values: read-only, full-access'], + }, + }); + }); + + test('3. Creates an api token (successfully)', async () => { const body = { name: 'api-token_tests-name', description: 'api-token_tests-description', @@ -64,15 +90,16 @@ describe('Admin API Token CRUD (e2e)', () => { }); expect(res.statusCode).toBe(201); - expect(res.body.data).not.toBeNull(); - expect(res.body.data.id).toBe(1); - expect(res.body.data.accessKey).toBeDefined(); - expect(res.body.data.name).toBe(body.name); - expect(res.body.data.description).toBe(body.description); - expect(res.body.data.type).toBe(body.type); + expect(res.body.data).toMatchObject({ + accessKey: expect.any(String), + name: body.name, + description: body.description, + type: body.type, + id: expect.any(Number), + }); }); - test('3. Creates an api token without a description (successfully)', async () => { + test('4. Creates an api token without a description (successfully)', async () => { const body = { name: 'api-token_tests-name-without-description', type: 'read-only', @@ -85,11 +112,12 @@ describe('Admin API Token CRUD (e2e)', () => { }); expect(res.statusCode).toBe(201); - expect(res.body.data).not.toBeNull(); - expect(res.body.data.id).toBe(2); - expect(res.body.data.accessKey).toBeDefined(); - expect(res.body.data.name).toBe(body.name); - expect(res.body.data.description).toBe(''); - expect(res.body.data.type).toBe(body.type); + expect(res.body.data).toMatchObject({ + accessKey: expect.any(String), + name: body.name, + description: '', + type: body.type, + id: expect.any(Number), + }); }); }); diff --git a/packages/core/admin/server/utils/index.d.ts b/packages/core/admin/server/utils/index.d.ts index b3e790f5f6..7feebd6cb1 100644 --- a/packages/core/admin/server/utils/index.d.ts +++ b/packages/core/admin/server/utils/index.d.ts @@ -11,11 +11,11 @@ type S = { role: typeof role; user: typeof user; permission: typeof permission; - ['content-type']: typeof contentType; + 'content-type': typeof contentType; token: typeof token; auth: typeof auth; metrics: typeof metrics; - ['api-token']: typeof apiToken; + 'api-token': typeof apiToken; }; export function getService(name: T): ReturnType;