01 Feb, 2022

Backend

Rails 6

Ruby

Devise

Rails 6 API authentication with JWT and Devise gem

Rails 6 API authentication with JWT and Devise gem.

MROY Team

Environment:

rails -v
Rails 6.1.4.1
ruby -v
ruby 3.0.1p64 (2021-04-05 revision 0fb782ee38) [x86_64-darwin19]

Add to the Gemfile:

gem 'devise'

Then run bundle install.

Run generator:

rails generate devise:install

Check Devise Getting started section for configuration options:
https://github.com/heartcombo/devise#getting-started

It's also useful to read about Warden and Rack:
https://github.com/wardencommunity/warden/wiki
https://github.com/rack/rack

Next for each environment you'll need to set the default URL options for the Devise mailer. For example config > environments > development.rb:

# config > environments > development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
# config > environments > development.rb
Rails.application.routes.default_url_options[:host] = 'localhost'

In the next command replace MODEL with name used for the app’s users (it’s frequently User but could be something else). This command will create a migration and a model file (or just update if you have one already). The generator also updates your config/routes.rb to point to the Devise controller.

rails generate devise MODEL
rails generate devise User
Running via Spring preloader in process 64900
        invoke  active_record
        create    db/migrate/XXXXXXXXXXXXXX_devise_create_users.rb
        create    app/models/user.rb
        invoke    test_unit
        create      test/models/user_test.rb
        create      test/fixtures/users.yml
        insert    app/models/user.rb
        route  devise_for :users
# app > models > user.rb
class User < ApplicationRecord
    # Include default devise modules. Others available are:
    # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
end
# db > migrate > XXXXXXXXXXXXXX_devise_create_users.rb
class DeviseCreateUsers < ActiveRecord::Migration[6.1]
    def change
    create_table :users do |t|
        ## Database authenticatable
        t.string :email,              null: false, default: ""
        t.string :encrypted_password, null: false, default: ""

        ## Recoverable
        t.string   :reset_password_token
        t.datetime :reset_password_sent_at

        ## Rememberable
        t.datetime :remember_created_at

        ## Trackable
        # t.integer  :sign_in_count, default: 0, null: false
        # t.datetime :current_sign_in_at
        # t.datetime :last_sign_in_at
        # t.string   :current_sign_in_ip
        # t.string   :last_sign_in_ip

        ## Confirmable
        # t.string   :confirmation_token
        # t.datetime :confirmed_at
        # t.datetime :confirmation_sent_at
        # t.string   :unconfirmed_email # Only if using reconfirmable

        ## Lockable
        # t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
        # t.string   :unlock_token # Only if unlock strategy is :email or :both
        # t.datetime :locked_at


        t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    # add_index :users, :confirmation_token,   unique: true
    # add_index :users, :unlock_token,         unique: true
    end
end

Then migrate:

rails db:migrate
rails db:migrate
== XXXXXXXXXXXXXX DeviseCreateUsers: migrating ================================
-- create_table(:users)
    -> 0.0021s
-- add_index(:users, :email, {:unique=>true})
    -> 0.0010s
-- add_index(:users, :reset_password_token, {:unique=>true})
    -> 0.0007s
== XXXXXXXXXXXXXX DeviseCreateUsers: migrated (0.0044s) =======================

