External API Authentication in Rails using Devise and JWT Tokens

Scenario

We want to create an API for our Rails application which requires a user to first authenticate with their username and password to verify their identity, but subsequently, we wish to identify the user using a JWT token.

So what is a JWT anyway?

The advantage of using JWTs (or JSON Web Tokens) is that they are an industry standard (RFC 7519) method for representing claims securely between two parties. They are trustworthy because they are digitally signed and secure.JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token.

A typical JSON Web Token looks something like this (taken from the jwt.io website).

Our solution will focus on an HMAC solution. What’s more, the secret (which is used to encrypt the token) will change each and every time a user authenticates with the server. In this way, if the JWT does get into the wrong hands (you are using SSL, aren’t you?) simply re-authenticating will make the previous secret invalid and the JWT would become useless. However, you should still use safeguards to protect the token as much as possible and do not keep them longer than required.

After the secret is generated a token is generated and returned to the user, the client can send it back whenever accessing a protected resource or route (typically) in the Authorization header using the Bearer schema: Authorization: Bearer <token>

Because they’re small, portable and reliable; JWTs are becoming extremely popular for web-based API authentication and rapidly becoming the industry standard.

But what happens if the JWT is intercepted and stolen?

The short answer is that it’s really bad. But what makes it less bad than a username and password being compromised is that it can be immediately invalidated without requiring (or impacting) on directly on the user. Also, the token itself is only useful to an attacker for a limited time. Once the token expires, it becomes useless.

However, it should be noted that there are circumstances where a stolen JWT can actually be worse. This largely depends on how the token was obtained in the first place. If an attacker has successfully executed a man-in-the-middle attack, the hacker may be able to simply obtain a new token whenever required.

Basically, as always there is no silver bullet when it comes to security concerns, and you should always follow best practices and take your own server security into account. While this has worked safely for some time in my applications; this code has been modified and generalized to make this tutorial easier to understand, it should not be considered a complete and secure implementation for a production environment.

Enough with the disclaimer; get to the solution!

This is actually really easy to setup in Rails with Devise. There are 2 main components. The first is a special API session controller to handle the initial authentication. Since this will not be completed through the standard Rails for and devise controller, we need to make a controller to handle it. I recommending creating a specialized session controller to do this so that the API authentication is structurally separate from the rest of your application so that it can be isolated for security and testing purposes.

module Api
class SessionsController < Devise::SessionsController
skip_before_action :verify_authenticity_token

def create
self.resource = warden.authenticate!(auth_options)
sign_in(resource_name, resource)
self.resource.update_attributes(session_attributes)
respond_to do |format|
format.json {render json: {token: generate_token(self.resource)}}
end
end

def
destroy
current_user.update_attributes(shared_secret: nil, token_expires: nil)
super
end


private

def
generate_token(resource)
JWT.encode(token_payload(resource), resource.shared_secret, 'HS256')
end

def
token_payload(resource)
{user: resource.email, exp: 1.week.from_now}
end

def
session_attributes
{
shared_secret: create_secret,
token_expires: 1.week.from_now
}
end

def
create_secret
SecureRandom.alphanumeric(127)
end
end
end

It should be pretty self explanatory. It accepts an email and password and performs the same actions as a regular Devise controller. it signs the user in, and then updates the user record with a randomly generated secret and sets an expiry for the secret generation.

You also need to configure a route so you can post the email and password to this controller. Its a little more complicated than usual but not overwhelmiingly so:

namespace :api do
devise_for :users, skip: :all
devise_scope :user do
post 'users', to: 'sessions#create', as: nil
end
end

This technique of authenticating the user session can be used so you can determine how long it has been since the user ACTUALLY authenticated. You could write a rake task to automatically invalidate all secrets older than a specified duration. Our example will not go into the expiry of the token, but it should be easy for any experienced Rails dev.

Next the actual API controller class. This is the meat and potatoes. All your API controllers should inherit from this controller. What this will do is bypass the usual Devise authentication process and instead look at the request header for the Authentication Token. You must supply two values. An API Key that has been uniquely assigned to each user is paired with a valid Authentication Token. This is to help strengthen the JWT Token so that it is not solely responsible for the authentication (both the JWT and the API key must be compromised).

After the API Key and the JWT Authentication Token have been verified, the system will allow the continuation of the child controller action. Notice that the current_user and user_signed_in will be available as normal.

module Api
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_user!
before_action :authenticate_api_key!
before_action :authenticate_user_from_token!
protect_from_forgery with: :null_session


protected

def
current_user
@resource
end

def
user_signed_in?
!@resource.nil?
end


private


def
authenticate_user_from_token!
@resource ||= user_with_key(apikey_from_request).where(email: claims[0]['user']).first
if @resource.nil?
raise Pundit::NotAuthorizedError.new('Unable to deserialize JWT token.')
end
rescue
StandardError => e
Rails.logger.error e
raise Pundit::NotAuthorizedError.new(e)
end

def
authenticate_api_key!
if apikey_from_request.present?
unless user_with_key(apikey_from_request).present?
raise Pundit::NotAuthorizedError.new('Unable to verify the apii key.')
end
end
end

def
claims(token = token_from_request, key: shared_key)
JWT.decode(token, key, true)
rescue JWT::DecodeError => e
raise Pundit::NotAuthorizedError.new(e)
end

def
jwt_token(user, key: shared_key)
expires = (DateTime.now + 1.day).to_i
JWT.encode({user: user.email, exp: expires}, key, 'HS256')
end

def
token_from_request
# Accepts the token either from the header or a query var
# Header authorization must be in the following format
# Authorization: Bearer {yourtokenhere}
auth_header = request.headers['Authorization']
token = auth_header.try(:split, ' ').try(:last)
unless Rails.env.production?
if token.to_s.empty?
token = request.parameters.try(:[], 'token')
end
end
token
end

def
apikey_from_request
# Accepts the ApiKey either from the header or a query var
# Header
ApiKey must be in the following format
#
ApiKey: {yourkeyhere}
key = request.headers.try(:[], 'ApiKey').try(:split, ' ').try(:last)
if !Rails.env.production? && key.blank?
key = request.parameters.try(:[], 'apikey')
end
key
end

def
shared_key
user_secret.tap do |key|
raise Pundit::NotAuthorizedError.new('Unable to verify the secret.') if key.blank?
end
end

def
user_secret
return if apikey_from_request.nil?
user_with_key(userkey_from_request).first.try(:shared_secret)
end

def
user_with_key(key)
return if apikey_from_request.nil?
User.where(private_key: key).where('private_key_expires > ?', Time.zone.now)
end
end
end

There you go. You now have the basis of a pretty good API Authentication Layer for your Rails app!

A few points of note:

  • This code will allow you (for ease of testing) to supply both the JWT and the API Key as query string parameters when NOT in production mode. However, in production, the request MUST use the correct headers.
  • This code assumes the use of the most excellent Pundit gem and raises a Pundit::NotAuthorizedError if the authentication fails. If you use something else, like CanCan you will need to raise an error appropriate to your application.
  • You may want to expose the current_user and user_signed_in? as controller helpers, if you need to access them in your views, but in order to keep this somewhat minimalistic, I have omitted this.
  • If you want more information about the security implications of using JWTs for Authentication and how to mitigate security risks, this is an excellent resource.
  • If you need help learning more about JWTs or if you would like an online tool to help generate valid JWTs for testing JWT.io is a very neat website.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.