A Little Clean Up
At this point, we have a fairly functional app, with full CRUD operations and an authentication strategy. Before we dive into doing authorization, or permission based access, we are going to clean up our app logic a little bit, proactively protect our routes from unauthorized access, get a reference to the currently logged in user, and display relevant information in the header.
For the most part, we access data when we need it. Some data, however, is global and used by the app as a whole. This could be information such as an application version or build information. It could be reference data used in most pages, or, in our case, it's the user that is currently logged in. We're going to create an app
object that will hold on to these types of values. It will also handle orchestrating information and workflow between views when appropriate.
We'll start by moving our local storage access routines into it.
/app/app.js
define(function (require) {
'use strict';
var globals = require('globals');
var mediator = require('mediator');
function _authenticated(response) {
window.localStorage.setItem(globals.auth.TOKEN_KEY, response.token);
window.localStorage.setItem(globals.auth.USER_KEY, response._id);
mediator.trigger('router:navigate', {route: 'home', options: {trigger: true}});
}
function _logout(){
window.localStorage.removeItem(globals.auth.TOKEN_KEY);
window.localStorage.removeItem(globals.auth.USER_KEY);
mediator.trigger('router:navigate', {route: 'home', options: {trigger: true}});
}
mediator.on('app:authenticated', _authenticated, this);
mediator.on('app:logout', _logout);
return {
}
});
We pull in our globals and mediator objects, and set up two functions that are called when the appropriate messages are triggered. Now, we can clean up /app/router.js
and /app/login/view.js
, removing their local storage calls and redirects with a simple message. To ensure that this global object is always loaded and listening for messages, we can load it along with the main file in require-main.js
require(['main', 'app']);
Protecting Our Routes
We'll be adding to app.js
shortly. Let's look at how we can enhance our router to protect our routes from unauthorized access. It's important to remember - ALL THIS CODE IS CLIENT SIDE - when we say we are protecting it, all we are doing is providing a better experience for our users and, in this case, minimizing the number of unauthenticated service calls. Any data, images, etc. that is in your JS/Handlebars/whatever files will still be sent to the user's browser - especially after you concatenate them.
If you recall the flowchart from the last installment, we asked potentially three questions before sending a user to their requested route: Does the route need authentication, do we have a stored token, and do we have a stored User? The first question we will answer implicitly by guarding only the routes that need it. For our app, that would be the Users page. The second question we will ask explicitly in our router, directing the user to a login page if not already authenticated. The third question we will defer to the view that we will use the information in.
Here is our new users
route in our router:
users: function() {
console.log('routing - users');
if (app.isAuthenticated()) {
require(['users/collection', 'users/listView'], function (Collection, View) {
var collection = new Collection();
collection.fetch().done(function () {
mediator.trigger('page:displayView', new View({collection: collection}));
});
});
} else {
console.log('unauthenticated - redirecting to login');
this.login();
}
},
We've wrapped our regular routing code in a check to our app object to see if we are authenticated, and if not we go to the login page. So, let's add that code now. On the empty object we are currently exporting from app we'll add an isAuthenticated property which will hold a reference to a "private" function.
function _isAuthenticated(){
return window.localStorage.getItem(globals.auth.TOKEN_KEY);
}
return {
isAuthenticated: _isAuthenticated
}
Here we are simply returning the value of the token we have stored. We make the assumption if there is one, it is valid, and use the truthiness of the value as our answer. If we want to get fancy, we could decouple this even more, using a Request pattern. Check out Backbone.Radio for more info.
Will the stored token always be valid? No. We currently have ours set to expire after 24 hours but nothing is going to remove it from the browser's storage unless the user logs out. In this case, we fall back on our 401 error handling. Same goes for a token that is forged or otherwise malformed. We could get fancy and use a client side JSON Web Token library to verify the encryption signature but there's really no need.
Application Wide Data
In addition to our current user, let's set up an unauthenticated API call that returns the current version of our application. Typically, we would set up another set of routes for our server, but let's cheat and put this call in our user routes. Use the following code and place it before we apply our authentication middleware.
userRouter.get('/applicationInfo', function(req,res){
res.json({version: '1.0-apple', build: 'local'});
});
While we are at it, let's add that URL to our globals
object
urls: {
AUTHENTICATE: '/api/authenticate',
APP_INFO: '/applicationInfo'
}
Together with the current user, we are going to spruce up our header/footer areas. So, we will be answering that third question (Do we have a user/app wide data?) in our page view. We will ask for it when we first render our page. In addition, we will add some messaging so that we can update our page should the information change (such as when the user logouts).
Since this is the third question, we can assume the user is authenticated - we verified this with the second question. What we can't assume is that we've loaded this information already. Perhaps the user is returning after being gone awhile. They still have a valid token, so they are 'authenticated' but haven't retrieved the requested data. What we want to do is "always" get the data, and then when done, return to our view. I say "always" because we will do a quick check and only call the server if we don't have it. To accomplish this, we will use jQuery's Deferred object and promises. In the case that the user has not authenticated, we will return an empty user object and render our page accordingly.
Aside: One might wonder why we don't store this information like we do the token or user id. For one, this type of information, especially reference data, can get quite large. We want to limit the amount of data we store locally. Additionally, this data can change. Maybe not over the course of a user's session but we do want to ensure we are working with current data when possible. Finally, some of the information may be sensitive. Leaving it in the browser storage makes it accessible with little safeguards.
Updating Our Header
Let's update the header to show different information on whether or not we have a valid user object. Let's assume if we have an empty user object (no _id property) that there is no logged in user.
/app/page/headerTemplate.hbs
<header>
<div class="navbar navbar-default">
<div class="container">
<div class="navbar-header">
<a href="/" class="navbar-brand"><b>relearning backbone</b></a>
</div>
<ul class="nav navbar-nav">
{{#if _id}}
<li><a href="#users"><span class="fa fa-users"></span> Users</a></li>
{{/if}}
</ul>
<ul class="nav navbar-nav navbar-right">
{{#if _id}}
<li class="navbar-text">Hello {{name}}</li>
<li><a href="#logout">Logout <span class="fa fa-lg fa-sign-out"></span></a></li>
{{else}}
<li><a href="#login">Login <span class="fa fa-lg fa-sign-in"></span></a></li>
{{/if}}
</ul>
</div>
</div>
</header>
To see this in action, we can update the render methods in our header view and page view
/app/page/headerView.js (render)
render: function(){
this.$el.html(template(this.model.toJSON()));
return this;
}
/app/page/pageView.js (render)
render: function () {
this.$el.html(template());
//this.headerView = new HeaderView({ model: new User() });
this.headerView = new HeaderView({ model: new User({ _id: '1', email: 'a@example.com', name: 'Example User' })});
this.headerView.render();
return this;
}
Here we alternately bind an empty User to the header view's model or one we mocked up. Comment one or the other out to see the two states of the header.
With that working, let's update our page view render once again to work with real live data.
/app/page/pageView.js (render)
render: function () {
var self = this;
// Gets nonauthenticated application info
app.initialize().done(function () {
// Ensure user is loaded if authenticated or blank user if not
app.initializeUser().done(function(){
self.$el.html(template());
self.headerView = new HeaderView({ model: app.getUser()});
self.headerView.render();
return self;
});
});
}
Here we've wrapped our rendering with two asynchronous calls to load app data and the current user, and we get the current user (or a blank one) to bind to the header view's model. Let's add these three methods to our app object (and a fourth for getApplicationInfo which we will use later).
/app/app.js
// other methods above
var User = require('users/model');
var _user;
var _applicationInfo;
function _initialize() {
var d = $.Deferred();
if (_applicationInfo !== null) {
d.resolve();
} else {
$.ajax({
url: globals.urls.APP_INFO,
success: function (data) {
_applicationInfo = data;
d.resolve();
}
});
}
return d.promise();
}
function _initializeUser() {
var d = $.Deferred();
if (_isAuthenticated() && !_user) {
_user = new User({_id: window.localStorage.getItem(globals.auth.USER_KEY)});
_user.fetch().success(function () {
d.resolve();
});
} else {
d.resolve();
}
return d.promise();
}
function _getApplicationInfo() {
return _applicationInfo;
}
function _getUser() {
return _user || new User();
}
return {
isAuthenticated: _isAuthenticated,
initialize: _initialize,
initializeUser: _initializeUser,
getApplicationInfo: _getApplicationInfo,
getUser: _getUser
}
This works. Kind of. If you already have a token from logging on previously, when you refresh the page, you should see the correct information in the header. However, if you need to login first, or when your log out, you'll see that the header information never changes. We set it once when we rendered it and then leave it alone. We need for our page view to respond when ever our current user changes.
Let's look at logout first. Let's update our logout
method in app
to clear out our user and fire off a message
function _logout() {
window.localStorage.removeItem(globals.AUTH_TOKEN_KEY);
window.localStorage.removeItem(globals.AUTH_USER_KEY);
_user = null;
mediator.trigger('page:updateUserInfo');
mediator.trigger('router:navigate', {route:'home', options: {trigger: true}});
};
Now, let's have the header view respond to that message. Here is the new view
/app/page/headerView.js
define(function (require) {
'use strict';
var app = require('app');
var Backbone = require('backbone');
var mediator = require('mediator');
var template = require('hbs!page/headerTemplate');
return Backbone.View.extend({
el: '#header',
initialize: function(){
this.bindPageEvents();
},
render: function(){
this.$el.html(template(this.model.toJSON()));
return this;
},
bindPageEvents: function(){
mediator.on('page:updateUserInfo', this.updateUserInfo, this);
},
updateUserInfo: function(){
this.model = app.getUser();
this.render();
}
});
});
Now, when we logout, the header refreshes itself and clears the user's info. Now for logging in. Previously on login, we stored the token and user id and then navigated to the home page. Let's also load up the user with our _initializeUser
call and send a message to update the page when it's done.
function _authenticated(response) {
window.localStorage.setItem(globals.auth.TOKEN_KEY, response.token);
window.localStorage.setItem(globals.auth.USER_KEY, response._id);
_initializeUser();
mediator.trigger('router:navigate', {route: 'home', options: {trigger: true}});
}
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');
d.resolve();
});
} else {
d.resolve();
}
return d.promise();
}
Once again, when the message is fired, the header view will be updated. We can follow the same pattern for any data that is stored and updated away from the view that is displaying it.
Next Steps
The last piece of functionality we want to take a look at is authorization. Just because a user is logged in doesn't mean they have access to all the app's features. We'll look at allowing only certain users to use the Delete feature and handling 403 Unauthorized errors.
(The code at this state in the project can be checked out at commit: 5942747fa9d760d148e825c76df05a6b8ea81c00)