add api-token auth strategy to the content-api

This commit is contained in:
Dieter Stinglhamber 2021-09-16 14:36:54 +02:00
parent 5bc7c4462a
commit 1a72747672
10 changed files with 448 additions and 50 deletions

View File

@ -1,48 +1,12 @@
'use strict';
const adminAuthStrategy = {
name: 'admin',
async authenticate(ctx) {
const { authorization } = ctx.request.header;
if (!authorization) {
return { authenticated: false };
}
const parts = authorization.split(/\s+/);
if (parts[0] !== 'Bearer' || parts.length !== 2) {
return { authenticated: false };
}
const token = parts[1];
const { payload, isValid } = strapi.admin.services.token.decodeJwtToken(token);
if (isValid) {
const user = await strapi
.query('admin::user')
.findOne({ where: { id: payload.id }, populate: ['roles'] });
if (!user || !(user.isActive === true)) {
return { error: 'Invalid credentials' };
}
const userAbility = await strapi.admin.services.permission.engine.generateUserAbility(user);
ctx.state.userAbility = userAbility;
ctx.state.user = user;
ctx.state.isAuthenticatedAdmin = true;
return { authenticated: true, credentials: user };
}
return { error: 'Invalid credentials' };
},
};
const adminAuthStrategy = require('./strategies/admin');
const apiTokenAuthStrategy = require('./strategies/api-token');
module.exports = () => {
const passportMiddleware = strapi.admin.services.passport.init();
strapi.server.api('admin').use(passportMiddleware);
strapi.container.get('auth').register('admin', adminAuthStrategy);
strapi.container.get('auth').register('content-api', apiTokenAuthStrategy);
};

View File

