Skip to content
This repository has been archived by the owner on Mar 22, 2022. It is now read-only.

Adds ability to limit queries unless authenticated and authorized #229

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 35 additions & 2 deletions example/app.js
Expand Up @@ -23,6 +23,7 @@ var app = feathers()
.use('/users', memory())
// A simple Message service that we can used for testing
.use('/messages', memory())
.use('/approved-messages', memory())
.use('/', feathers.static(__dirname + '/public'))
.use(errorHandler());

Expand All @@ -39,9 +40,40 @@ messageService.before({
]
})

var approvedMessageService = app.service('/approved-messages');
approvedMessageService.create({text: 'A million people walk into a Silicon Valley bar', approved: false}, {}, function(){});
approvedMessageService.create({text: 'Nobody buys anything', approved: true}, {}, function(){});
approvedMessageService.create({text: 'Bar declared massive success', approved: true}, {}, function(){});


// Will merge this restriction with the query params
var restriction = { restrict: {approved: true} };

approvedMessageService.before({
all: [
// Necessary since restrict must always use find and hook id is a string when the memory service expects it as a number
function(hook) {
if(hook.id) {
hook.id = parseInt(hook.id, 10);
}
}
],
find: [
authentication.hooks.verifyOrRestrict(restriction),
authentication.hooks.populateOrRestrict(restriction),
authentication.hooks.hasRoleOrRestrict(Object.assign({roles: ['admin']}, restriction))
],
get: [
authentication.hooks.verifyOrRestrict(restriction),
authentication.hooks.populateOrRestrict(restriction),
authentication.hooks.hasRoleOrRestrict(Object.assign({roles: ['admin']}, restriction))
]
})


var userService = app.service('users');

