- Build an easy to use full-featured option to handle User Auth with a Rails API
$ rails new devise_auth_app --api --database=postgresql
Once everything is installed move into the rails projectcd devise_auth_app
let's install the required gems
gem 'devise'
gem 'jwt'
# we also need to uncomment
gem 'rack-cors'
and now run$ bundle install
Now that we have the required gems in our app, let's run the devise installer.
$ rails g devise:install
when running this, you will get this output in the console, don't worry about any of this now.
===============================================================================
Some setup you must do manually if you haven't yet:
1. Ensure you have defined default url options in your environments files. Here
is an example of default_url_options appropriate for a development environment
in config/environments/development.rb:
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
In production, :host should be set to the actual host of your application.
2. Ensure you have defined root_url to *something* in your config/routes.rb.
For example:
root to: "home#index"
3. Ensure you have flash messages in app/views/layouts/application.html.erb.
For example:
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
4. You can copy Devise views (for customization) to your app by running:
rails g devise:views
===============================================================================
One excellent feature of Devise is that it builds a full User model for you out of the box and can also be edited if needed. In only one line!
$ rails g devise User
Open your db/migrations/
folder, and you will see the user migration that was generated by Devise.
# frozen_string_literal: true
class DeviseCreateUsers < ActiveRecord::Migration[6.0]
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
For now, let's leave this alone; if you want to add more values, check out the Devise documentation.
$ rails db:migrate
At this point, we need to set up our API with rack-cors, and we need to build our Devise Strategy to use Warden and JWT
./config/initializers/cors.rb
# Be sure to restart your server when you modify this file.
# Avoid CORS issues when API is called from the frontend app.
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests.
# Read more: https://github.com/cyu/rack-cors
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: %i[get post put patch delete options head]
end
end
./config/initializers/devise.rb
## around line 268 ##
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
config.warden do |manager|
# manager.intercept_401 = false
manager.strategies.add :jwt, Devise::Strategies::JWT
manager.default_strategies(scope: :user).unshift :jwt
end
And at the end of the file, we can place this code.
### Taking from GoRails.com ###
module Devise
module Strategies
class JWT < Base
def valid?
request.headers['Authorization'].present?
end
def authenticate!
token = request.headers.fetch('Authorization', '').split(' ').last
payload = JsonWebToken.decode(token)
success! User.find(payload['sub'])
rescue ::JWT::ExpiredSignature
fail! 'Auth token has expired'
rescue ::JWT::DecodeError
fail! 'Auth token is invalid'
end
end
end
end
That is all the configuration we have to handle for now. Let's just add a few controllers and API endpoints.
we can start $ rails s
and make sure there are no errors
let's configure the routes that the basic API will have
`./config/routes.rb``
Rails.application.routes.draw do
devise_for :users, controllers: { registrations: 'registrations' }
namespace :api do
namespace :v1 do
get 'post/index'
post :auth, to: 'authentication#create'
get '/auth' => 'authentication#fetch'
end
namespace :v2 do
# Things yet to come
end
end
end
The line devise_for
builds a bunch of devise endpoints, most of which we will not use but reference the Devise docs for more information.
And namespacing allows us to version our API without errors.
Now to configure ./app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
skip_before_action :verify_authenticity_token
end
We need to modify this class to inherit from ActionController::Base
instead of ::API
due to Devise
and since we did that, Rails now expects all requests to come from special Rails forms the have built-in Auth tokens, so we need to skip that check app-wide.
Since we are not are using ::Base
, we can make our API controller that the rest of our app can inherits from.
$ touch ./app/controllers/api_controller.rb
class ApiController < ApplicationController
before_action :set_default_format
before_action :authenticate_user!
private
def set_default_format
request.format = :json
end
end
This file set our default response to JSON, and I chose to lock every endpoint in our API behind the fact that the user is logged in.
We also need to build a RegistrationController that will be used to register new users to our site. I build it this way for a few workarounds for Devise.
$ touch ./app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
respond_to :json
def create
@user = User.new(sign_up_params)
if @user.save
render json: @user
else
render json: { errors: @user.errors.full_messages }
end
end
private
def sign_up_params
params.permit(:email, :password, :password_confirmation)
end
end
Now we can build the controllers inside our API
$ touch ./app/controllers/api/v1/post_controller.rb
class Api::V1::PostController < ApiController
before_action :authenticate_user!
def index
puts current_user.email
render json: { user: current_user.email, list: [1, 2, 3] }
end
end
And we still need to build the JsonWebToken class we can do that in our models' folder
$ touch ./app/models/json_web_token.rb
class JsonWebToken
def self.encode(payload)
expiration = 2.weeks.from_now.to_i
JWT.encode payload.merge(exp: expiration), Rails.application.credentials.fetch(:secret_key_base)
end
def self.decode(token)
JWT.decode(token, Rails.application.credentials.fetch(:secret_key_base)).first
end
end
At this point, if we have the rails server running, we can pass a GET request to the API and see what happens. In Postman I sent a GET request to http://localhost:3000/api/v1/post/index
If everything is working correctly, we will get the response of
{
"error": "You need to sign in or sign up before continuing."
}
If this works, you have everything but the last piece of the puzzle working! The only thing left would be to create a User and then sign in with them, which will return a JWT that you can then place in LocalStorage in your front-end application.
We still need to create a last controller that will handle the two routes we placed in our ./config/routes.rb
file.
post :auth, to: 'authentication#create'
get '/auth' => 'authentication#fetch'
The first will allow the user to sign in, and the second will return all of the data from the currently signed-in users. Maybe for a user profile page.
$ touch ./app/controller/api/v1/authentication_controller.rb
class Api::V1::AuthenticationController < ApiController
skip_before_action :authenticate_user!, only: [:create]
def create
user = User.find_by(email: params[:email])
puts user
if user&.valid_password?(params[:password])
render json: { token: JsonWebToken.encode(sub: user.id) }
else
render json: { errors: 'invalid' }
end
end
def fetch
render json: current_user
end
end
Okay, That's it. Let's test everything out.
first, in Postman we can create a user
POST to http://localhost:3000/users
{
"email": "[email protected]",
"password": "1234567"
}
this should return a new user
{
"id": 4,
"email": "[email protected]",
"created_at": "2020-03-06T13:35:05.903Z",
"updated_at": "2020-03-06T13:35:05.903Z"
}
I set this up so that once a user signs up, they will need to sign in. A great to have if you want a user to confirm their email and adds extra security.
So now, we send a POST to http://localhost:3000/api/v1/auth
with the user's email and password.
{
"email": "[email protected]",
"password": "1234567"
}
and this will return your JWT
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQsImV4cCI6MTU4NDcxMTQ1M30.nbN-8A964zmM4PnDj8DuioqhSYWmBAF-Sj2Uhppim2E"
}
Save this in Local Storage and make sure to pass this token in your headers every time you make an Axios request. If there is any place you do not need a user to be signed in throw skip_before_action :authenticate_user!, only: [:action_to_skip_here]