store the hashed accessKey in the database

This commit is contained in:
Dieter Stinglhamber 2021-08-27 16:23:19 +02:00
parent 5305f2e757
commit e9b897b66b
12 changed files with 157 additions and 39 deletions

View File

@ -44,6 +44,7 @@ module.exports = async () => {
const permissionService = getService('permission'); const permissionService = getService('permission');
const userService = getService('user'); const userService = getService('user');
const roleService = getService('role'); const roleService = getService('role');
const apiTokenService = getService('api-token');
await roleService.createRolesIfNoneExist(); await roleService.createRolesIfNoneExist();
await roleService.resetSuperAdminPermissions(); await roleService.resetSuperAdminPermissions();
@ -55,4 +56,6 @@ module.exports = async () => {
await userService.displayWarningIfUsersDontHaveRole(); await userService.displayWarningIfUsersDontHaveRole();
await syncAuthSettings(); await syncAuthSettings();
apiTokenService.createSaltIfNotDefined();
}; };

View File

@ -0,0 +1,7 @@
'use strict';
const { env } = require('../../../utils/lib');
module.exports = {
salt: env('API_TOKEN_SALT'),
};

View File

@ -6,4 +6,5 @@ module.exports = {
forgotPassword: { forgotPassword: {
emailTemplate: forgotPasswordTemplate, emailTemplate: forgotPasswordTemplate,
}, },
'api-token': require('./api-token'),
}; };

View File

