Using rails-api to build an authenticated JSON API with warden
An updated version of my previous tutorials on building an authenticated JSON API with Ruby on Rails
In this tutorial I will build a small web application that provides a JSON API to manage customers
through a REST interface. The requests to the endpoints will be authenticated through a token based authentication strategy, passing custom headers (X-User-Email
and X-Auth-Token
) containing the user’s credentials.
A sessions
endpoint is available to issue a new authentication token on login and disposing it on logout.
The goal of the tutorial is building the base of an up-to-date, well tested, minimal and functional backend API that can be used for clients such as Angular/Ember web apps or even Mobile applications. Take a look to the previous tutorials to have an idea of the differences with those examples.
Requirements
- Rails::API is a subset of a normal Rails application, created for applications that don’t require all functionality that a complete Rails application provides.
- Warden provides a mechanism for authentication in Rack based Ruby applications. It is used by many other libraries and gems, like Devise.
- RSpec is a gem to do behavior-driven development in Ruby (on Rails). We will use the Rails helpers for our models, controllers, routes and integration tests.
The complete code for this tutorial can be found on my Github account.
Setup
Let’s start by installing the rails-api gem and creating the app using the rails-api
command in the terminal.
gem install rails-api
rails-api new example_api --skip-turbolinks --skip-sprockets --skip-test-unit --skip-javascript
I issued the command with some options to skip unused functionalities like turbolinks and sprockets, since we will not have a “frontend”. I also disabled the default test-unit
framework since we will use RSpec for our tests.
Testing
Speaking of RSpec, let’s start the development of our new app by installing and configuring it. First add the gem and some companion gems like shoulda-matchers and the spring-commands-rspec to enable the usage of RSpec with Spring.
# file: Gemfile
source 'https://rubygems.org'
gem 'rails', '4.2.3'
gem 'rails-api'
gem 'sqlite3'
group :development do
gem 'spring'
gem 'spring-commands-rspec'
end
group :test do
gem 'shoulda-matchers', require: false
end
group :development, :test do
gem 'rspec-rails', '~> 3.0'
end
Install all the gems, run the installation of RSpec to generate the spec_helper.rb
and rails_helper.rb
files and finally create the bin/rspec
binstub.
bundle install
bin/rails generate rspec:install
bundle exec spring binstub rspec
For more information about Spring, refer to the official documentation.
Customers
Once we have our testing framework up and running, we can start by creating our first model. I will use a Customer
resource as an example to build the first (and only) endpoint. If you are building a real application, please create your model(s) accordingly.
Model
Use the rails generator to create the model and the migration. Our model will have three attributes: full_name
, email
and phone
. Remember to run the migration after the files are automatically generated.
bin/rails generate model customer full_name:string email:string phone:string
bin/rake db:migrate
Before starting to create our first spec, we need to add shoulda-matchers to the spec/rails_helper.rb
file. Add require 'shoulda/matchers'
at the beginning of the file:
# file: spec/rails_helper.rb
# This file is copied to spec/ when you run 'rails generate rspec:install'
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'spec_helper'
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
require 'shoulda/matchers'
# ...
I will use the shoulda-matchers
matchers to test our models, specifying which database columns (and attributes) it should have. I add also the matcher for the validation that the model is expected to have.
# file: spec/models/customer_spec.rb
require 'rails_helper'
RSpec.describe Customer, type: :model do
describe "db structure" do
it { is_expected.to have_db_column(:full_name).of_type(:string) }
it { is_expected.to have_db_column(:email).of_type(:string) }
it { is_expected.to have_db_column(:phone).of_type(:string) }
it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
end
describe "validations" do
it { is_expected.to validate_presence_of(:full_name) }
end
end
Run the specs with bin/rspec
and see them fail once. Add the Customer
model and relaunch the specs.
# file: app/models/customer.rb
class Customer < ActiveRecord::Base
validates_presence_of :full_name
end
bin/rspec
The output of the specs should be similar to this one:
$ bin/rspec
......
Finished in 0.02619 seconds (files took 0.49037 seconds to load)
6 examples, 0 failures
If everything looks fine, let’s start adding the first endpoint to our API.
Routing
Start by using again the rails generator to create the CustomersController
and related routing specs.
bin/rails generate controller customers index show create update destroy --no-view-specs --skip-routes
I will start by adding the routing specs to be sure that our controller is routable as expected.
# file: spec/routing/customers_routing_spec.rb
require 'rails_helper'
RSpec.describe CustomersController, type: :routing do
it { expect(get: "/customers").to route_to("customers#index") }
it { expect(get: "/customers/1").to route_to("customers#show", id: "1") }
it { expect(post: "/customers").to route_to("customers#create") }
it { expect(put: "/customers/1").to route_to("customers#update", id: "1") }
it { expect(delete: "/customers/1").to route_to("customers#destroy", id: "1") }
end
To make these specs pass we need to add the customers resource to the routes.rb
file.
# file: config/routes.rb
Rails.application.routes.draw do
resources :customers, only: [:index, :show, :create, :update, :destroy]
end
Launch again the bin/rspec
command and see the specs pass.
Controller
It’s time to add real value to our API. Let’s start by defining how our controller is expected to behave when is issued the canonical REST actions: index
, show
, create
, update
and destroy
.
# file: spec/controllers/customers_controller_spec.rb
require 'rails_helper'
RSpec.describe CustomersController, type: :controller do
# This should return the minimal set of attributes required to create a valid
# Customer. As you add validations to Customer, be sure to
# adjust the attributes here as well.
let(:valid_attributes) {
{ full_name: "John Doe", email: "john.doe@example.com", phone: "123456789" }
}
let(:invalid_attributes) {
{ full_name: nil, email: "john.doe@example.com", phone: "123456789" }
}
let!(:customer) { Customer.create(valid_attributes) }
describe "GET #index" do
it "assigns all customers as @customers" do
get :index, { format: :json }
expect(assigns(:customers)).to eq([customer])
end
end
describe "GET #show" do
it "assigns the requested customer as @customer" do
get :show, { id: customer.id, format: :json }
expect(assigns(:customer)).to eq(customer)
end
end
describe "POST #create" do
context "with valid params" do
it "creates a new Customer" do
expect {
post :create, { customer: valid_attributes, format: :json }
}.to change(Customer, :count).by(1)
end
it "assigns a newly created customer as @customer" do
post :create, { customer: valid_attributes, format: :json }
expect(assigns(:customer)).to be_a(Customer)
expect(assigns(:customer)).to be_persisted
end
end
context "with invalid params" do
it "assigns a newly created but unsaved customer as @customer" do
post :create, { customer: invalid_attributes, format: :json }
expect(assigns(:customer)).to be_a_new(Customer)
end
it "returns unprocessable_entity status" do
put :create, { customer: invalid_attributes }
expect(response.status).to eq(422)
end
end
end
describe "PUT #update" do
context "with valid params" do
let(:new_attributes) {
{ full_name: "John F. Doe", phone: "234567890" }
}
it "updates the requested customer" do
put :update, { id: customer.id, customer: new_attributes, format: :json }
customer.reload
expect(customer.full_name).to eq("John F. Doe")
expect(customer.phone).to eq("234567890")
end
it "assigns the requested customer as @customer" do
put :update, { id: customer.id, customer: valid_attributes, format: :json }
expect(assigns(:customer)).to eq(customer)
end
end
context "with invalid params" do
it "assigns the customer as @customer" do
put :update, { id: customer.id, customer: invalid_attributes, format: :json }
expect(assigns(:customer)).to eq(customer)
end
it "returns unprocessable_entity status" do
put :update, { id: customer.id, customer: invalid_attributes, format: :json }
expect(response.status).to eq(422)
end
end
end
describe "DELETE #destroy" do
it "destroys the requested customer" do
expect {
delete :destroy, { id: customer.id, format: :json }
}.to change(Customer, :count).by(-1)
end
it "redirects to the customers list" do
delete :destroy, { id: customer.id, format: :json }
expect(response.status).to eq(204)
end
end
end
The controller specs are long and detailed, but they are covering all the possible expectations about the controller. We are testing that our index action returns the right collection of Customer
objects, the show action retrieves the right object, create is persisting a new object only if it’s valid and update is modifying accordingly to the given new attributes. Finally we check that destroy actually deletes the given record from the database.
# file: app/controllers/customers_controller.rb
class CustomersController < ApplicationController
before_action :set_customer, only: [:show, :update, :destroy]
def index
@customers = Customer.all
render json: @customers
end
def show
render json: @customer
end
def create
@customer = Customer.new(customer_params)
if @customer.save
render json: @customer, status: :created, location: @customer
else
render json: @customer.errors, status: :unprocessable_entity
end
end
def update
if @customer.update(customer_params)
head :no_content
else
render json: @customer.errors, status: :unprocessable_entity
end
end
def destroy
@customer.destroy
head :no_content
end
private
def set_customer
@customer = Customer.find(params[:id])
end
def customer_params
params.require(:customer).permit(:full_name, :email, :phone)
end
end
The actual code for the controller is as simple as this: not so different from the one you can get from the rails scaffold command. We are returning the objects as JSON and rendering also the errors as JSON in case of failed validations or exceptions.
Adding some real data
We tested our controller with the specs, but it’s now time to see the actual result in action. Let’s add some fake data with the seeds.
# file: db/seeds.rb
[
{ full_name: "John Doe", email: "john.doe@example.com", phone: "033 1234 5678"},
{ full_name: "Mark Smith", email: "mark.smith@example.com", phone: "034 6789 1234"},
{ full_name: "Tom Clark", email: "tom.clark@example.com", phone: "033 4321 9876"},
{ full_name: "Sue Palmer", email: "sue.palmer@example.com", phone: "034 9876 1234"},
{ full_name: "Kate Lee", email: "kate.lee@example.com", phone: "033 6789 4321"}
].each do |customer_attributes|
Customer.create(customer_attributes)
end
Run the rake command to insert the seeds in your database and start the rails application server.
bin/rake db:seed
bin/rails server
Visit localhost:3000/customers.json to see the result:
[
{
id: 1,
full_name: "John Doe",
email: "john.doe@example.com",
phone: "033 1234 5678",
created_at: "2015-08-22T18:11:46.572Z",
updated_at: "2015-08-22T18:11:46.572Z"
}, {
id: 2,
full_name: "Mark Smith",
email: "mark.smith@example.com",
phone: "034 6789 1234",
created_at: "2015-08-22T18:11:46.584Z",
updated_at: "2015-08-22T18:11:46.584Z"
}, {
id: 3,
full_name: "Tom Clark",
email: "tom.clark@example.com",
phone: "033 4321 9876",
created_at: "2015-08-22T18:11:46.587Z",
updated_at: "2015-08-22T18:11:46.587Z"
}, {
id: 4,
full_name: "Sue Palmer",
email: "sue.palmer@example.com",
phone: "034 9876 1234",
created_at: "2015-08-22T18:11:46.591Z",
updated_at: "2015-08-22T18:11:46.591Z"
}, {
id: 5,
full_name: "Kate Lee",
email: "kate.lee@example.com",
phone: "033 6789 4321",
created_at: "2015-08-22T18:11:46.595Z",
updated_at: "2015-08-22T18:11:46.595Z"
}
]
You should see the JSON payload above as expected.
Using ActiveModel::Serializer to build the JSON response
Looking at the payload we just created, you can see that we don’t have control on which data we would like to expose. To do so, we will use the ActiveModel::Serializer gem, created as a companion of the rails-api project.
# file: Gemfile
gem 'active_model_serializers', github: 'rails-api/active_model_serializers'
Add the gem to Gemfile and bundle it. Use the rails generator provided to add the CustomerSerializer
model.
bundle install
bin/rails generate serializer customer
Add the attributes that we want to return as JSON:
# file: app/serializers/customer_serializer.rb
class CustomerSerializer < ActiveModel::Serializer
attributes :id, :full_name, :email, :phone
end
The payload should be now like the following one, without the timestamps.
[
{
id: 1,
full_name: "John Doe",
email: "john.doe@example.com",
phone: "033 1234 5678"
}, {
id: 2,
full_name: "Mark Smith",
email: "mark.smith@example.com",
phone: "034 6789 1234"
}, {
id: 3,
full_name: "Tom Clark",
email: "tom.clark@example.com",
phone: "033 4321 9876"
}, {
id: 4,
full_name: "Sue Palmer",
email: "sue.palmer@example.com",
phone: "034 9876 1234"
}, {
id: 5,
full_name: "Kate Lee",
email: "kate.lee@example.com",
phone: "033 6789 4321"
}
]
Adding better errors on exceptions
Our endpoint is getting better and better, but we will encounter some issues when some of the common exceptions are raised, like 404 on record not found or strong_parameters is returning ActionController::ParameterMissing
: the API will return normal html without a clear way to understand the error from the client perspective. To fix this we will add some specs to define that all our controllers will behave accordingly in those cases, rescuing the exceptions and return well formatted JSON.
Let’s add first a shared example to our specs. Start by enabling the usage of the spec/support
directory where wil create our shared example file.
# file: spec/rails_helper.rb
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
The spec will be used in controllers and defines that in case of ActiveRecord::RecordNotFound
or ActionController::ParameterMissing
, the payload will be JSON with the correct status code and error message.
# file: spec/support/api_controller.rb
require 'rails_helper'
RSpec.shared_examples "api_controller" do
describe "rescues from ActiveRecord::RecordNotFound" do
context "on GET #show" do
before { get :show, { id: 'not-existing', format: :json } }
it { expect(response.status).to eq(404) }
it { expect(response.body).to be_blank }
end
context "on PUT #update" do
before { put :update, { id: 'not-existing', format: :json } }
it { expect(response.status).to eq(404) }
it { expect(response.body).to be_blank }
end
context "on DELETE #destroy" do
before { delete :destroy, { id: 'not-existing', format: :json } }
it { expect(response.status).to eq(404) }
it { expect(response.body).to be_blank }
end
end
describe "rescues from ActionController::ParameterMissing" do
context "on POST #create" do
before { post :create, { wrong_params: { foo: :bar }, format: :json } }
it { expect(response.status).to eq(422) }
it { expect(response.body).to match(/error/) }
end
end
end
Add to the customer controller specs the shared example with the it_behaves_like
method call.
# file: spec/controllers/customer_controller_spec.rb
require 'rails_helper'
RSpec.describe CustomersController, type: :controller do
it_behaves_like "api_controller"
# ...
end
If you run the specs now, they will fail because we haven’t define yet the code to rescue the two exceptions. Add the following code to the application_controller.rb
:
# file: app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :missing_param_error
def not_found
render status: :not_found, json: ""
end
def missing_param_error(exception)
render status: :unprocessable_entity, json: { error: exception.message }
end
end
By running again the bin/rspec
command, the specs should pass as expected and our customers endpoint will return proper JSON on exceptions.
Authentication
The second part of the tutorial will add an authentication layer to the API. Let’s start by building a service that can issue secure tokens to an existing user in order to use them to make authenticated requests to the protected endpoints.
Usually Ruby on Rails applications rely on complex setups to provide authentication (and user account management), typically using Devise as a go-to solution. In this tutorial I would like to implement something from scratch, building the simplest working and secure authentication system possible, starting from the tools that Rails provide by default like has_secure_password and has_secure_token (a backport of a functionality from Rails 5) and packing everything with a custom build strategy with Warden.
Setup
Let’s start by adding the gems needed for the first iteration.
# file: Gemfile
gem 'bcrypt', '~> 3.1.7'
gem 'has_secure_token'
bcrypt is needed to use the has_secure_password
feature from Rails and has_secure_token
will enable the automatic generation of secure and unique token in our service object.
bundle install
Install the gems with the bundle command.
Token Issuer
We are going to create a service object to issue new authentication tokens for our logged in user, in order to have multiple authenticated sessions for a given user and being able to log out each one of them without affecting the others. Usually the authentication token is saved in the user record, enabling only one sessions at the time, since logging out and resetting the token will de facto log out every other sessions.
Let’s start by enabling the autoload of files in the app/services
directory.
# file: config/application.rb
# ...
module ExampleApi
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers
# -- all .rb files in that directory are automatically loaded.
config.autoload_paths += %W(#{config.root}/app/services/**/)
# ...
end
end
Let’s then create the specs for our TokenIssuer
class. Its responsibilities are to create and return tokens (the model will come later) and purge the expired tokens of a user.
# file: spec/services/token_issuer_spec.rb
require 'rails_helper'
RSpec.describe TokenIssuer, type: :model do
let(:resource) {
double(:resource, id: 1,
authentication_tokens: authentication_tokens) }
let(:authentication_tokens) {
double(:authentication_tokens, create!: authentication_token) }
let(:authentication_token) {
double(:authentication_token, body: "token") }
let(:request) {
double(:request, remote_ip: "100.10.10.23", user_agent: "Test Browser") }
describe ".create_and_return_token" do
it "creates a new token for the user" do
expect(resource.authentication_tokens).to receive(:create!)
.with(last_used_at: DateTime.current,
ip_address: request.remote_ip,
user_agent: request.user_agent)
.and_return(authentication_token)
described_class.create_and_return_token(resource, request)
end
it "returns the token body" do
allow(resource.authentication_tokens).to receive(:create!)
.and_return(authentication_token)
expect(described_class.create_and_return_token(resource, request)).to eq("token")
end
end
describe ".purge_old_tokens" do
it "deletes all the user's tokens" do
expect(resource.authentication_tokens).to receive_message_chain(:order, :offset, :destroy_all)
described_class.purge_old_tokens(resource)
end
end
end
The implementation of the class is as follow. The basic functionality is to create and return a new AuthenticationToken
for a user, to find a token between the user’s tokens and finally destroy an expired token.
A constant MAXIMUM_TOKENS_PER_USER
overridable on initialization of the service sets how many tokens a user can keep active whenever the purge is called. This method can be used in a cron job in order to keep the unused tokens at bay.
# file: app/services/token_issuer.rb
class TokenIssuer
MAXIMUM_TOKENS_PER_USER = 20
def self.build
new(MAXIMUM_TOKENS_PER_USER)
end
def self.create_and_return_token(resource, request)
build.create_and_return_token(resource, request)
end
def self.expire_token(resource, request)
build.expire_token(resource, request)
end
def self.purge_old_tokens(resource)
build.purge_old_tokens(resource)
end
def initialize(maximum_tokens_per_user)
self.maximum_tokens_per_user = maximum_tokens_per_user
end
def create_and_return_token(resource, request)
token = resource.authentication_tokens.create!(
last_used_at: DateTime.current,
ip_address: request.remote_ip,
user_agent: request.user_agent)
token.body
end
def expire_token(resource, request)
find_token(resource, request.headers["X-Auth-Token"]).try(:destroy)
end
def find_token(resource, token_from_headers)
resource.authentication_tokens.detect do |token|
token.body == token_from_headers
end
end
def purge_old_tokens(resource)
resource.authentication_tokens
.order(last_used_at: :desc)
.offset(maximum_tokens_per_user)
.destroy_all
end
private
attr_accessor :maximum_tokens_per_user
end
User and AuthenticationToken models
Let’s create now the User
and AuthenticationToken
models.
bin/rails generate model authentication_token body:string user:references last_used_at:datetime ip_address:string user_agent:string
bin/rails generate model user email:string password_digest:string
# file: db/migrate/XXX_create_authentication_tokens.rb
class CreateAuthenticationTokens < ActiveRecord::Migration
def change
create_table :authentication_tokens do |t|
t.string :body
t.references :user, index: true, foreign_key: true
t.datetime :last_used_at
t.string :ip_address
t.string :user_agent
t.timestamps null: false
end
end
end
I added an index on the email
attribute since we will look for users through the email while doing the authentication.
# file: db/migrate/XXX_create_users.rb
class CreateUsers < ActiveRecord::Migration
def change
create_table :users do |t|
t.string :email, index: :email
t.string :password_digest
t.timestamps null: false
end
end
end
# file: spec/models/authentication_token_spec.rb
require 'rails_helper'
RSpec.describe AuthenticationToken, type: :model do
describe "db structure" do
it { is_expected.to have_db_column(:user_id).of_type(:integer) }
it { is_expected.to have_db_column(:body).of_type(:string) }
it { is_expected.to have_db_column(:ip_address).of_type(:string) }
it { is_expected.to have_db_column(:user_agent).of_type(:string) }
it { is_expected.to have_db_column(:last_used_at).of_type(:datetime) }
it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
it { is_expected.to have_db_index(:user_id) }
end
describe "associations" do
it { is_expected.to belong_to(:user) }
end
end
# file: app/models/authentication_token.rb
class AuthenticationToken < ActiveRecord::Base
belongs_to :user
has_secure_token :body
end
# file: spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
describe "db structure" do
it { is_expected.to have_db_column(:email).of_type(:string) }
it { is_expected.to have_db_column(:password_digest).of_type(:string) }
it { is_expected.to have_db_column(:created_at).of_type(:datetime) }
it { is_expected.to have_db_column(:updated_at).of_type(:datetime) }
it { is_expected.to have_db_index(:email) }
end
describe "associations" do
it { is_expected.to have_many(:authentication_tokens) }
end
describe "secure password" do
it { is_expected.to have_secure_password }
it { is_expected.to validate_length_of(:password) }
it { expect(User.new({ email: "user@email.com", password: nil }).save).to be_falsey }
it { expect(User.new({ email: "user@email.com", password: "foo" }).save).to be_falsey }
it { expect(User.new({ email: "user@email.com", password: "af3714ff0ffae" }).save).to be_truthy }
end
end
# file: app/models/user.rb
class User < ActiveRecord::Base
has_many :authentication_tokens
has_secure_password
validates :password, length: { minimum: 8 }
end
The specs and the models are simple and we are just testing the attributes, validations an the has_secure_password
and has_secure_token
features.
Adding Warden to handle authentication
We are coming to the core of our authentication layer. I was inspired by this tutorial by Oliver Brisse, so all the credits are due to him.
Start by adding the Warden gem and install it.
# file: Gemfile
gem 'warden'
bundle install
Let’s add an initializer to require and load our new strategy and setup the middleware.
# file: initializers/warden.rb
require 'authentication_token_strategy'
Warden::Strategies.add(:authentication_token, AuthenticationTokenStrategy)
Rails.application.config.middleware.insert_after ActionDispatch::ParamsParser, Warden::Manager do |manager|
manager.default_strategies :authentication_token
manager.failure_app = UnauthenticatedController
end
Authentication Token Strategy
We now just need to create the authentication token strategy specs and class.
# file: spec/lib/authentication_token_strategy_spec.rb
require 'rails_helper'
RSpec.describe AuthenticationTokenStrategy, type: :model do
let!(:user) {
User.create(email: "user@example.com", password: "password") }
let!(:authentication_token) {
AuthenticationToken.create(user_id: user.id, body: "token", last_used_at: DateTime.current) }
let(:env) {
{ "HTTP_X_USER_EMAIL" => user.email,
"HTTP_X_AUTH_TOKEN" => authentication_token.body } }
let(:subject) { described_class.new(nil) }
describe "#valid?" do
context "with valid credentials" do
before { allow(subject).to receive(:env).and_return(env) }
it { is_expected.to be_valid }
end
context "with invalid credentials" do
before { allow(subject).to receive(:env).and_return({}) }
it { is_expected.not_to be_valid }
end
end
describe "#authenticate!" do
context "with valid credentials" do
before { allow(subject).to receive(:env).and_return(env) }
it "returns success" do
expect(User).to receive(:find_by)
.with(email: user.email)
.and_return(user)
expect(TokenIssuer).to receive_message_chain(:build, :find_token)
.with(user, authentication_token.body)
.and_return(authentication_token)
expect(subject).to receive(:success!).with(user)
subject.authenticate!
end
it "touches the token" do
expect(subject).to receive(:touch_token)
.with(authentication_token)
subject.authenticate!
end
end
context "with invalid user" do
before { allow(subject).to receive(:env)
.and_return({ "HTTP_X_USER_EMAIL" => "invalid@email",
"HTTP_X_AUTH_TOKEN" => "invalid-token" }) }
it "fails" do
expect(User).to receive(:find_by)
.with(email: "invalid@email")
.and_return(nil)
expect(TokenIssuer).not_to receive(:build)
expect(subject).not_to receive(:success!)
expect(subject).to receive(:fail!)
subject.authenticate!
end
end
context "with invalid token" do
before { allow(subject).to receive(:env)
.and_return({ "HTTP_X_USER_EMAIL" => user.email,
"HTTP_X_AUTH_TOKEN" => "invalid-token" }) }
it "fails" do
expect(User).to receive(:find_by)
.with(email: user.email)
.and_return(user)
expect(TokenIssuer).to receive_message_chain(:build, :find_token)
.with(user, "invalid-token")
.and_return(nil)
expect(subject).not_to receive(:success!)
expect(subject).to receive(:fail!)
subject.authenticate!
end
end
end
end
As you can see, the strategy uses the valid?
and authenticate!
methods to check if the parameters (our two custom headers) are present and if the user exists and has a valid token.
# file: lib/authentication_token_strategy.rb
class AuthenticationTokenStrategy < ::Warden::Strategies::Base
def valid?
user_email_from_headers.present? && auth_token_from_headers.present?
end
def authenticate!
failure_message = "Authentication failed for user/token"
user = User.find_by(email: user_email_from_headers)
return fail!(failure_message) unless user
token = TokenIssuer.build.find_token(user, auth_token_from_headers)
if token
touch_token(token)
return success!(user)
end
fail!(failure_message)
end
def store?
false
end
private
def user_email_from_headers
env["HTTP_X_USER_EMAIL"]
end
def auth_token_from_headers
env["HTTP_X_AUTH_TOKEN"]
end
def touch_token(token)
token.update_attribute(:last_used_at, DateTime.current) if token.last_used_at < 1.hour.ago
end
end
One last touch is updating the token every time the user uses it to authenticate itself in order to keep it between the non-expirable tokens.
Controllers
With the strategy up and running, we need now to add some helpers to the application controller in order to require the authentication to all the actions we want to restrict access to.
I will create a controller concern with some helper methods and a before_action prepended to all the actions that will try to authenticate the user with the provided credentials (if any) present in the request.
# file: app/controllers/concerns/warden_helper.rb
module WardenHelper
extend ActiveSupport::Concern
included do
helper_method :warden, :current_user
prepend_before_action :authenticate!
end
def current_user
warden.user
end
def warden
request.env['warden']
end
def authenticate!
warden.authenticate!
end
end
By including the concern in the ApplicationController
we assure that all the controllers that inherit from it will require authentication.
# file: app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include WardenHelper
# ...
end
As you could see in the initializer, we delegate to a special UnauthenticatedController
controller the handling of failed authentications.
It will respond with a 401 status code and an error message to all the requests that don’t satisfy the authentication.
# file: app/controllers/unauthenticated_controller.rb
class UnauthenticatedController < ActionController::Metal
def self.call(env)
@respond ||= action(:respond)
@respond.call(env)
end
def respond
self.status = :unauthorized
self.content_type = "application/json"
self.response_body = { errors: ["Unauthorized Request"] }.to_json
end
end
In order to test the authentication layer, we need to add some helpers to configure RSpec with Warden. I had some issues and found a solution in a StackOverflow post:
# file: spec/support/warden.rb
# Based on http://stackoverflow.com/questions/13420923/configuring-warden-for-use-in-rspec-controller-specs
module Warden
# Warden::Test::ControllerHelpers provides a facility to test controllers in isolation
# Most of the code was extracted from Devise's Devise::TestHelpers.
module Test
module ControllerHelpers
def self.included(base)
base.class_eval do
setup :setup_controller_for_warden, :warden if respond_to?(:setup)
end
end
# Override process to consider warden.
def process(*)
# Make sure we always return @response, a la ActionController::TestCase::Behavior#process, even if warden interrupts
_catch_warden {super} || @response
end
# We need to setup the environment variables and the response in the controller
def setup_controller_for_warden
@request.env['action_controller.instance'] = @controller
end
# Quick access to Warden::Proxy.
def warden
@warden ||= begin
manager = Warden::Manager.new(nil, &Rails.application.config.middleware.detect{|m| m.name == 'Warden::Manager'}.block)
@request.env['warden'] = Warden::Proxy.new(@request.env, manager)
end
end
protected
# Catch warden continuations and handle like the middleware would.
# Returns nil when interrupted, otherwise the normal result of the block.
def _catch_warden(&block)
result = catch(:warden, &block)
if result.is_a?(Hash) && !warden.custom_failure? && !@controller.send(:performed?)
result[:action] ||= :unauthenticated
env = @controller.request.env
env['PATH_INFO'] = "/#{result[:action]}"
env['warden.options'] = result
Warden::Manager._run_callbacks(:before_failure, env, result)
status, headers, body = warden.config[:failure_app].call(env).to_a
@controller.send :render, status: status, text: body,
content_type: headers['Content-Type'], location: headers['Location']
nil
else
result
end
end
end
end
end
RSpec.configure do |config|
config.include Warden::Test::ControllerHelpers, type: :controller
end
Just add this code in a spec/support/warden.rb
file and you will be fine.
Let’s then add another shared example to gather the common specs for an authenticated controller.
# file: spec/support/authenticated_api_controller.rb
require 'rails_helper'
RSpec.shared_examples "authenticated_api_controller" do
describe "authentiation" do
it "returns unauthorized request without email and token" do
request.env["HTTP_X_USER_EMAIL"] = nil
request.env["HTTP_X_AUTH_TOKEN"] = nil
get :index, { format: :json }
expect(response.status).to eq(401)
end
it "returns unauthorized request without token" do
user = User.create(email: "user@example.com", password: "password")
request.env["HTTP_X_USER_EMAIL"] = user.email
request.env["HTTP_X_AUTH_TOKEN"] = nil
get :index, { format: :json }
expect(response.status).to eq(401)
end
end
end
By adding a before block where we create a user and its token and set them in the headers, we can now test that our customers controller specs are still passing.
Add the shared example as well to be sure that the controller respects the authentication strategy whenever it’s not valid.
# file: spec/controllers/customers_controller_spec.rb
require 'rails_helper'
RSpec.describe CustomersController, type: :controller do
before do
user = User.create(email: "user@example.com", password: "password")
authentication_token = AuthenticationToken.create(user_id: user.id,
body: "token", last_used_at: DateTime.current)
request.env["HTTP_X_USER_EMAIL"] = user.email
request.env["HTTP_X_AUTH_TOKEN"] = authentication_token.body
end
it_behaves_like "api_controller"
it_behaves_like "authenticated_api_controller"
# ...
end
Adding a sessions controller to login users
Our last step in order to see some real results for our API is adding a way for the user to log in and receive a valid token to authenticate subsequent requests to the endpoints.
Let’s add some routing with specs for the sessions controller:
# file: spec/routing/sessions_routing_spec.rb
require 'rails_helper'
RSpec.describe SessionsController, type: :routing do
it { expect(post: "/sessions").to route_to("sessions#create") }
it { expect(delete: "/sessions").to route_to("sessions#destroy") }
end
# file: config/routes.rb
Rails.application.routes.draw do
resource :sessions, only: [:create, :destroy]
resources :customers, only: [:index, :show, :create, :update, :destroy]
end
And write the controller specs for the create (login) and destroy (logout) actions:
# file: spec/controllers/sessions_controller_spec.rb
require 'rails_helper'
RSpec.describe SessionsController, type: :controller do
let!(:user) { User.create(email: "user@example.com", password: "password") }
let!(:authentication_token) { AuthenticationToken.create(user_id: user.id,
body: "token", last_used_at: DateTime.current) }
let(:valid_attributes) {
{ user: { email: user.email, password: "password" } }
}
let(:invalid_attributes) {
{ user: { email: user.email, password: "not-the-right-password" } }
}
let(:parsed_response) { JSON.parse(response.body) }
def set_auth_headers
request.env["HTTP_X_USER_EMAIL"] = user.email
request.env["HTTP_X_AUTH_TOKEN"] = authentication_token.body
end
before do
allow(TokenIssuer).to receive(:create_and_return_token).and_return(authentication_token.body)
end
describe "POST #create" do
context "with valid credentials" do
before { post :create, valid_attributes, format: :json }
it { expect(response).to be_success }
it { expect(parsed_response).to eq({ "user_email" => user.email, "auth_token" => authentication_token.body }) }
end
context "with invalid credentials" do
before { post :create, invalid_attributes, format: :json }
it { expect(response.status).to eq(401) }
end
context "with missing/invalid params" do
before { post :create, { foo: { bar: "baz" }, format: :json } }
it { expect(response.status).to eq(422) }
end
end
describe "DELETE #destroy" do
context "with valid credentials" do
before do
set_auth_headers
delete :destroy, { format: :json }
end
it { expect(response).to be_success }
end
context "with invalid credentials" do
before { delete :destroy, { format: :json } }
it { expect(response.status).to eq(401) }
end
end
end
The implementation uses the TokenIssuer
to create and return a new token for the user with valid credentials while logging in and again uses the service to get rid of the expired token on logout.
As you might notice, I skip the authenticate!
before_action on the create action since we want to enable the user to make this request while not being authenticated for obvious reasons.
The destroy (logout) action instead would still require the user to provide the authentication credentials to be executed.
# file: app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate!, only: [:create]
def create
user = User.find_by(email: session_params[:email])
if user && user.authenticate(session_params[:password])
token = TokenIssuer.create_and_return_token(user, request)
render status: :ok, json: { user_email: user.email, auth_token: token }
else
render status: :unauthorized, json: ""
end
end
def destroy
TokenIssuer.expire_token(current_user, request) if current_user
render status: :ok, json: ""
end
private
def session_params
params.require(:user).permit(:email, :password)
end
end
Let’s add an example user to our seeds and then test with curl the working login and an authenticated request to the customers endpoint.
# file: db/seeds.rb
# ...
User.create(email: "admin@example.com", password: "password")
curl -i -X POST -H "Content-Type:application/json" -d '{ "user": { "email": "admin@example.com", "password": "password" } }' http://localhost:3000/sessions.json
{"user_email":"admin@example.com","auth_token":"m5d2eADqgZ5pX7aE4daSkevg"}
If you received the valid token after logging in, you can now request the list of customers providing the user email and token through the custom headers:
curl -i -X GET -H "Content-Type:application/json" -H "X-User-Email:admin@example.com" -H "X-Auth-Token:m5d2eADqgZ5pX7aE4daSkevg" http://localhost:3000/customers.json
If everything is working properly, you should receive the payload we created some steps ago. Congratulations!
Cross-origin resource sharing (CORS)
The previous step marks the last piece of the standard functionalities that I would require from a simple API application. I would still add one bonus step in order to make sure that our API can be used in client web applications through AJAX calls. In order to do this, the browser expects the API to provide Cross-origin resource sharing (CORS) headers. You can find more information about them on Wikipedia.
The most important thing is that all the responses of our application will contain these additional headers and a special route that respond to OPTIONS
HTTP requests is present.
Let’s start by adding the config.action_dispatch.default_headers
to the config/application.rb
file. I use constants to set the headers so we can reuse them in other places centralizing the setup.
# file: config/application.rb
# ...
CORS_ALLOW_ORIGIN = "*"
CORS_ALLOW_METHODS = %w{GET POST PUT OPTIONS DELETE}.join(',')
CORS_ALLOW_HEADERS = %w{Content-Type Accept X-User-Email X-Auth-Token}.join(',')
module ExampleApi
class Application < Rails::Application
# ...
config.action_dispatch.default_headers = {
"Access-Control-Allow-Origin" => CORS_ALLOW_ORIGIN,
"Access-Control-Allow-Methods" => CORS_ALLOW_METHODS,
"Access-Control-Allow-Headers" => CORS_ALLOW_HEADERS
}
end
end
I also added to the api_controller
shared example in the support directory, some specs that check the presence of those headers in all the responses.
# file: spec/support/api_controller.rb
require 'rails_helper'
RSpec.shared_examples "api_controller" do
# ...
describe "responds to OPTIONS requests to return CORS headers" do
before { process :index, 'OPTIONS' }
context "CORS requests" do
it "returns the Access-Control-Allow-Origin header to allow CORS from anywhere" do
expect(response.headers['Access-Control-Allow-Origin']).to eq('*')
end
it "returns general HTTP methods through CORS (GET/POST/PUT/DELETE)" do
%w{GET POST PUT DELETE}.each do |method|
expect(response.headers['Access-Control-Allow-Methods']).to include(method)
end
end
it "returns the allowed headers" do
%w{Content-Type Accept X-User-Email X-Auth-Token}.each do |header|
expect(response.headers['Access-Control-Allow-Headers']).to include(header)
end
end
end
end
end
Since the Warden fail_app is not using the default_headers set by our Rails application, we need to manually set again the CORS header in the UnauthenticatedController
respond method.
# file: app/controllers/unauthenticated_controller.rb
class UnauthenticatedController < ActionController::Metal
# ...
def respond
self.status = :unauthorized
self.content_type = "application/json"
self.response_body = { errors: ["Unauthorized Request"] }.to_json
self.headers["Access-Control-Allow-Origin"] = CORS_ALLOW_ORIGIN
self.headers["Access-Control-Allow-Methods"] = CORS_ALLOW_METHODS
self.headers["Access-Control-Allow-Headers"] = CORS_ALLOW_HEADERS
end
end
Finally we create a “catch all” route (be sure it’s at bottom of your list) that will respond to every OPTIONS
HTTP request with only the CORS headers.
In this way the browser pre-flight request to enable the usage of our API will be fulfilled and we will allow the requests as we like. Please customize the CORS header constants accordingly.
# file: config/routes.rb
Rails.application.routes.draw do
# ...
match "/*path",
to: proc {
[
204,
{
"Content-Type" => "text/plain",
"Access-Control-Allow-Origin" => CORS_ALLOW_ORIGIN,
"Access-Control-Allow-Methods" => CORS_ALLOW_METHODS,
"Access-Control-Allow-Headers" => CORS_ALLOW_HEADERS
},
[]
]
}, via: [:options, :head]
end
Conclusion
It was a long tutorial and we just skimmed the surface of the topic. I will probably extend in the future the application with some more features and write other tutorials about them. For example I would like to use this API on an Angular.js web app to show how you can build a single-page application using these technologies.
You can find the complete repository for the tutorial on Github.
For any question or request, you can use the comments below or send an email to luca.tironi@gmail.com
Stay tuned and thanks for your time, I hope you will find this useful.
L
^Back to top