Skip to content
heartsentwined edited this page Oct 18, 2013 · 9 revisions

Devise


CHANGES This page has been updated for the 9.x branch. Legacy instructions are gone - clone this wiki repo and checkout the 8.x tag.


Deprecation: Devise 3.1 has deprecated token_authenticatable itself. This demo will work fine with previous versions. Read the explanation, and this temporary fix.

User model

Create a user devise model. Devise will, by default, use email as the identifier.

$ rails g devise user

The user model should validate the presence of the email field. In spec/models/user_spec.rb:

require 'spec_helper'

describe User do
  it { should validate_presence_of :email }
end

Start Guard, and watch your tests fail.

$ guard

Modify the migration file to enable token_authenticatable specific fields. db/migrate/(timestamp)_devise_create_users.rb:

class DeviseCreateUsers < ActiveRecord::Migration
  def change
    create_table(:users) do |t|
      # ...

      ## Token authenticatable
      t.string :authentication_token

      # ...
    end

    # ...

    add_index :users, :authentication_token, :unique => true
  end
end

Migrate the database, and prepare your test database.

$ rake db:migrate db:test:prepare

Modify the user model. app/models/user.rb:

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable,
         :token_authenticatable

  validates :email, presence: true
end

We have added :token_authenticatable to the list of devise modules, added the email field presence validation, and removed some clutter.

Tests should pass now.

Authentication API

We expect to have a POST create and a DELETE destroy route, as # and sign out API end points respectively. We will also wrap all our json api inside the /api namespace.

spec/controllers/api/sessions_controller_spec.rb:

require 'spec_helper'

describe Api::SessionsController do
  describe 'POST create' do
  end

  describe 'DELETE destroy' do
  end
end

We need to mock the user model, so make a Fabricator. spec/fabricators/user_fabricator.rb:

Fabricator(:user) do
  email { sequence(:email) { |i| "foo#{i}@example.com"} }
  password 'foobarbaz'
end

Add the mock user to the specs. spec/controllers/api/sessions_controller_spec.rb:

  let(:user) { Fabricate(:user) }

We want to ensure that the user has an authentication token.

  before { user.ensure_authentication_token! }

The # API should accept email and password as params.

On successful authentication:

  • it should return a 201 Created status code
  • it should return a JSON response, with the following fields:
    • auth_token: the authentication token
    • user_id: the corresponding authenticated user

Error handling:

  • missing params: it should return a 400 Bad Request status code
  • wrong params: it should return a 401 Unauthorized status code
  describe 'POST create' do
    context 'no param' do
      before { post :create }

      it 'returns http 400' do
        response.response_code.should == 400
      end
    end

    context 'wrong credentials' do
      before { post :create, email: user.email, password: '' }

      it 'returns http 401' do
        response.response_code.should == 401
      end
    end

    context 'normal email + password auth' do
      before { post :create, email: user.email, password: user.password }
      subject { JSON.parse response.body }

      it { should include 'user_id' }
      it { should include 'auth_token' }

      it 'returns http 201' do
        response.response_code.should == 201
      end
    end
  end

The sign out API should accept auth_token as param.

On successful sign out:

  • it should return a 200 OK status code
  • it should return a JSON response, with the following field:
    • user_id: the corresponding authenticated user

Error handling:

  • missing params: it should return a 400 Bad Request status code
  • wrong params: it should return a 401 Unauthorized status code
  describe 'DELETE destroy' do
    context 'no param' do
      before { delete :destroy }

      it 'returns http 400' do
        response.response_code.should == 400
      end
    end

    context 'wrong credentials' do
      before { delete :destroy, auth_token: '' }

      it 'returns http 401' do
        response.response_code.should == 401
      end
    end

    context 'normal auth token param' do
      before { delete :destroy, auth_token: user.authentication_token }
      subject { JSON.parse response.body }

      it { should include 'user_id' }

      it 'returns http 200' do
        response.response_code.should == 200
      end
    end
  end

For reference, a complete spec/controllers/api/sessions_controller_spec.rb:

require 'spec_helper'

describe Api::SessionsController do
  let(:user) { Fabricate(:user) }

  before { user.ensure_authentication_token! }

  describe 'POST create' do
    context 'no param' do
      before { post :create }

      it 'returns http 400' do
        response.response_code.should == 400
      end
    end

    context 'wrong credentials' do
      before { post :create, email: user.email, password: '' }

      it 'returns http 401' do
        response.response_code.should == 401
      end
    end

    context 'normal email + password auth' do
      before { post :create, email: user.email, password: user.password }
      subject { JSON.parse response.body }

      it { should include 'user_id' }
      it { should include 'auth_token' }

      it 'returns http 201' do
        response.response_code.should == 201
      end
    end
  end

  describe 'DELETE destroy' do
    context 'no param' do
      before { delete :destroy }

      it 'returns http 400' do
        response.response_code.should == 400
      end
    end

    context 'wrong credentials' do
      before { delete :destroy, auth_token: '' }

      it 'returns http 401' do
        response.response_code.should == 401
      end
    end

    context 'normal auth token param' do
      before { delete :destroy, auth_token: user.authentication_token }
      subject { JSON.parse response.body }

      it { should include 'user_id' }

      it 'returns http 200' do
        response.response_code.should == 200
      end
    end
  end