// Add a hook to the user service that automatically replaces
// Add a hook to the user service that automatically replaces
// the password with a hash of the password before saving it.
userService.before({
create: authentication.hooks.hashPassword()
Expand All @@ -50,7 +82,8 @@ userService.before({
// Create a user that we can use to log in
var User = {
email: 'admin@feathersjs.com',
password: 'admin'
password: 'admin',
roles: ['admin']
};

userService.create(User, {}).then(function(user) {
Expand Down
8 changes: 7 additions & 1 deletion example/client.js
Expand Up @@ -19,13 +19,19 @@ app.authenticate({
'password': 'admin'
}).then(function(result){
console.log(`Successfully authenticated against ${host}!`, result);

app.service('messages').find({}).then(function(data){
console.log('messages', data);
}).catch(function(error){
console.error('Error finding data', error);
});

app.service('approved-messages').find({}).then(function(data){
console.log('approvedMessages', data);
}).catch(function(error){
console.error('Error finding data', error);
});

}).catch(function(error){
console.error('Error authenticating!', error);
});
10 changes: 8 additions & 2 deletions example/public/index.html
Expand Up @@ -161,13 +161,19 @@ <h3>Basic email/password authentication via socket</h3>
console.log('Authenticated!', result);

alert('You successfully authenticated over sockets with email and password. Your new JWT is:\n\n' + app.get('token'));

app.service('messages').find({}).then(function(data){
console.log('messages', data);
}).catch(function(error){
console.error('Error finding data', error);
});

app.service('approvedMessages').find({}).then(function(data){
console.log('approvedMessages', data);
}).catch(function(error){
console.error('Error finding data', error);
});

}).catch(function(error){
console.error('Error authenticating!', error);
alert('Error: ' + error.message);
Expand All @@ -176,4 +182,4 @@ <h3>Basic email/password authentication via socket</h3>
});
</script>
</body>
</html>
</html>
146 changes: 146 additions & 0 deletions src/hooks/has-role-or-restrict.js
@@ -0,0 +1,146 @@
import errors from 'feathers-errors';
import isPlainObject from 'lodash.isplainobject';

const defaults = {
fieldName: 'roles',
idField: '_id',
ownerField: 'userId',
owner: false
};

export default function(options = {}){
if (!options.roles || !options.roles.length) {
throw new Error(`You need to provide an array of 'roles' to check against.`);
}

return function(hook) {
if (hook.type !== 'before') {
throw new Error(`The 'hasRoleOrRestrict' hook should only be used as a 'before' hook.`);
}

// If it was an internal call then skip this hook
if (!hook.params.provider) {
return hook;
}

if(hook.result) {
return hook;
}

options = Object.assign({}, defaults, hook.app.get('auth'), options);

// If we don't have a user we have to always use find instead of get because we must not return id queries that are unrestricted and we don't want the developer to have to add after hooks.
let query = Object.assign({}, hook.params.query, options.restrict);

if(hook.id !== null && hook.id !== undefined) {
const id = {};
id[options.idField] = hook.id;
query = Object.assign(query, id);
}

// Set provider as undefined so we avoid an infinite loop if this hook is
// set on the resource we are requesting.
const params = Object.assign({}, hook.params, { provider: undefined });

if (!hook.params.user) {
if(hook.result) {
return hook;
}

return this.find({ query }, params).then(results => {
if(results.length >= 1) {
if(hook.id !== undefined && hook.id !== null) {
hook.result = results[0];
} else {
hook.result = results;
}
return hook;
}
throw new errors.NotFound(`No record found`);
}).catch(() => {
throw new errors.NotFound(`No record found`);
});
}

let authorized = false;
let roles = hook.params.user[options.fieldName];
const id = hook.params.user[options.idField];
const error = new errors.Forbidden('You do not have valid permissions to access this.');

if (id === undefined) {
throw new Error(`'${options.idField} is missing from current user.'`);
}

// If the user doesn't even have a `fieldName` field and we're not checking
// to see if they own the requested resource return Forbidden error
if (!options.owner && roles === undefined) {
throw error;
}

// If the roles is not an array, normalize it
if (!Array.isArray(roles)) {
roles = [roles];
}

// Iterate through all the roles the user may have and check
// to see if any one of them is in the list of permitted roles.
authorized = roles.some(role => options.roles.indexOf(role) !== -1);

// If we should allow users that own the resource and they don't already have
// the permitted roles check to see if they are the owner of the requested resource
if (options.owner && !authorized) {
if (!hook.id) {
throw new errors.MethodNotAllowed(`The 'hasRoleOrRestrict' hook should only be used on the 'get', 'update', 'patch' and 'remove' service methods if you are using the 'owner' field.`);
}

// look up the document and throw a Forbidden error if the user is not an owner
return new Promise((resolve, reject) => {
// Set provider as undefined so we avoid an infinite loop if this hook is
// set on the resource we are requesting.
const params = Object.assign({}, hook.params, { provider: undefined });

this.get(hook.id, params).then(data => {
if (data.toJSON) {
data = data.toJSON();
}
else if (data.toObject) {
data = data.toObject();
}

let field = data[options.ownerField];

// Handle nested Sequelize or Mongoose models
if (isPlainObject(field)) {
field = field[options.idField];
}

if ( field === undefined || field.toString() !== id.toString() ) {
reject(new errors.Forbidden('You do not have the permissions to access this.'));
}

resolve(hook);
}).catch(reject);
});
}

if (!authorized) {
if(hook.result) {
return hook;
}

return this.find({ query }, params).then(results => {
if(results.length >= 1) {
if(hook.id !== undefined && hook.id !== null) {
hook.result = results[0];
} else {
hook.result = results;
}
return hook;
}
throw new errors.NotFound(`No record found`);
}).catch(() => {
throw new errors.NotFound(`No record found`);
});
}
};
}
8 changes: 7 additions & 1 deletion src/hooks/index.js
Expand Up @@ -6,6 +6,9 @@ import restrictToAuthenticated from './restrict-to-authenticated';
import restrictToOwner from './restrict-to-owner';
import restrictToRoles from './restrict-to-roles';
import verifyToken from './verify-token';
import verifyOrRestrict from './verify-or-restrict';
import populateOrRestrict from './populate-or-restrict';
import hasRoleOrRestrict from './has-role-or-restrict';

let hooks = {
associateCurrentUser,
Expand All @@ -15,7 +18,10 @@ let hooks = {
restrictToAuthenticated,
restrictToOwner,
restrictToRoles,
verifyToken
verifyToken,
verifyOrRestrict,
populateOrRestrict,
hasRoleOrRestrict
};

export default hooks;
105 changes: 105 additions & 0 deletions src/hooks/populate-or-restrict.js
@@ -0,0 +1,105 @@
import errors from 'feathers-errors';

/**
* Populate the current user associated with the JWT
*/
const defaults = {
userEndpoint: '/users',
idField: '_id'
};

export default function(options = {}){
return function(hook) {
let id;

options = Object.assign({}, defaults, hook.app.get('auth'), options);

// If it's an after hook grab the id from the result
if (hook.type !== 'before') {
throw new Error(`The 'populateOrRestrict' hook should only be used as a 'before' hook.`);
}

// If it was an internal call then skip this hook
if (!hook.params.provider) {
return hook;
}

// If we don't have a payload we have to always use find instead of get because we must not return id queries that are unrestricted and we don't want the developer to have to add after hooks.
let query = Object.assign({}, hook.params.query, options.restrict);

// Set provider as undefined so we avoid an infinite loop if this hook is
// set on the resource we are requesting.
const params = Object.assign({}, hook.params, { provider: undefined });

if(hook.id !== null && hook.id !== undefined) {
const id = {};
id[options.idField] = hook.id;
query = Object.assign(query, id);
}

// Check to see if we have an id from a decoded JWT
if (hook.params.payload) {
id = hook.params.payload[options.idField];
} else {
if(hook.result) {
return hook;
}

return this.find({ query }, params).then(results => {
if(results.length >= 1) {
if(hook.id !== undefined && hook.id !== null) {
hook.result = results[0];
} else {
hook.result = results;
}
return hook;
}
throw new errors.NotFound(`No record found`);
}).catch(() => {
throw new errors.NotFound(`No record found`);
});
}

// If we didn't find an id then just pass through
if (id === undefined) {
return Promise.resolve(hook);
}

return new Promise(function(resolve){
hook.app.service(options.userEndpoint).get(id, {}).then(user => {
// attach the user to the hook for use in other hooks or services
hook.params.user = user;

// If it's an after hook attach the user to the response
if (hook.result) {
hook.result.data = Object.assign({}, user = !user.toJSON ? user : user.toJSON());

// remove the id field from the root, it already exists inside the user object
delete hook.result[options.idField];
}

return resolve(hook);
}).catch(() => {

if(hook.result) {
return hook;
}

return this.find({ query }, params).then(results => {
if(results.length >= 1) {
if(hook.id !== undefined && hook.id !== null) {
hook.result = results[0];
} else {
hook.result = results;
}
return hook;
}

throw new errors.NotFound(`No record found`);
}).catch(() => {
throw new errors.NotFound(`No record found`);
});
});
});
};
}