mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 15:13:21 +00:00
Merge branch 'features/api-token-v2' into api-token-v2/permissions-route
This commit is contained in:
commit
4d72da1257
@ -26,6 +26,7 @@ module.exports = {
|
||||
minLength: 1,
|
||||
configurable: false,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
@ -60,5 +61,15 @@ module.exports = {
|
||||
configurable: false,
|
||||
required: false,
|
||||
},
|
||||
expiresAt: {
|
||||
type: 'datetime',
|
||||
configurable: false,
|
||||
required: false,
|
||||
},
|
||||
lifespan: {
|
||||
type: 'integer',
|
||||
configurable: false,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const { ApplicationError } = require('@strapi/utils').errors;
|
||||
const { omit } = require('lodash/fp');
|
||||
const createContext = require('../../../../../../test/helpers/create-context');
|
||||
const apiTokenController = require('../api-token');
|
||||
|
||||
@ -63,6 +64,109 @@ describe('API Token Controller', () => {
|
||||
expect(create).toHaveBeenCalledWith(body);
|
||||
expect(created).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Create API Token with lifespan', async () => {
|
||||
const lifespan = 90 * 24 * 60 * 60 * 1000; // 90 days
|
||||
const createBody = {
|
||||
...body,
|
||||
lifespan,
|
||||
};
|
||||
const tokenBody = {
|
||||
...createBody,
|
||||
expiresAt: Date.now() + lifespan,
|
||||
permissions: undefined,
|
||||
};
|
||||
|
||||
const create = jest.fn().mockResolvedValue(tokenBody);
|
||||
const exists = jest.fn(() => false);
|
||||
const badRequest = jest.fn();
|
||||
const created = jest.fn();
|
||||
const ctx = createContext({ body: createBody }, { badRequest, created });
|
||||
|
||||
global.strapi = {
|
||||
admin: {
|
||||
services: {
|
||||
'api-token': {
|
||||
exists,
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await apiTokenController.create(ctx);
|
||||
|
||||
expect(exists).toHaveBeenCalledWith({ name: tokenBody.name });
|
||||
expect(badRequest).not.toHaveBeenCalled();
|
||||
expect(create).toHaveBeenCalledWith(createBody);
|
||||
expect(created).toHaveBeenCalledWith({ data: tokenBody });
|
||||
});
|
||||
|
||||
test('Throws with invalid lifespan', async () => {
|
||||
const lifespan = -1;
|
||||
const createBody = {
|
||||
...body,
|
||||
lifespan,
|
||||
};
|
||||
|
||||
const create = jest.fn();
|
||||
const created = jest.fn();
|
||||
const ctx = createContext({ body: createBody }, { created });
|
||||
|
||||
global.strapi = {
|
||||
admin: {
|
||||
services: {
|
||||
'api-token': {
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(async () => {
|
||||
await apiTokenController.create(ctx);
|
||||
}).rejects.toThrow('lifespan must be greater than or equal to 1');
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(created).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Ignores a received expiresAt', async () => {
|
||||
const lifespan = 90 * 24 * 60 * 60 * 1000; // 90 days
|
||||
const createBody = {
|
||||
...body,
|
||||
expiresAt: 1234,
|
||||
lifespan,
|
||||
};
|
||||
const tokenBody = {
|
||||
...createBody,
|
||||
expiresAt: Date.now() + lifespan,
|
||||
permissions: undefined,
|
||||
};
|
||||
|
||||
const create = jest.fn().mockResolvedValue(tokenBody);
|
||||
const exists = jest.fn(() => false);
|
||||
const badRequest = jest.fn();
|
||||
const created = jest.fn();
|
||||
const ctx = createContext({ body: createBody }, { badRequest, created });
|
||||
|
||||
global.strapi = {
|
||||
admin: {
|
||||
services: {
|
||||
'api-token': {
|
||||
exists,
|
||||
create,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await apiTokenController.create(ctx);
|
||||
|
||||
expect(exists).toHaveBeenCalledWith({ name: tokenBody.name });
|
||||
expect(badRequest).not.toHaveBeenCalled();
|
||||
expect(create).toHaveBeenCalledWith(omit(['expiresAt'], createBody));
|
||||
expect(created).toHaveBeenCalledWith({ data: tokenBody });
|
||||
});
|
||||
});
|
||||
|
||||
describe('List API tokens', () => {
|
||||
|
||||
@ -25,6 +25,7 @@ module.exports = {
|
||||
description: trim(body.description),
|
||||
type: body.type,
|
||||
permissions: body.permissions,
|
||||
lifespan: body.lifespan,
|
||||
};
|
||||
|
||||
await validateApiTokenCreationInput(attributes);
|
||||
@ -100,11 +101,6 @@ module.exports = {
|
||||
attributes.description = trim(body.description);
|
||||
}
|
||||
|
||||
// Don't allow updating lastUsedAt time
|
||||
if (has(attributes, 'lastUsedAt')) {
|
||||
throw new ApplicationError('lastUsedAt cannot be updated');
|
||||
}
|
||||
|
||||
await validateApiTokenUpdateInput(attributes);
|
||||
|
||||
const apiTokenExists = await apiTokenService.getById(id);
|
||||
|
||||
@ -11,28 +11,23 @@ describe('API Token', () => {
|
||||
hexedString: '6170692d746f6b656e5f746573742d72616e646f6d2d6279746573',
|
||||
};
|
||||
|
||||
const SELECT_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'lastUsedAt',
|
||||
'type',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
const now = Date.now();
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(crypto, 'randomBytes')
|
||||
.mockImplementation(() => Buffer.from(mockedApiToken.randomBytes));
|
||||
|
||||
jest.useFakeTimers('modern').setSystemTime(now);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
test('Creates a new token', async () => {
|
||||
test('Creates a new read-only token', async () => {
|
||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||
|
||||
global.strapi = {
|
||||
@ -53,16 +48,143 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.create(attributes);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
data: {
|
||||
...attributes,
|
||||
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
populate: ['permissions'],
|
||||
});
|
||||
expect(res).toEqual({
|
||||
...attributes,
|
||||
accessKey: mockedApiToken.hexedString,
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Creates a new token with lifespan', async () => {
|
||||
const attributes = {
|
||||
name: 'api-token_tests-name',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'read-only',
|
||||
lifespan: 123456,
|
||||
};
|
||||
|
||||
const expectedExpires = Date.now() + attributes.lifespan;
|
||||
|
||||
const create = jest.fn(({ data }) => Promise.resolve(data));
|
||||
global.strapi = {
|
||||
query() {
|
||||
return { create };
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
};
|
||||
|
||||
const res = await apiTokenService.create(attributes);
|
||||
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
data: {
|
||||
...attributes,
|
||||
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
|
||||
expiresAt: expectedExpires,
|
||||
lifespan: attributes.lifespan,
|
||||
},
|
||||
populate: ['permissions'],
|
||||
});
|
||||
expect(res).toEqual({
|
||||
...attributes,
|
||||
accessKey: mockedApiToken.hexedString,
|
||||
expiresAt: expectedExpires,
|
||||
lifespan: attributes.lifespan,
|
||||
});
|
||||
expect(res.expiresAt).toBe(expectedExpires);
|
||||
});
|
||||
|
||||
test('Creates a custom token', async () => {
|
||||
const attributes = {
|
||||
name: 'api-token_tests-name',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'custom',
|
||||
permissions: ['admin::content.content.read'],
|
||||
};
|
||||
const createTokenResult = {
|
||||
...attributes,
|
||||
lifespan: null,
|
||||
expiresAt: null,
|
||||
id: 1,
|
||||
};
|
||||
|
||||
const findOne = jest.fn().mockResolvedValue(omit('permissions', createTokenResult));
|
||||
const create = jest.fn().mockResolvedValue(createTokenResult);
|
||||
const load = jest.fn().mockResolvedValueOnce(
|
||||
Promise.resolve(
|
||||
attributes.permissions.map((p) => {
|
||||
return {
|
||||
action: p,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
global.strapi = {
|
||||
query() {
|
||||
return {
|
||||
findOne,
|
||||
create,
|
||||
};
|
||||
},
|
||||
config: {
|
||||
get: jest.fn(() => ''),
|
||||
},
|
||||
entityService: {
|
||||
load,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await apiTokenService.create(attributes);
|
||||
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'admin::api-token',
|
||||
{
|
||||
...createTokenResult,
|
||||
},
|
||||
'permissions'
|
||||
);
|
||||
|
||||
// call to create token
|
||||
expect(create).toHaveBeenNthCalledWith(1, {
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
data: {
|
||||
...omit('permissions', attributes),
|
||||
accessKey: apiTokenService.hash(mockedApiToken.hexedString),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
populate: ['permissions'],
|
||||
});
|
||||
// call to create permission
|
||||
expect(create).toHaveBeenNthCalledWith(2, {
|
||||
data: {
|
||||
action: 'admin::content.content.read',
|
||||
token: {
|
||||
...createTokenResult,
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
...createTokenResult,
|
||||
accessKey: mockedApiToken.hexedString,
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -149,7 +271,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.list();
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
orderBy: { name: 'ASC' },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -177,7 +299,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.revoke(token.id);
|
||||
|
||||
expect(mockedDelete).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id: token.id },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -196,7 +318,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.revoke(42);
|
||||
|
||||
expect(mockedDelete).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id: 42 },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -224,7 +346,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.getById(token.id);
|
||||
|
||||
expect(findOne).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id: token.id },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -243,7 +365,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.getById(42);
|
||||
|
||||
expect(findOne).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id: 42 },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -349,7 +471,7 @@ describe('API Token', () => {
|
||||
},
|
||||
});
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id },
|
||||
data: attributes,
|
||||
populate: ['permissions'],
|
||||
@ -461,7 +583,7 @@ describe('API Token', () => {
|
||||
});
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id },
|
||||
data: omit(['permissions'], updatedAttributes),
|
||||
populate: expect.anything(), // it doesn't matter how this is used
|
||||
@ -533,7 +655,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.update(id, updatedAttributes);
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { id },
|
||||
data: omit(['permissions'], updatedAttributes),
|
||||
populate: expect.anything(), // it doesn't matter how this is used
|
||||
@ -565,7 +687,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.getByName(token.name);
|
||||
|
||||
expect(findOne).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { name: token.name },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
@ -584,7 +706,7 @@ describe('API Token', () => {
|
||||
const res = await apiTokenService.getByName('unexistant-name');
|
||||
|
||||
expect(findOne).toHaveBeenCalledWith({
|
||||
select: SELECT_FIELDS,
|
||||
select: expect.arrayContaining([expect.any(String)]),
|
||||
where: { name: 'unexistant-name' },
|
||||
populate: ['permissions'],
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { isNil } = require('lodash/fp');
|
||||
const { omit, difference, isEmpty, map, isArray } = require('lodash/fp');
|
||||
const { ValidationError, NotFoundError } = require('@strapi/utils').errors;
|
||||
const constants = require('./constants');
|
||||
@ -17,6 +18,8 @@ const constants = require('./constants');
|
||||
* @property {string} description
|
||||
* @property {string} accessKey
|
||||
* @property {number} lastUsedAt
|
||||
* @property {number} lifespan
|
||||
* @property {number} expiresAt
|
||||
* @property {TokenType} type
|
||||
* @property {(number|ApiTokenPermission)[]} permissions
|
||||
*/
|
||||
@ -30,11 +33,23 @@ const constants = require('./constants');
|
||||
*/
|
||||
|
||||
/** @constant {Array<string>} */
|
||||
const SELECT_FIELDS = ['id', 'name', 'description', 'lastUsedAt', 'type', 'createdAt', 'updatedAt'];
|
||||
const SELECT_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'lastUsedAt',
|
||||
'type',
|
||||
'lifespan',
|
||||
'expiresAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
/** @constant {Array<string>} */
|
||||
const POPULATE_FIELDS = ['permissions'];
|
||||
|
||||
// TODO: we need to ensure the permissions are actually valid registered permissions!
|
||||
|
||||
/**
|
||||
* Assert that a token's permissions attribute is valid for its type
|
||||
*
|
||||
@ -124,12 +139,31 @@ const hash = (accessKey) => {
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} lifespan
|
||||
*
|
||||
* @returns { { lifespan: null | number, expiresAt: null | number } }
|
||||
*/
|
||||
const getExpirationFields = (lifespan) => {
|
||||
// it must be nil or a finite number >= 0
|
||||
const isValidNumber = Number.isFinite(lifespan) && lifespan > 0;
|
||||
if (!isValidNumber && !isNil(lifespan)) {
|
||||
throw new ValidationError('lifespan must be a positive number or null');
|
||||
}
|
||||
|
||||
return {
|
||||
lifespan: lifespan || null,
|
||||
expiresAt: lifespan ? Date.now() + lifespan : null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a token and its permissions
|
||||
*
|
||||
* @param {Object} attributes
|
||||
* @param {TokenType} attributes.type
|
||||
* @param {string} attributes.name
|
||||
* @param {number} attributes.lifespan
|
||||
* @param {string[]} attributes.permissions
|
||||
* @param {string} attributes.description
|
||||
*
|
||||
@ -147,6 +181,7 @@ const create = async (attributes) => {
|
||||
data: {
|
||||
...omit('permissions', attributes),
|
||||
accessKey: hash(accessKey),
|
||||
...getExpirationFields(attributes.lifespan),
|
||||
},
|
||||
});
|
||||
|
||||
@ -300,8 +335,8 @@ const update = async (id, attributes) => {
|
||||
}
|
||||
|
||||
const changingTypeToCustom =
|
||||
attributes.type === constants.API_TOKEN_TYPE.custom &&
|
||||
originalToken.type !== constants.API_TOKEN_TYPE.custom;
|
||||
attributes.type === constants.API_TOKEN_TYPE.CUSTOM &&
|
||||
originalToken.type !== constants.API_TOKEN_TYPE.CUSTOM;
|
||||
|
||||
// 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
|
||||
@ -341,11 +376,6 @@ const update = async (id, attributes) => {
|
||||
)
|
||||
);
|
||||
|
||||
// method using deleteMany -- leaves relations in _links table!
|
||||
// await strapi
|
||||
// .query('admin::token-permission')
|
||||
// .deleteMany({ where: { action: map('action', permissionsToDelete), token: id } });
|
||||
|
||||
// TODO: improve efficiency here
|
||||
// using a loop -- works but very inefficient
|
||||
await Promise.all(
|
||||
@ -355,25 +385,6 @@ const update = async (id, attributes) => {
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// method using createMany -- doesn't create relations in _links table!
|
||||
// await strapi
|
||||
// .query('admin::token-permission')
|
||||
// .createMany({ data: actionsToAdd.map(action => ({ action, token: id })) });
|
||||
|
||||
// method attempting to use entityService -- can't create new items in entityservice, permissions need to already exist
|
||||
// await strapi.entityService.update('admin::api-token', originalToken.id, {
|
||||
// data: {
|
||||
// permissions: [
|
||||
// actionsToAdd.map(action => {
|
||||
// return { action };
|
||||
// }),
|
||||
// ],
|
||||
// },
|
||||
// populate: POPULATE_FIELDS,
|
||||
// });
|
||||
|
||||
// method attempting to createMany permissions, then update token with those permissions -- createMany doesn't return the ids, and we can't query for them
|
||||
}
|
||||
// if type is not custom, make sure any old permissions get removed
|
||||
else if (updatedToken.type !== constants.API_TOKEN_TYPE.CUSTOM) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { UnauthorizedError } = require('@strapi/utils/lib/errors');
|
||||
const createContext = require('../../../../../../test/helpers/create-context');
|
||||
const apiTokenStrategy = require('../api-token');
|
||||
|
||||
@ -83,6 +84,37 @@ describe('API Token Auth Strategy', () => {
|
||||
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
|
||||
expect(response).toStrictEqual({ authenticated: false });
|
||||
});
|
||||
|
||||
test('Expired token throws on authorize', async () => {
|
||||
const pastDate = Date.now() - 1;
|
||||
|
||||
const getBy = jest.fn(() => {
|
||||
return {
|
||||
...apiToken,
|
||||
expiresAt: pastDate,
|
||||
};
|
||||
});
|
||||
const update = jest.fn(() => apiToken);
|
||||
const ctx = createContext({}, { request });
|
||||
|
||||
global.strapi = {
|
||||
admin: {
|
||||
services: {
|
||||
'api-token': {
|
||||
getBy,
|
||||
hash,
|
||||
update,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(async () => {
|
||||
await apiTokenStrategy.authenticate(ctx);
|
||||
}).rejects.toThrow(new UnauthorizedError('Token expired'));
|
||||
|
||||
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verify an access key', () => {
|
||||
@ -160,6 +192,42 @@ describe('API Token Auth Strategy', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Verify with expiration in future', () => {
|
||||
global.strapi = {
|
||||
container,
|
||||
};
|
||||
|
||||
expect(
|
||||
apiTokenStrategy.verify(
|
||||
{
|
||||
credentials: {
|
||||
...readOnlyApiToken,
|
||||
expiresAt: Date.now() + 99999,
|
||||
},
|
||||
},
|
||||
{ scope: ['api::model.model.find'] }
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Throws with expired token', () => {
|
||||
global.strapi = {
|
||||
container,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
apiTokenStrategy.verify(
|
||||
{
|
||||
credentials: {
|
||||
...readOnlyApiToken,
|
||||
expiresAt: Date.now() - 1,
|
||||
},
|
||||
},
|
||||
{ scope: ['api::model.model.find'] }
|
||||
);
|
||||
}).toThrow(new UnauthorizedError('Token expired'));
|
||||
});
|
||||
|
||||
test('Throws an error if trying to access a `full-access` action with a read only access key', () => {
|
||||
global.strapi = {
|
||||
container,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { castArray } = require('lodash/fp');
|
||||
const { castArray, isNil } = require('lodash/fp');
|
||||
const { UnauthorizedError, ForbiddenError } = require('@strapi/utils').errors;
|
||||
const constants = require('../services/constants');
|
||||
const { getService } = require('../utils');
|
||||
@ -21,7 +21,10 @@ const extractToken = (ctx) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** @type {import('.').AuthenticateFunction} */
|
||||
/**
|
||||
* Authenticate the validity of the token
|
||||
*
|
||||
* @type {import('.').AuthenticateFunction} */
|
||||
const authenticate = async (ctx) => {
|
||||
const apiTokenService = getService('api-token');
|
||||
const token = extractToken(ctx);
|
||||
@ -34,10 +37,16 @@ const authenticate = async (ctx) => {
|
||||
accessKey: apiTokenService.hash(token),
|
||||
});
|
||||
|
||||
// token not found
|
||||
if (!apiToken) {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
// token has expired
|
||||
if (!isNil(apiToken.expiresAt) && apiToken.expiresAt < Date.now()) {
|
||||
throw new UnauthorizedError('Token expired');
|
||||
}
|
||||
|
||||
// update lastUsedAt
|
||||
await apiTokenService.update(apiToken.id, {
|
||||
lastUsedAt: new Date(),
|
||||
@ -54,12 +63,20 @@ const authenticate = async (ctx) => {
|
||||
return { authenticated: true, credentials: apiToken };
|
||||
};
|
||||
|
||||
/** @type {import('.').VerifyFunction} */
|
||||
/**
|
||||
* Verify the token has the required abilities for the requested scope
|
||||
*
|
||||
* @type {import('.').VerifyFunction} */
|
||||
const verify = (auth, config) => {
|
||||
const { credentials: apiToken, ability } = auth;
|
||||
|
||||
if (!apiToken) {
|
||||
throw new UnauthorizedError();
|
||||
throw new UnauthorizedError('Token not found');
|
||||
}
|
||||
|
||||
// token has expired
|
||||
if (!isNil(apiToken.expiresAt) && apiToken.expiresAt < Date.now()) {
|
||||
throw new UnauthorizedError('Token expired');
|
||||
}
|
||||
|
||||
// Full access
|
||||
|
||||
@ -31,13 +31,14 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
await strapi.destroy();
|
||||
});
|
||||
|
||||
// create a random valid token that we can test with (delete, list, etc)
|
||||
// create a predictable valid token that we can test with (delete, list, etc)
|
||||
let currentTokens = 0;
|
||||
const createValidToken = async (token = {}) => {
|
||||
currentTokens += 1;
|
||||
|
||||
const body = {
|
||||
type: 'read-only',
|
||||
// eslint-disable-next-line no-plusplus
|
||||
name: `token_${String(currentTokens++)}`,
|
||||
name: `token_${String(currentTokens)}`,
|
||||
description: 'generic description',
|
||||
...token,
|
||||
};
|
||||
@ -65,7 +66,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
expect(res.body).toStrictEqual({
|
||||
data: null,
|
||||
error: {
|
||||
status: 400,
|
||||
@ -98,7 +99,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
expect(res.body).toStrictEqual({
|
||||
data: null,
|
||||
error: {
|
||||
status: 400,
|
||||
@ -117,7 +118,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Creates a read-only api token (successfully)', async () => {
|
||||
test('Creates a read-only api token', async () => {
|
||||
const body = {
|
||||
name: 'api-token_tests-readonly',
|
||||
description: 'api-token_tests-description',
|
||||
@ -131,16 +132,149 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.data).toMatchObject({
|
||||
expect(res.body.data).toStrictEqual({
|
||||
accessKey: expect.any(String),
|
||||
name: body.name,
|
||||
permissions: [],
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
id: expect.any(Number),
|
||||
createdAt: expect.any(String),
|
||||
createdAt: expect.toBeISODate(),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
updatedAt: expect.toBeISODate(),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Creates a token without a lifespan', async () => {
|
||||
const body = {
|
||||
name: 'api-token_tests-no-lifespan',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'read-only',
|
||||
};
|
||||
|
||||
const res = await rq({
|
||||
url: '/admin/api-tokens',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.data).toStrictEqual({
|
||||
accessKey: expect.any(String),
|
||||
name: body.name,
|
||||
permissions: [],
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
id: expect.any(Number),
|
||||
createdAt: expect.toBeISODate(),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.toBeISODate(),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Creates a token with a lifespan', async () => {
|
||||
const now = Date.now();
|
||||
jest.useFakeTimers('modern').setSystemTime(now);
|
||||
|
||||
const body = {
|
||||
name: 'api-token_tests-lifespan',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'read-only',
|
||||
lifespan: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
};
|
||||
|
||||
const res = await rq({
|
||||
url: '/admin/api-tokens',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.data).toStrictEqual({
|
||||
accessKey: expect.any(String),
|
||||
name: body.name,
|
||||
permissions: [],
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
id: expect.any(Number),
|
||||
createdAt: expect.toBeISODate(),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.toBeISODate(),
|
||||
expiresAt: expect.toBeISODate(),
|
||||
lifespan: body.lifespan,
|
||||
});
|
||||
|
||||
// Datetime stored in some databases may lose ms accuracy, so allow a range of 2 seconds for timing edge cases
|
||||
expect(Date.parse(res.body.data.expiresAt)).toBeGreaterThan(now + body.lifespan - 2000);
|
||||
expect(Date.parse(res.body.data.expiresAt)).toBeLessThan(now + body.lifespan + 2000);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('Creates a token with a null lifespan', async () => {
|
||||
const body = {
|
||||
name: 'api-token_tests-nulllifespan',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'read-only',
|
||||
lifespan: null,
|
||||
};
|
||||
|
||||
const res = await rq({
|
||||
url: '/admin/api-tokens',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.data).toStrictEqual({
|
||||
accessKey: expect.any(String),
|
||||
name: body.name,
|
||||
permissions: [],
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
id: expect.any(Number),
|
||||
createdAt: expect.toBeISODate(),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.toBeISODate(),
|
||||
expiresAt: null,
|
||||
lifespan: body.lifespan,
|
||||
});
|
||||
});
|
||||
|
||||
test('Fails to create a token with invalid lifespan', async () => {
|
||||
const body = {
|
||||
name: 'api-token_tests-lifespan',
|
||||
description: 'api-token_tests-description',
|
||||
type: 'read-only',
|
||||
lifespan: -1,
|
||||
};
|
||||
|
||||
const res = await rq({
|
||||
url: '/admin/api-tokens',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toStrictEqual({
|
||||
data: null,
|
||||
error: {
|
||||
status: 400,
|
||||
name: 'ValidationError',
|
||||
message: 'lifespan must be greater than or equal to 1',
|
||||
details: {
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: 'lifespan must be greater than or equal to 1',
|
||||
name: 'ValidationError',
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -159,7 +293,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
expect(res.body).toStrictEqual({
|
||||
data: null,
|
||||
error: {
|
||||
status: 400,
|
||||
@ -200,10 +334,12 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('Creates a custom api token (successfully)', async () => {
|
||||
test('Creates a custom api token', async () => {
|
||||
const body = {
|
||||
name: 'api-token_tests-customSuccess',
|
||||
description: 'api-token_tests-description',
|
||||
@ -228,6 +364,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -280,6 +418,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -307,6 +447,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: expect.any(String),
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -323,6 +465,7 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
);
|
||||
tokens.push(await createValidToken({ type: 'full-access' }));
|
||||
tokens.push(await createValidToken({ type: 'read-only' }));
|
||||
tokens.push(await createValidToken({ lifespan: 12345 }));
|
||||
tokens.push(await createValidToken());
|
||||
|
||||
const res = await rq({
|
||||
@ -331,16 +474,15 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.data.length).toBe(4);
|
||||
expect(res.body.data.length).toBe(tokens.length);
|
||||
// check that each token exists in data
|
||||
tokens.forEach((token) => {
|
||||
const t = res.body.data.find((t) => t.id === token.id);
|
||||
if (t.permissions) {
|
||||
t.permissions = t.permissions.sort();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
token.permissions = token.permissions.sort();
|
||||
Object.assign(token, { permissions: token.permissions.sort() });
|
||||
}
|
||||
expect(t).toMatchObject(omit(token, ['accessKey']));
|
||||
expect(t).toStrictEqual(omit(token, ['accessKey']));
|
||||
});
|
||||
});
|
||||
|
||||
@ -362,6 +504,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -395,6 +539,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -419,6 +565,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -477,6 +625,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
// expect(updatedRes.body.data.updated)
|
||||
});
|
||||
@ -529,6 +679,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -588,6 +740,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -614,6 +768,8 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
createdAt: token.createdAt,
|
||||
lastUsedAt: null,
|
||||
updatedAt: expect.any(String),
|
||||
expiresAt: null,
|
||||
lifespan: null,
|
||||
});
|
||||
});
|
||||
|
||||
@ -645,7 +801,5 @@ describe('Admin API Token v2 CRUD (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.todo('Regenerated access key works');
|
||||
test.todo('Tokens access content for which they are authorized');
|
||||
test.todo('Tokens fail to access content for which they are not authorized');
|
||||
test.todo('Custom token can only be created with valid permissions that exist');
|
||||
});
|
||||
@ -10,8 +10,10 @@ const apiTokenCreationSchema = yup
|
||||
description: yup.string().optional(),
|
||||
type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).required(),
|
||||
permissions: yup.array().of(yup.string()).nullable(),
|
||||
lifespan: yup.number().integer().min(1).nullable(),
|
||||
})
|
||||
.noUnknown();
|
||||
.noUnknown()
|
||||
.strict();
|
||||
|
||||
const apiTokenUpdateSchema = yup
|
||||
.object()
|
||||
@ -20,8 +22,10 @@ const apiTokenUpdateSchema = yup
|
||||
description: yup.string().nullable(),
|
||||
type: yup.string().oneOf(Object.values(constants.API_TOKEN_TYPE)).notNull(),
|
||||
permissions: yup.array().of(yup.string()).nullable(),
|
||||
lifespan: yup.number().integer().min(1).nullable(),
|
||||
})
|
||||
.noUnknown();
|
||||
.noUnknown()
|
||||
.strict();
|
||||
|
||||
module.exports = {
|
||||
validateApiTokenCreationInput: validateYupSchema(apiTokenCreationSchema),
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
"test:unit": "jest --verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/cors": "3.1.0",
|
||||
"@koa/cors": "3.4.1",
|
||||
"@koa/router": "10.1.1",
|
||||
"@strapi/admin": "4.3.6",
|
||||
"@strapi/database": "4.3.6",
|
||||
|
||||
15
yarn.lock
15
yarn.lock
@ -2325,17 +2325,10 @@
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@koa/cors@3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
|
||||
integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
|
||||
dependencies:
|
||||
vary "^1.1.2"
|
||||
|
||||
"@koa/cors@^3.1.0":
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2"
|
||||
integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ==
|
||||
"@koa/cors@3.4.1", "@koa/cors@^3.1.0":
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.1.tgz#ddd5c6ff07a1e60831e1281411a3b9fdb95a5b26"
|
||||
integrity sha512-/sG9NlpGZ/aBpnRamIlGs+wX+C/IJ5DodNK7iPQIVCG4eUQdGeshGhWQ6JCi7tpnD9sCtFXcS04iTimuaJfh4Q==
|
||||
dependencies:
|
||||
vary "^1.1.2"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user