@ -21,13 +21,15 @@ const SELECT_FIELDS = ['id', 'name', 'description', 'type'];
/**
* @param {Object} whereParams
* @param {string} whereParams.name
* @param {string|number} [whereParams.id]
* @param {string} [whereParams.name]
* @param {string} [whereParams.description]
* @param {string} [whereParams.accessKey]
*
* @returns {Promise<boolean>}
*/
const exists = async (whereParams = {}) => {
const apiToken = await strapi.query('admin::api-token').findOne({ where: whereParams });
const apiToken = await getBy(whereParams);
return !!apiToken;
};
@ -113,7 +115,7 @@ const revoke = async id => {
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const getById = async id => {
return strapi.query('admin::api-token').findOne({ select: SELECT_FIELDS, where: { id } });
return getBy({ id });
};
/**
@ -122,7 +124,7 @@ const getById = async id => {
* @returns {Promise<Omit<ApiToken, 'accessKey'>>}
*/
const getByName = async name => {
return strapi.query('admin::api-token').findOne({ select: SELECT_FIELDS, where: { name } });
return getBy({ name });
};
/**
@ -140,6 +142,23 @@ const update = async (id, attributes) => {
.update({ where: { id }, data: attributes, select: SELECT_FIELDS });
};
/**
* @param {Object} whereParams
* @param {string|number} [whereParams.id]
* @param {string} [whereParams.name]
* @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;
}
return strapi.query('admin::api-token').findOne({ select: SELECT_FIELDS, where: whereParams });
};
module.exports = {
create,
exists,
@ -150,4 +169,5 @@ module.exports = {
getById,
update,
getByName,
getBy,
};

View File

@ -0,0 +1,114 @@
'use strict';
const createContext = require('../../../../../../test/helpers/create-context');
const adminAuthStrategy = require('../admin');
describe('Admin Auth Strategy', () => {
describe('Authenticate a user', () => {
const request = {
header: {
authorization: 'Bearer admin_tests-jwt-token',
},
};
test('Authenticates a valid JWT token', async () => {
const decodeJwtToken = jest.fn(() => ({ isValid: true, payload: { id: 1 } }));
const ctx = createContext({}, { request, state: {} });
const user = { id: 1, isActive: true };
const findOne = jest.fn(() => user);
const generateUserAbility = jest.fn();
global.strapi = {
admin: {
services: {
token: { decodeJwtToken },
permission: { engine: { generateUserAbility } },
},
},
query: jest.fn(() => ({ findOne })),
};
const response = await adminAuthStrategy.authenticate(ctx);
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(findOne).toHaveBeenCalledWith({ where: { id: 1 }, populate: ['roles'] });
expect(response).toStrictEqual({ authenticated: true, credentials: user });
});
test('Fails to authenticate if the authorization header is missing', async () => {
const ctx = createContext({}, { request: { header: {} } });
const response = await adminAuthStrategy.authenticate(ctx);
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an invalid authorization header', async () => {
const ctx = createContext({}, { request: { header: { authorization: 'invalid-header' } } });
const response = await adminAuthStrategy.authenticate(ctx);
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an invalid bearer token', async () => {
const decodeJwtToken = jest.fn(() => ({ isValid: false }));
const ctx = createContext({}, { request });
global.strapi = {
admin: {
services: {
token: { decodeJwtToken },
},
},
};
const response = await adminAuthStrategy.authenticate(ctx);
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an invalid user', async () => {
const decodeJwtToken = jest.fn(() => ({ isValid: true, payload: { id: 1 } }));
const ctx = createContext({}, { request });
const findOne = jest.fn(() => ({ isActive: false }));
global.strapi = {
admin: {
services: {
token: { decodeJwtToken },
},
},
query: jest.fn(() => ({ findOne })),
};
const response = await adminAuthStrategy.authenticate(ctx);
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(findOne).toHaveBeenCalledWith({ where: { id: 1 }, populate: ['roles'] });
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an non-existing user', async () => {
const decodeJwtToken = jest.fn(() => ({ isValid: true, payload: { id: 1 } }));
const ctx = createContext({}, { request });
const findOne = jest.fn(() => null);
global.strapi = {
admin: {
services: {
token: { decodeJwtToken },
},
},
query: jest.fn(() => ({ findOne })),
};
const response = await adminAuthStrategy.authenticate(ctx);
expect(decodeJwtToken).toHaveBeenCalledWith('admin_tests-jwt-token');
expect(findOne).toHaveBeenCalledWith({ where: { id: 1 }, populate: ['roles'] });
expect(response).toStrictEqual({ authenticated: false });
});
});
});

View File

@ -0,0 +1,164 @@
'use strict';
const createContext = require('../../../../../../test/helpers/create-context');
const apiTokenStrategy = require('../api-token');
describe('API Token Auth Strategy', () => {
describe('Authenticate an access key', () => {
const request = {
header: {
authorization: 'Bearer api-token_tests-api-token',
},
};
const apiToken = {
id: 1,
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
};
const hash = jest.fn(() => 'api-token_tests-hashed-access-key');
test('Authenticates a valid hashed access key', async () => {
const getBy = jest.fn(() => apiToken);
const ctx = createContext({}, { request });
global.strapi = {
admin: {
services: {
'api-token': {
getBy,
hash,
},
},
},
};
const response = await apiTokenStrategy.authenticate(ctx);
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
expect(response).toStrictEqual({ authenticated: true, credentials: apiToken });
});
test('Fails to authenticate if the authorization header is missing', async () => {
const ctx = createContext({}, { request: { header: {} } });
const response = await apiTokenStrategy.authenticate(ctx);
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an invalid authorization header', async () => {
const ctx = createContext({}, { request: { header: { authorization: 'invalid-header' } } });
const response = await apiTokenStrategy.authenticate(ctx);
expect(response).toStrictEqual({ authenticated: false });
});
test('Fails to authenticate an invalid bearer token', async () => {
const getBy = jest.fn(() => null);
const ctx = createContext(
{},
{ request: { header: { authorization: 'bearer invalid-header' } } }
);
global.strapi = {
admin: {
services: {
'api-token': {
getBy,
hash,
},
},
},
};
const response = await apiTokenStrategy.authenticate(ctx);
expect(getBy).toHaveBeenCalledWith({ accessKey: 'api-token_tests-hashed-access-key' });
expect(response).toStrictEqual({ authenticated: false });
});
});
describe('Verify an access key', () => {
const readOnlyApiToken = {
id: 1,
name: 'api-token_tests-name',
description: 'api-token_tests-description',
type: 'read-only',
};
const fullAccessApiToken = {
...readOnlyApiToken,
type: 'full-access',
};
const container = {
get: jest.fn(() => ({
errors: {
UnauthorizedError: jest.fn(() => new Error()),
ForbiddenError: jest.fn(() => new Error()),
},
})),
};
test('Verify read only access', () => {
global.strapi = {
container,
};
expect(
apiTokenStrategy.verify(
{ credentials: { readOnlyApiToken } },
{ scope: 'api::model.model.find' }
)
).toBeUndefined();
});
test('Verify full access', () => {
global.strapi = {
container,
};
expect(
apiTokenStrategy.verify(
{ credentials: { fullAccessApiToken } },
{ scope: 'api::model.model.create' }
)
).toBeUndefined();
});
test('Throws an error if trying to access a `full-access` action with a read only access key', () => {
global.strapi = {
container,
};
expect.assertions(1);
try {
apiTokenStrategy.verify(
{ credentials: { readOnlyApiToken } },
{ scope: 'api::model.model.create' }
);
} catch (err) {
expect(err).toBeInstanceOf(Error);
}
});
test('Throws an error if the credentials are not passed in the auth object', () => {
global.strapi = {
container,
};
expect.assertions(1);
try {
apiTokenStrategy.verify({}, { scope: 'api::model.model.create' });
} catch (err) {
expect(err).toBeInstanceOf(Error);
}
});
});
});

View File

@ -0,0 +1,46 @@
'use strict';
const { getService } = require('../utils');
/** @type {import('.').AuthenticateFunction} */
const authenticate = async ctx => {
const { authorization } = ctx.request.header;
if (!authorization) {
return { authenticated: false };
}
const parts = authorization.split(/\s+/);
if (parts[0].toLowerCase() !== 'bearer' || parts.length !== 2) {
return { authenticated: false };
}
const token = parts[1];
const { payload, isValid } = getService('token').decodeJwtToken(token);
if (!isValid) {
return { authenticated: false };
}
const user = await strapi
.query('admin::user')
.findOne({ where: { id: payload.id }, populate: ['roles'] });
if (!user || !(user.isActive === true)) {
return { authenticated: false };
}
const userAbility = await getService('permission').engine.generateUserAbility(user);
ctx.state.userAbility = userAbility;
ctx.state.user = user;
return { authenticated: true, credentials: user };
};
/** @type {import('.').AuthStrategy} */
module.exports = {
name: 'admin',
authenticate,
};

View File

@ -0,0 +1,64 @@
'use strict';
const constants = require('../services/constants');
const { getService } = require('../utils');
/** @type {import('.').AuthenticateFunction} */
const authenticate = async ctx => {
const apiTokenService = getService('api-token');
const { authorization } = ctx.request.header;
if (!authorization) {
return { authenticated: false };
}
const parts = authorization.split(/\s+/);
if (parts[0].toLowerCase() !== 'bearer' || parts.length !== 2) {
return { authenticated: false };
}
const token = parts[1];
const apiToken = await apiTokenService.getBy({
accessKey: apiTokenService.hash(token),
});
if (!apiToken) {
return { authenticated: false };
}
return { authenticated: true, credentials: apiToken };
};
/** @type {import('.').VerifyFunction} */
const verify = (auth, config) => {
const { errors } = strapi.container.get('auth');
const { credentials: apiToken } = auth;
if (!apiToken) {
throw new errors.UnauthorizedError();
}
const isReadAction = config.scope.endsWith('find') || config.scope.endsWith('findOne');
/**
* If it's a "READ" action, then the type of token doesn't matter as
* both `full-access` and `read-only` allowa you to get the data.
*/
if (isReadAction) {
return;
}
if (!isReadAction && apiToken.type === constants.API_TOKEN_TYPE.FULL_ACCESS) {
return;
}
throw new errors.ForbiddenError();
};
/** @type {import('.').AuthStrategy} */
module.exports = {
name: 'api-token',
authenticate,
verify,
};

View File

@ -0,0 +1,25 @@
'use strict';
/**
* @typedef {{authenticated: boolean, error?: string, credentials?: Record<any, any>}} AuthenticateResponse
* @typedef {(ctx: Record<any, any>) => AuthenticateResponse | Promise<AuthenticateResponse>} AuthenticateFunction
* @typedef {{strategy: AuthStrategy, credentials?: Record<any, any>}} VerifyInputAuth
* @typedef {{scope: string, [key: any]: any}} VerifyInputConfig
* @typedef {(auth: VerifyInputAuth, config: VerifyInputConfig) => void | Promise<void>} VerifyFunction
*/
/**
* @typedef AuthStrategy
*
* @property {string} name
* @property {AuthenticateFunction} authenticate
* @property {VerifyFunction} [verify]
*/
/**
* @type {Record<string, AuthStrategy>}
*/
module.exports = {
admin: require('./admin'),
'api-token': require('./api-token'),
};

View File

@ -69,7 +69,7 @@ const createAuthentication = () => {
}
}
return ctx.unauthorized('Missing credentials');
return ctx.unauthorized('Missing or invalid credentials');
},
async verify(auth, config = {}) {
if (config === false) {

View File

@ -1,6 +1,6 @@
'use strict';
const authStrategy = require('./auth/strategy');
const authStrategy = require('./strategies/users-permissions');
module.exports = strapi => {
strapi.container.get('auth').register('content-api', authStrategy);

View File

@ -11,27 +11,28 @@ const getAdvancedSettings = () => {
const authenticate = async ctx => {
if (ctx.request && ctx.request.header && ctx.request.header.authorization) {
try {
console.log({ ctx });
const { id } = await getService('jwt').getToken(ctx);
if (id === undefined) {
return { error: 'Invalid token: Token did not contain required fields' };
return { authenticated: false };
}
// fetch authenticated user
const user = await getService('user').fetchAuthenticatedUser(id);
if (!user) {
return { error: 'Invalid credentials' };
return { authenticated: false };
}
const advancedSettings = await getAdvancedSettings();
if (advancedSettings.email_confirmation && !user.confirmed) {
return { error: 'Invalid credentials' };
return { authenticated: false };
}
if (user.blocked) {
return { error: 'Invalid credentials' };
return { authenticated: false };
}
ctx.state.user = user;
@ -41,7 +42,7 @@ const authenticate = async ctx => {
credentials: user,
};
} catch (err) {
return { error: 'Invalid credentials' };
return { authenticated: false };
}
}