mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 23:24:03 +00:00
add api-token auth strategy to the content-api
This commit is contained in:
parent
5bc7c4462a
commit
1a72747672
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
114
packages/core/admin/server/strategies/__tests__/admin.test.js
Normal file
114
packages/core/admin/server/strategies/__tests__/admin.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/core/admin/server/strategies/admin.js
Normal file
46
packages/core/admin/server/strategies/admin.js
Normal 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,
|
||||
};
|
||||
64
packages/core/admin/server/strategies/api-token.js
Normal file
64
packages/core/admin/server/strategies/api-token.js
Normal 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,
|
||||
};
|
||||
25
packages/core/admin/server/strategies/index.js
Normal file
25
packages/core/admin/server/strategies/index.js
Normal 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'),
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user