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