Authentication in Ember.js ~ July 12, 2013
This post is about authentication, but it also covers a more general topic: setting up a framework for ajax calls that accepts errors and reacts to them appropriately. When implementing my own Ember app, I decided against ember-data and ember-auth because they’re both new enough that the rough edges were causing me more headache than good while learning my way around Ember to begin with. I also really struggled with getting basic auth such as this working while learning Ember, so hopefully this will be useful to a few other Ember newbies out there.
This guide was written for Ember 1.0.0-rc4, and originally published in May as a gist.
The Difficulty
Ember makes it hard to statically get a reference to the currently active Route
, and with good reason. If the active route is changed in an unexpected way, or at an unexpected time, the result is undefined. Therefore, all result handlers from ajax calls must have a reference to the Route
in hand so that they can react to the results appropriately.
Robin Ward wrote a good article on using Ember without ember-data, which convinced me to pursue this approach. There are some conventions your service must follow to use ember-data easily which I’m not a huge fan of, and the whole thing is still pretty new. I’m sure it’ll be awesome in time, but for now I’d rather just handle the data layer myself.
A Solution
A good way to accomplish this is with an Ember Mixin. With this, we can define a set of functions for calling REST services that do some appropriate preliminary processing on the result (such as redirecting to a login route when receiving an authentication challenge).
First, set up our App. Local storage is used to store the authentication token for two reasons: 1) cookies can be unreliable (they are difficult to set correctly if your app is running within an iframe, for instance) and 2) in some server frameworks, pulling cookies out of a request is slightly less convenient than GET or POST parameters, and we don’t actually need the browser to attach our auth token to each request for us; we can handle it.
1 2 3 4 5 6 7 | window.App = Ember.Application.create({
// support for remembering auth via localStorage. Only works on modern browsers
authToken: localStorage['authToken'],
// global alert error for user feedback <String>
error: null
});
|
Remember that LocalStorage
is a fairly new feature. If you are developing an app that must support older browsers (unlikely, since you’re developing in Ember to begin with) then you should use Cookies as your authentication transport instead, as annoying a they are.
Next, define the Mixin.
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | // Mixin to any Route that you want to be able to send authenticated ajax calls from. Calls that fail for auth
// reasons will result in a redirect to the 'login' route automatically
App.Ajax = Ember.Mixin.create({
ajaxSuccessHandler: function (json) {
// in my app, error code 201 is reserved for authentication errors that require a login
if (json.error != null && json.error.code == 201) {
App.error.set(json.error.message);
var self = this;
// delay to let current processing finish.
setTimeout(function () { self.transitionTo('login'); });
// let handlers further down the Promise chain know that we've already handled this one.
return null;
}
return json;
},
// perform ajax GET call to retrieve json
GET: function (url, data) {
var settings = {data: data || {}};
settings.url = url;
settings.dataType = "json";
settings.type = "GET";
var authToken = App.get('authToken');
if (authToken != null) settings.data.authToken = authToken;
return this.ajax(settings);
},
// perform ajax POST call to retrieve json
POST: function (url, data) {
var settings = {data: data || {}};
settings.url = url;
settings.dataType = "json";
settings.type = "POST";
var authToken = App.get('authToken');
if (authToken != null) settings.data.authToken = authToken;
// post our data as a JSON object in the request body
settings.data = JSON.stringify(settings.data);
return this.ajax(settings);
},
ajax: function (settings) {
var self = this;
return $.ajax(settings).then(function () {
// preserve 'this' for the success handler
return self.ajaxSuccessHandler.apply(self, $.makeArray(arguments));
});
}
});
|
Templates
The templates are structured to look reasonable with Twitter’s Bootstrap CSS.
Here we have the top level template. It includes a conditionally displayed logout button in the top navigation bar, and a container for app contents.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <!-- top level app -->
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="navbar-inner">
<a class="brand" href="#">App</a>
<!-- if our App's authToken field is set, display a logout button. Action event is handled
in ApplicationRoute -->
{{#if App.authToken}}
<button class="btn pull-right" {{action logout}}>Logout</button>
{{/if}}
</div>
</div>
<div class="container">
<!-- if our App's error field is set, display the error with a close button. Button's action is
handled in ApplicationRoute -->
{{#if App.error}}
<div class="alert alert-error">
<button type="button" class="close" data-dismiss="alert"
{{action "dismissError"}}>×</button>
{{App.error}}}
</div>
{{/if}}
{{outlet}}
</div>
|
The login template is pretty standard: username and password fields, plus a “remember me” checkbox, telling the application to store the authentication token in LocalStorage
if checked.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <!-- 'login' route's template, goes in the top level outlet, is replaced by other route content
after -->
<div class="session_form">
<form>
<fieldset>
<legend>Sign In</legend>
<label>
Username<br>
{{view Ember.TextField name="username" valueBinding="username"}}
</label>
<label>
Password<br>
{{view Ember.TextField type="password" name="password" valueBinding="password"}}
</label>
<label class="checkbox">Remember me {{view Ember.Checkbox type="checkbox"
name="remember_me" checkedBinding="remember"}}</label>
<br>
<!-- login action handled in LoginRoute -->
<button {{action "login"}} class="btn" type="submit">Sign In</button>
</fieldset>
</form>
</div>
|
Routes
App.Router
is defined here with just the login route, but of course a full app would have more. The ApplicationRoute
handles the logout
event (from the logout button).
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | App.Router.map(function () {
this.resource('login');
// others...
});
App.ApplicationRoute = Ember.Route.extend(App.Ajax, {
events: {
logout: function () {
this.GET('/auth/logout').then(function (json) {
if (json != null && json.error != null) {
App.set('error', json.error.message);
}
});
// even if we error out, we can still clear our own record
App.set('authToken', null);
delete localStorage['authToken'];
this.transitionTo('login');
},
dismissError: function () {
App.set('error', null);
}
}
});
|
The login logic uses a LoginCreds
model that the login template fills in, the App.Ajax
mixin, and the Ajax call to our login service.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | App.LoginCreds = Ember.Object.extend({
username: null,
password: null,
remember: false,
json: function () {
return {
username: this.get('username'),
password: this.get('password'),
remember: this.get('remember')
}
}
});
App.LoginController = Ember.ObjectController.extend({});
App.LoginRoute = Ember.Route.extend(App.Ajax, {
model: function () {
// let our login template fill in the properties of a creds object
return App.LoginCreds.create({});
},
events: {
login: function () {
var model = this.modelFor('login'); // <App.LoginCreds>
var self = this;
self.GET('/auth/login', model.json()).then(
function (json) {
if (json == null) return; // shouldn't happen, but should still NPE protect
if (json.error != null) {
// useful for any ajax call: set the global error alert with our error message
App.set('error', json.error.message);
} else {
// setting this value will reveal our logout button
App.set('authToken', json.authToken);
if (model.get('remember')) {
localStorage['authToken'] = json.authToken;
} else {
// make sure a stale value isn't left behind
delete localStorage['authToken'];
}
// clear out any login error that was left over
App.set('error', null);
self.router.transitionTo(/*<wherever you want after login>*/);
}
});
}
}
});
|
That’s it! Using the App.Ajax
for normal service calls is easy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | App.FooRoute = Ember.Route.extend(App.Ajax, {
model: function () {
// create and return model
},
events: {
// handle events from the model that result in Ajax calls
updateBar: function () {
this.GET('/bar/update', {/* parameters */}).then(
function (json) {
if (json == null) {
// there was an auth challenge, and we'll get set to login automatically
return;
}
// process result, likely updating the model and/or moving to another route
};
}
}
});
|
comments powered by Disqus