end

Base controller

We want to limit controller access to JSON only, so we force the format of all requests to json, and respond_to json only. This behavior would be common across all our apis, so let's group them in an Api::BaseController.

app/controllers/api/base_controller.rb:

module Api
  class BaseController < ApplicationController
    respond_to :json
    before_action :default_json

    protected

    def default_json
      request.format = :json if params[:format].nil?
    end
  end
end

Sessions controller scaffold

Our SessionsController will extend from our BaseController. Create a scaffold app/controllers/api/sessions_controller.rb:

module Api
  class SessionsController < BaseController
    def create
    end

    def destroy
    end
  end
end

Modify the routes. First we move the devise_for :users declaration into the :api namespace.

routes.rb:

EmberAuthRailsDemo::Application.routes.draw do
  namespace :api do
    devise_for :users
  end
end

We only want a subset of devise routes, so we'll default to generating no route from devise:

  namespace :api do
    devise_for :users, only: []
  end

Add back our session routes.

  namespace :api do
    # ...
    devise_scope :user do
      post   'sign_in'  => 'sessions#create'
      delete 'sign_out' => 'sessions#destroy'
    end
  end

For reference, a full config/routes.rb:

EmberAuthRailsDemo::Application.routes.draw do
  namespace :api do
    devise_for :users, only: []
    devise_scope :user do
      post   'sign_in'  => 'sessions#create'
      delete 'sign_out' => 'sessions#destroy'
    end
  end
end

#

First we need to find the user with the supplied credentials.

We will use User::find_for_database_authentication in conjunction with User#valid_password? to authenticate the user.

    def create
      @user = user_from_credentials
    end

    private

    def user_from_credentials
      if user = User.find_for_database_authentication(email: params[:email])
        if user.valid_password? params[:password]
          user
        end
      end
    end
  end

We will then use User#ensure_authentication_token! to generate an auth token for the user if it doesn't already have one. Rendering a response:

    def create
      @user = user_from_credentials # prev code

      @user.ensure_authentication_token!

      data = {
        user_id: @user.id,
        auth_token: @user.authentication_token
      }

      render json: data, status: 201
    end

Sign out

The sign out method is simpler. We will use the standard ActiveRecord method of find_by_* to retrieve our user, and user.reset_authentication_token! to clear the auth token.

    def destroy
      @user = User.find_by authentication_token: params[:auth_token]

      @user.reset_authentication_token!

      render json: { user_id: @user.id }, status: 200
    end

Error handling

Define helpers for our 400 Bad Request and 401 Unauthorized responses.

    private

    def missing_params
      render json: {}, status: 400
    end

    def invalid_credentials
      render json: {}, status: 401
    end

Add guard clauses against missing params.

    def create
      return missing_params unless (params[:email] && params[:password])
      # ...
    end

    def destroy
      return missing_params unless params[:auth_token]
      # ...
    end

Add guard clauses against invalid params.

    def create
      # ...
      @user = user_from_credentials # prev
      return invalid_credentials unless @user
      # ...
    end

    def destroy
      # ...
      @user = User.find_by authentication_token: params[:auth_token] # prev
      return invalid_credentials unless @user
      # ...
    end

Checkpoint

The completed SessionsController:

module Api
  class SessionsController < BaseController
    def create
      return missing_params unless (params[:email] && params[:password])

      @user = user_from_credentials
      return invalid_credentials unless @user

      @user.ensure_authentication_token!

      data = {
        user_id: @user.id,
        auth_token: @user.authentication_token
      }

      render json: data, status: 201
    end

    def destroy
      return missing_params unless params[:auth_token]

      @user = User.find_by authentication_token: params[:auth_token]
      return invalid_credentials unless @user

      @user.reset_authentication_token!

      render json: { user_id: @user.id }, status: 200
    end

    private

    def user_from_credentials
      if user = User.find_for_database_authentication(email: params[:email])
        if user.valid_password? params[:password]
          user
        end
      end
    end

    def missing_params
      render json: {}, status: 400
    end

    def invalid_credentials
      render json: {}, status: 401
    end
  end
end

Tests should pass.

Continue to Model.

Clone this wiki locally