Permissions
For the last piece of our application, we are going to require that a user have "admin" permissions to delete another user. We created a cheat way to add permissions to our users earlier in the series. Using that, or Mongohub, ensure we have one user with "admin" as a permission, and one user without. Let's start logged in as the user without permissions.
Update The Server
On the server, we know who the user making a request is due to the token they present on every call, but we don't have the User object. We could make a DB call to see if they have the right permissions. This could become a burden on our server. We could use a server side session to store the User, but again, this may not scale well. The easiest way to determine the permissions is to have the user tell us. We can add the permissions to the token before we encrypt it. Then when we decrypt it, which we do on every authenticated call, we can access the permissions it contains. Let's update our authenticate
endpoint
/app/routes/user.js
userRouter.post('/authenticate', function (req, res) {
User.findOne({
email: req.body.email
}).select('name email password').exec(function (err, user) {
if (err) throw err;
if (!user) {
res.json({success: false, message: 'User not found'});
} else {
var validPassword = user.comparePassword(req.body.password);
if (!validPassword) {
res.json({success: false, message: 'Wrong password'});
} else {
var token = jwt.sign({
name: user.name,
email: user.email,
_id: user._id,
permissions: user.permissions
}, superSecret, {
expiresInMinutes: 1440
});
res.json({
success: true,
message: 'login ok',
token: token,
_id: user._id
});
}
}
});
});
We simply added the one line
permissions: user.permissions
in our signing method.
Now, let's update our DELETE route to check for admin. For convenience, and consistency with our client, let's throw underscore.js into the server.
npm install underscore --save
and add it to the top of our user routes
var _ = require('underscore');
and finally update the DELETE route
.delete(function (req, res) {
if (_.contains(req.decoded.permissions, 'admin')){
User.remove({_id: req.params.user_id}, function (err, user) {
if (err) res.send(err);
res.json({});
})
} else {
return res.status(403).send({success: false, message: 'User is not authorized to delete users'});
}
});
We check if the permissions property of our decoded token contains "admin", and if not, throw a 403 error. Refresh the server and try to delete a user. You should see the error in the console window and the list of users remaining the same.
For this error, we don't want to redirect the user anywhere but it would be nice to let them know that the error occured. Let's trap for the 403, like we did the 401, and popup an alert. For our popups, we'll be using Toastr, so let's add it with bower, update require-main.js
, and include its CSS on our index page.
bower install toastr --save
require-main.js
requirejs.config({
baseUrl: "app",
paths: {
"jquery": "../components/jquery/dist/jquery",
"underscore": "../components/underscore/underscore",
"backbone": "../components/backbone/backbone",
"handlebars": "../components/handlebars/handlebars.amd",
"text": "../components/requirejs-text/text",
"hbs": "../components/require-handlebars-plugin/hbs",
"datatables": "../components/datatables/media/js/jquery.dataTables",
"datatables-bootstrap3": "../components/datatables-bootstrap3-plugin/media/js/datatables-bootstrap3",
"bootstrap-modal": "../components/bootstrap/js/modal",
"backbone.bootstrap-modal": "../components/backbone.bootstrap-modal/src/backbone.bootstrap-modal",
"stickit" : "../components/backbone.stickit/backbone.stickit",
"toastr" : "../components/toastr/toastr"
}
});
require(['main', 'app']);
index.html
<link rel="stylesheet" href="components/toastr/toastr.css">
With the library in place, let's update our error handler. Remember to require toastr at the top of the file.
app/main.js
$.ajaxSetup({
statusCode: {
401: function (context) {
mediator.trigger('router:navigate', {route: 'login', options: {trigger: true}});
},
403: function(context){
toastr.options = {
"closeButton": false,
"debug": false,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-top-center",
"preventDuplicates": false,
"onclick": null,
"showDuration": "300",
"hideDuration": "1000",
"timeOut": "5000",
"extendedTimeOut": "1000",
"showEasing": "swing",
"hideEasing": "linear",
"showMethod": "fadeIn",
"hideMethod": "fadeOut"
};
toastr["error"](context.responseJSON.message);
}
},
beforeSend: function (xhr) {
var token = window.localStorage.getItem(globals.auth.TOKEN_KEY);
xhr.setRequestHeader('x-access-token', token);
}
});
You can configure your popup however you like. These options are the ones generated from the library's demo site. We configure the popup, then display the error message our backend returned to us.
One last tweak is needed. Backbone is optimistic when you ask to destroy a model, and will remove it from any collections it is in. Since we may not want that to happen, we pass in a wait property to our destroy method. Let's update that
app/users/listView.js
deleteUser: function(e){
var self = this;
var id = $(e.currentTarget).attr('data-id');
var user = this.collection.get(id);
user.destroy({wait: true}).done(function(){
self.collection.remove(user);
self.render();
});
}
Now, ideally, we don't want the user to even be given the option to press the delete button. All of this error handling is neccesary to protect our data but we shouldn't be giving users options they don't have. Much like checking for authentication, we are going to create a way for our views to check for authorizations.
Let's create a simple Permissions object that we can create when the user logs in.
app/permissions.js
define(function (require) {
'use strict';
var _ = require('underscore');
return function(permissions){
this.isAdmin = function(){
return _.contains(permissions, 'admin');
};
}
});
We'll pass in the permissions array from the user when we create this, then for convenience we expose an isAdmin function. This can be expanded as needed. Common patterns include 'has one of these permissions' and 'has all of these permissions'. Let's update our app
object
app/app.js
var Permissions = require('permissions');
var _permissions;
function _initializeUser() {
var d = $.Deferred();
if (_isAuthenticated() && !_user) {
_user = new User({_id: window.localStorage.getItem(globals.auth.USER_KEY)});
_user.fetch().success(function () {
mediator.trigger('page:updateUserInfo');
_permissions = new Permissions(_user.get('permissions'));
d.resolve();
});
} else {
d.resolve();
}
return d.promise();
}
function _getPermissions() {
return _permissions;
}
return {
isAuthenticated: _isAuthenticated,
initialize: _initialize,
initializeUser: _initializeUser,
getApplicationInfo: _getApplicationInfo,
getUser: _getUser,
getPermissions: _getPermissions
}
Now, let's update our user list template and view to not show the delete button unless the user is an admin.
app/users/list.hbs
<div class="pull-right">
<button data-id="{{_id}}" class="btn btn-danger btn-sm js-deleteUser">Delete</button>
{{#if ../admin}}<button data-id="{{_id}}" class="btn btn-success btn-sm js-editUser">Edit</button>{{/if}}
</div>
Here we check the value of ../admin to conditionally show the button. We are using the ../
notation because we are inside of the users
loop and we will be storing our admin value one level up.
_app/users/listView.js (render)
render: function () {
this.$el.html(template({
users: this.collection.toJSON(),
admin: app.getPermissions().isAdmin()
}));
this.$('table').DataTable({
"aoColumns": [
null,
null,
null,
{ "bSortable": false }
]
});
return this;
}
After adding an app dependency to our object, we add an admin value to the context object we pass our template for rendering. Refresh the page, and the button is gone. Log in now as a user with admin permissions and verify that the button is still visible and in fact deletes the user.
Next Steps
In our final installment, we will make a few tweaks to the code and talk about what could be some next steps.
(The code at this state in the project can be checked out at commit: 7d3bc57ffc8ec09c4e28b739da9ba3d20da37a2c)