@ -1,8 +1,5 @@
'use strict'; 'use strict';
/**
* Lifecycle callbacks for the `ApiToken` model.
*/
module.exports = { module.exports = {
collectionName: 'strapi_api_tokens', collectionName: 'strapi_api_tokens',
info: { info: {

View File

@ -4,5 +4,5 @@ module.exports = {
permission: { schema: require('./Permission') }, permission: { schema: require('./Permission') },
user: { schema: require('./User') }, user: { schema: require('./User') },
role: { schema: require('./Role') }, role: { schema: require('./Role') },
['api-token']: { schema: require('./api-token') }, 'api-token': { schema: require('./api-token') },
}; };

View File

@ -11,7 +11,7 @@ describe('API Token Controller', () => {
type: 'read-only', 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 exists = jest.fn(() => true);
const badRequest = jest.fn(); const badRequest = jest.fn();
const ctx = createContext({ body }, { badRequest }); const ctx = createContext({ body }, { badRequest });
@ -19,7 +19,7 @@ describe('API Token Controller', () => {
global.strapi = { global.strapi = {
admin: { admin: {
services: { services: {
['api-token']: { 'api-token': {
exists, exists,
}, },
}, },
@ -42,7 +42,7 @@ describe('API Token Controller', () => {
global.strapi = { global.strapi = {
admin: { admin: {
services: { services: {
['api-token']: { 'api-token': {
exists, exists,
create, create,
}, },

View File

@ -14,12 +14,12 @@ module.exports = {
return ctx.badRequest('ValidationError', err); 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'); return ctx.badRequest('Name already taken');
} }
const apiToken = await apiTokenService.create(attributes); const apiToken = await apiTokenService.create(attributes);
ctx.created({ data: apiToken }); ctx.created({ data: apiToken });
}, },
}; };

View File

@ -23,6 +23,9 @@ describe('API Token', () => {
query() { query() {
return { create }; return { create };
}, },
config: {
get: jest.fn(() => ({})),
},
}; };
const attributes = { const attributes = {
@ -34,10 +37,10 @@ describe('API Token', () => {
const res = await apiTokenService.create(attributes); const res = await apiTokenService.create(attributes);
expect(create).toHaveBeenCalledWith({ expect(create).toHaveBeenCalledWith({
select: ['id', 'name', 'description', 'type', 'accessKey'], select: ['id', 'name', 'description', 'type'],
data: { data: {
...attributes, ...attributes,
accessKey: mockedApiToken.hexedString, accessKey: apiTokenService.hash(mockedApiToken.hexedString),
}, },
}); });
expect(res).toEqual({ 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();
});
});
}); });

View File

@ -13,14 +13,28 @@ const crypto = require('crypto');
*/ */
/** /**
* @param {Object} attributes * @param {Object} whereParams
* @param {string} attributes.name * @param {string} whereParams.name
* @param {string} [attributes.description] * @param {string} [whereParams.description]
* *
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
const exists = async (attributes = {}) => { const exists = async (whereParams = {}) => {
return (await strapi.query('admin::api-token').count({ where: attributes })) > 0; 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 create = async attributes => {
const accessKey = crypto.randomBytes(128).toString('hex'); const accessKey = crypto.randomBytes(128).toString('hex');
return strapi.query('admin::api-token').create({ const apiToken = await strapi.query('admin::api-token').create({
select: ['id', 'name', 'description', 'type', 'accessKey'], select: ['id', 'name', 'description', 'type'],
data: { data: {
...attributes, ...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 = { module.exports = {
create, create,
exists, exists,
createSaltIfNotDefined,
hash,
}; };

View File

@ -12,5 +12,5 @@ module.exports = {
condition: require('./condition'), condition: require('./condition'),
auth: require('./auth'), auth: require('./auth'),
action: require('./action'), action: require('./action'),
['api-token']: require('./api-token'), 'api-token': require('./api-token'),
}; };

View File

@ -8,8 +8,10 @@ const { createAuthRequest } = require('../../../../../test/helpers/request');
* *
* N° Description * N° Description
* ------------------------------------------- * -------------------------------------------
* 1. Creates an api token (wrong body) * 1. Fails to creates an api token (missing parameters from the body)
* 2. Creates an api token (successfully) * 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)', () => { describe('Admin API Token CRUD (e2e)', () => {
@ -27,7 +29,7 @@ describe('Admin API Token CRUD (e2e)', () => {
await strapi.destroy(); 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 = { const body = {
name: 'api-token_tests-name', name: 'api-token_tests-name',
description: 'api-token_tests-description', 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 = { const body = {
name: 'api-token_tests-name', name: 'api-token_tests-name',
description: 'api-token_tests-description', description: 'api-token_tests-description',
@ -64,15 +90,16 @@ describe('Admin API Token CRUD (e2e)', () => {
}); });
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
expect(res.body.data).not.toBeNull(); expect(res.body.data).toMatchObject({
expect(res.body.data.id).toBe(1); accessKey: expect.any(String),
expect(res.body.data.accessKey).toBeDefined(); name: body.name,
expect(res.body.data.name).toBe(body.name); description: body.description,
expect(res.body.data.description).toBe(body.description); type: body.type,
expect(res.body.data.type).toBe(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 = { const body = {
name: 'api-token_tests-name-without-description', name: 'api-token_tests-name-without-description',
type: 'read-only', type: 'read-only',
@ -85,11 +112,12 @@ describe('Admin API Token CRUD (e2e)', () => {
}); });
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
expect(res.body.data).not.toBeNull(); expect(res.body.data).toMatchObject({
expect(res.body.data.id).toBe(2); accessKey: expect.any(String),
expect(res.body.data.accessKey).toBeDefined(); name: body.name,
expect(res.body.data.name).toBe(body.name); description: '',
expect(res.body.data.description).toBe(''); type: body.type,
expect(res.body.data.type).toBe(body.type); id: expect.any(Number),
});
}); });
}); });

View File

@ -11,11 +11,11 @@ type S = {
role: typeof role; role: typeof role;
user: typeof user; user: typeof user;
permission: typeof permission; permission: typeof permission;
['content-type']: typeof contentType; 'content-type': typeof contentType;
token: typeof token; token: typeof token;
auth: typeof auth; auth: typeof auth;
metrics: typeof metrics; metrics: typeof metrics;
['api-token']: typeof apiToken; 'api-token': typeof apiToken;
}; };
export function getService<T extends keyof S>(name: T): ReturnType<S[T]>; export function getService<T extends keyof S>(name: T): ReturnType<S[T]>;