To add support for JWT (Devise doesn't have one) we'll use devise-jwt gem. Add it to your Gemfile:

gem 'devise-jwt', '~> 0.9.0'

Then run bundle install.

Add a secret key in the Devise initializer:

# config > initializers > devise.rb
Devise.setup do |config|
    ...
    config.secret_key = 'fbae6f84a060a84ca6bc52...'
    ...
end

You can generate secrets on the command line with bundle exec rake secret (this secret is used to generate signed JWT tokens).

We also need to add login/logout routes and token expiration date:

# config > initializers > devise.rb
Devise.setup do |config|
    # ...
    config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
    jwt.dispatch_requests = [
        ['POST', %r{^/login$}]
    ]
    jwt.revocation_requests = [
        ['DELETE', %r{^/logout$}]
    ]
    jwt.expiration_time = 1.day.to_i
    end
end

We also need to tell Devise not use navigational formats:

# config > initializers > devise.rb
config.navigational_formats = []

We will revoke JWT tokens using the DenyList strategy. We'll create a new table in our database to store expired tokens, and reference token from request to check if it is valid.

If you want to know more about revocation strategies you could read this article by the author of the gem devise-jwt.
Create migration for denylist:

rails generate migration CreateJwtDenylist
rails generate migration CreateJwtDenylist
        invoke  active_record
        create    db/migrate/XXXXXXXXXXXXXX_create_jwt_denylist.rb

The migration creates a table with an indexed string column which contains the expired token, together with an expired_at datetime column.

# db > migrate > XXXXXXXXXXXXXX_create_jwt_denylist.rb
class CreateJwtDenylist < ActiveRecord::Migration[6.1]
    def change
    create_table :jwt_denylist do |t|
        t.string :jti, null: false
        t.datetime :expired_at, null: false
    end
    add_index :jwt_denylist, :jti
    end
end

Run rails db:migrate:

== XXXXXXXXXXXXXX CreateJwtDenylist: migrating ================================
-- create_table(:jwt_denylist)
    -> 0.0021s
-- add_index(:jwt_denylist, :jti)
    -> 0.0010s
== XXXXXXXXXXXXXX CreateJwtDenylist: migrated (0.0033s) =======================

Create a model to implement the revocation strategy:

# app > models > jwt_denylist.rb
class JwtDenylist < ApplicationRecord
    include Devise::JWT::RevocationStrategies::Denylist

    self.table_name = 'jwt_denylist'
end

We need to tell User model that we're going to authenticate with JWT, and our revocation strategy:

# app > models > user.rb
class User < ApplicationRecord
    devise :database_authenticatable, :registerable, :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end

Install rack-cors to allow cross-origin requests

Rails blocks cross-origin requests by default. To access our API from a different origin, we need to install the rack-cors gem. Add it to your Gemfile:

gem 'rack-cors'

Then bundle install.

Let's create an initializer to address CORS issue. We allow all origins here, but you should limit origins in production. Read more about CORS here.

# config > initializers > cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
    allow do
    origins '*'

    resource '*',
        headers: :any,
        methods: [:get, :post, :put, :patch, :delete, :options, :head]
    end
end

Set up routes.rb

# config > routes.rb
Rails.application.routes.draw do
    resources :users, only: %i[index show]

    devise_for :users,
    path: '',
    path_names: {
        sign_in: 'login',
        sign_out: 'logout',
        registration: 'signup'
    },
    controllers: {
        sessions: 'sessions',
        registrations: 'registrations'
    }
end

We've set up custom paths for our endpoints (you could remove path and path_names keys to use Devise default paths users/sign_in, users/sign_out, users/sing_up).

Set up ApplicationController

# app > controllers > application_controller.rb
class ApplicationController < ActionController::API
    def render_resource(resource)
        if resource.errors.empty?
            render json: resource
        else
            validation_error(resource)
        end
    end

    def validation_error(resource)
        render json: {
            errors: [
            {
                status: '400',
                title: 'Bad Request',
                detail: resource.errors,
                code: '100'
            }
            ]
        }, status: :bad_request
    end
end

Set up RegistrationsController

We inherit from Devise's RegistrationsController and filter params with sign_up_params method. We attempt to save the user on signup, then return response with the render_resource method we've declared in ApplicationController.

# app > controllers > registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
    respond_to :json

    def create
        build_resource(sign_up_params)
        resource.save
        render_resource(resource)
    end

    private

    def sign_up_params
        params.require(:user).permit(:email, :password)
    end
end

The main reason for that is that on registration you usually want to send email, etc. So, you'll need to write you own action.

Set up SessionsController

# app > controllers > sessions_controller.rb
class SessionsController < Devise::SessionsController
    respond_to :json

    private

    def respond_with(resource, _opts = {})
        render json: resource
    end

    def respond_to_on_destroy
        head :no_content
    end
end

Set up UsersController (close endpoint)

# app > controllers > users_controller.rb
class UsersController < ApplicationController

    before_action :authenticate_user!
    before_action :find_user, only: %i[show]

    def index
        users = User.all
        render json: users
    end

    def show
        render json: @user
    end

    private

    def find_user
        @user = User.find(params[:id])
    end

end