UPDATE: There have been some changes in the JWT Gem that make some of the below not work exactly right (it’ll still be about 90% the same). Specifically, they added expiration support. See my post on the same topic, but using React.js. The server side code in this post will work just as well with Angular.
Overview
I’m a big proponent of rolling your own authentication solution, especially if you’re only doing simple username/password based logins (as opposed to logging in via an OAuth provider). I’ve tried to use Devise on a number of Rails apps, but I always end up ripping it out. It’s not because Devise is a bad gem, but because it always takes me more time to customize it to my liking than it does to just write everything myself. And the flexibility of a custom solution almost always comes in handy down the road. I have generally implemented it the same way that Ryan Bates does in this Railscasts episode.
But now that most of my greenfield projects are single page javascript apps, authentication has become slightly more complicated. While you can certainly continue doing traditional authentication with cookies and server-rendered views, my preference is to use a token-based approach. This has a number of benefits:
- The same authentication API can be used by all types of clients (web app, mobile app, etc).
- It is stateless, so the web server does not have to keep track of session information, which is good for scaling.
- Protected against CSRF (cross-site request forgery) attacks
- All of your views are rendered by the client, rather than a mix of server and client rendered views.
A relatively new standard for accomplishing this is JSON Web Tokens (abbreviated to JWT). I won’t dig into the details because there are plenty of good resources, but JWT is a way of digitally signing data to be transferred between two parties. The data is represented as an encoded JSON object. In a nutshell, these tokens are passed to the client upon successful authentication and then subsequently used in every HTTP request in order to verify the identity of the client.
Client/Server Data Flow
So the application flow will look something like this:
- Client sends username and password to server.
- If credentials are valid, the server generates a token that include’s the user’s ID inside the token payload. (Remember, this payload is not encrypted - the client can read it - so don’t put anything you don’t want the client to see)
- The token is returned to the client, who saves it somewhere for later use.
- When the client makes a request for protected data from the server, it includes the token in an HTTP header.
- Upon receiving a request for protected data, the server looks at the token and verifies that it was indeed generated for the user represented by the ID in the payload.
Server-Side Code
So let’s start with the server-side code and assume you already have a basic user model. We’ll first need some code to generate a JWT for a given user. So install the jwt gem into your Gemfile. Next, I found there to be fair amount of logic around the JWT auth tokens, so I extracted it into a simple AuthToken
class that takes care of encoding and decoding the tokens for us.
class AuthToken
def self.encode(payload, exp=24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, Rails.application.secrets.secret_key_base)
def self.decode(token)
payload = JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
DecodedAuthToken.new(payload)
rescue
nil # It will raise an error if it is not a token that was generated with our secret key or if the user changes the contents of the payload
end
end
# We could just return the payload as a hash, but having keys with indifferent access is always nice, plus we get an expired? method that will be useful later
class DecodedAuthToken < HashWithIndifferentAccess
def expired?
self[:exp] <= Time.now.to_i
end
end
And let’s add a helper method to our User
model that uses this class:
def generate_auth_token
payload = { user_id: self.id }
AuthToken.encode(payload)
end
Ok, now we need to take care of that initial authentication request in our Client/Server Data flow. The client sends a username/password combination and the server sends back a new token. So go ahead and create a new controller called AuthController
and add a new post
route to routes.rb
. You may also want to return some information about the current user inside the JSON response, but for now we’ll just return the auth token.
class AuthController < ApplicationController
skip_before_action :authenticate_request # this will be implemented later
def authenticate
user = User.find_by_credentials(params[:username], params[:password]) # you'll need to implement this
if user
render json: { auth_token: user.generate_auth_token }
else
render json: { error: 'Invalid username or password' }, status: :unauthorized
end
end
end
# in routes.rb:
post 'auth' => 'auth#authenticate'
Ok, now we need to add code to validate the token on subsequent requests. What we’ll do first is implement a few helper methods in our ApplicationController
that take care of decoding/validating the token and, based on the token payload, finding the current user. Then we’ll tie them all together in a a before filter/action. If the token is properly decoded and the user found, the request can be continued. If not, we’ll return a 401 Unauthorized
response.
class ApplicationController < ActionController::Base
before_action :set_current_user, :authenticate_request
rescue_from NotAuthenticatedError do
render json: { error: 'Not Authorized' }, status: :unauthorized
end
rescue_from AuthenticationTimeoutError do
render json: { error: 'Auth token is expired' }, status: 419 # unofficial timeout status code
end
private
# Based on the user_id inside the token payload, find the user.
def set_current_user
if decoded_auth_token
@current_user ||= User.find(decoded_auth_token[:user_id])
end
end
# Check to make sure the current user was set and the token is not expired
def authenticate_request
if auth_token_expired?
fail AuthenticationTimeoutError
elsif !@current_user
fail NotAuthenticatedError
end
end
def decoded_auth_token
@decoded_auth_token ||= AuthToken.decode(http_auth_header_content)
end
def auth_token_expired?
decoded_auth_token && decoded_auth_token.expired?
end
# JWT's are stored in the Authorization header using this format:
# Bearer somerandomstring.encoded-payload.anotherrandomstring
def http_auth_header_content
return @http_auth_header_content if defined? @http_auth_header_content
@http_auth_header_content = begin
if request.headers['Authorization'].present?
request.headers['Authorization'].split(' ').last
else
nil
end
end
end
end
You’ll also need to define the 2 errors that are being rescued from:
class NotAuthenticatedError < StandardError
end
class AuthenticationTimeoutError < StandardError
end
Client Side Code
That should take care of the server side. Next, we’ll need to add support for these API’s into our angular app. To do this, we’ll need to implement two pieces of code: An AuthService that will handle logging in followed by an HTTP interceptor that will automatically attach our auth token to every http request and handle auth-related error responses.
First, our AuthService. Note that there are two dependencies you’ll need to implement. First, AuthToken
is a simple service for storing the auth token in local storage while AuthEvents
is a constant with a few auth/login related events so we’re not using magic strings.
app.factory("AuthService", function($http, $q, $rootScope, AuthToken, AuthEvents) {
return {
login: function(username, password) {
var d = $q.defer();
$http.post('/api/auth', {
username: username,
password: password
}).success(function(resp) {
AuthToken.set(resp.auth_token);
$rootScope.$broadcast(AuthEvents.loginSuccess);
d.resolve(resp.user);
}).error(function(resp) {
$rootScope.$broadcast(AuthEvents.loginFailed);
d.reject(resp.error);
});
return d.promise;
}
};
});
You’ll need to implement a basic login form and controller that use this service.
Next, let’s add our two http interceptors. The first is quite simple. Just attach “Bearer” followed by the auth token. This is the standard format for adding a JWT to your http headers. The error interceptor is slightly more complicatated. First, we check to make sure this isn’t our intial auth request because we want that to handle errors on its own. Then, we check to see if the response code matches any of our auth-related codes. If so, we broadcast an appropriate event.
app.factory("AuthInterceptor", function($q, $injector) {
return {
// This will be called on every outgoing http request
request: function(config) {
var AuthToken = $injector.get("AuthToken");
var token = AuthToken.get();
config.headers = config.headers || {};
if (token) {
config.headers.Authorization = "Bearer " + token;
}
return config || $q.when(config);
},
// This will be called on every incoming response that has en error status code
responseError: function(response) {
var AuthEvents = $injector.get('AuthEvents');
var matchesAuthenticatePath = response.config && response.config.url.match(new RegExp('/api/auth'));
if (!matchesAuthenticatePath) {
$injector.get('$rootScope').$broadcast({
401: AuthEvents.notAuthenticated,
403: AuthEvents.notAuthorized,
419: AuthEvents.sessionTimeout
}[response.status], response);
}
return $q.reject(response);
}
};
});
app.config(function($httpProvider) {
return $httpProvider.interceptors.push("AuthInterceptor");
});
// Elsewhere....
$rootScope.$on(AuthEvents.notAuthorized, function() {
// ... Take some action in response to a 401
});
How you handle these auth error events will be up to you. The simplest solution is to just redirect the user to the login page. Or you may want to pop up a modal login form so that the user doesn’t lose his or her work.
Also, this naively assumes you’ll have a long session length and the user won’t mind logging in again at the end, even if they’ve been actively using it the whole time. In my app, the session timeout length is just 60 minutes. So I implemented a timer that, every x minutes, requests to reissue the token (thus pushing back the expiration date) so long as there had been recent user activity. I may share this code in a future blog post, but I figured it was out of scope for the time being.
I’d love to hear your feedback because, again, this was roughly extracted from my application and I’m not even sure it’s the best implementation. So let me know on twitter, where I’m @adam_albrecht, if you find any bugs or ways to improve the code. Thanks!