JSON Web Token (JWT) and HTML Logins with Devise and Ruby on Rails 5

1. Install devise-jwt

2. Configure Devise & Warden for JWT

# config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
jwt.dispatch_requests = [
['POST', %r{^/api/login$}],
['POST', %r{^/api/login.json$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/api/logout$}],
['DELETE', %r{^/api/logout.json$}]
]
jwt.expiration_time = 1.day.to_i
jwt.request_formats = { api_user: [:json] }
end
config.skip_session_storage = [:http_auth]
config.navigational_formats = ['*/*', :html, :json]
  • dispatch_requests: the endpoint (URL/route) for logins, where devise-jwt will issue new authentication tokens. I want JSON users to visit /api/login to authenticate.
  • revocation_requests: the endpoint for logouts, where devise-jwt will revoke authentication tokens.
  • request_formats: the format of incoming requests that devise-jwt should pay attention to. In my app, I only want JSON web tokens to be issued for JSON requests (you could also add nilto the array, which means unspecified format). For HTML requests, I want users to go to the web interface at a different endpoint, “/signin”, so if someone visits /api/login with an HTML request instead of JSON, I want devise-jwt to ignore it.
# config/initializers/warden_auth.rb
Warden::JWTAuth.configure do |config|
config.secret = ENV['DEVISE_JWT_SECRET_KEY']
config.dispatch_requests = [
['POST', %r{^/api/login$}],
['POST', %r{^/api/login.json$}]
]
config.revocation_requests = [
['DELETE', %r{^/api/logout$}],
['DELETE', %r{^/api/logout.json$}]
]
end

3. Configure API login/logout routes

# config/routes.rb# Devise (login/logout) for HTML requests
devise_for :users, defaults: { format: :html },
path: '',
path_names: { sign_up: 'register' },
controllers: {
sessions: 'sessions',
registrations: 'registrations',
confirmations: 'confirmations'
}
devise_scope :user do
get 'sign_in', to: 'devise/sessions#new'
get 'register', to: 'devise/registrations#new'
post 'register', to: 'devise/registrations#create'
delete 'sign_out', to: 'devise/sessions#destroy'
get 'confirmation/sent', to: 'confirmations#sent'
get 'confirmation/:confirmation_token', to: 'confirmations#show'
patch 'confirmation', to: 'confirmations#create'
end
# API namespace, for JSON requests at /api/sign_[in|out]
namespace :api do
devise_for :users, defaults: { format: :json },
class_name: 'ApiUser',
skip: [:registrations, :invitations,
:passwords, :confirmations,
:unlocks],
path: '',
path_names: { sign_in: 'login',
sign_out: 'logout' }
devise_scope :user do
get 'login', to: 'devise/sessions#new'
delete 'logout', to: 'devise/sessions#destroy'
end
...
end

4. Update the User table and model

# db/migrations/20190626011110_add_jti_to_users
class AddJtiToUsers < ActiveRecord::Migration
def change
add_column :users, :jti, :string
# populate jti so we can make it not nullable
User.all.each do |user|
user.update_column(:jti, SecureRandom.uuid)
end
change_column_null :users, :jti, false
add_index :users, :jti, unique: true
end
end
# app/models/user.rb
class User < ApplicationRecord
devise :registerable, :database_authenticatable,
:recoverable, :rememberable, :trackable, :validatable,
:lockable, :confirmable, :invitable
...

before_create :add_jti
def add_jti
self.jti ||= SecureRandom.uuid
end
...
end

5. Add an ApiUser model, as a sub-class of User

# app/models/api_user.rb
class ApiUser < User
include Devise::JWT::RevocationStrategies::JTIMatcher
devise :jwt_authenticatable, jwt_revocation_strategy: self
validates :jti, presence: true def generate_jwt
JWT.encode({ id: id,
exp: 5.days.from_now.to_i },
Rails.env.devise.jwt.secret_key)
end
end

6. Update ApplicationController

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception, unless: :json_request?
protect_from_forgery with: :null_session, if: :json_request?
skip_before_action :verify_authenticity_token, if: :json_request?
rescue_from ActionController::InvalidAuthenticityToken,
with: :invalid_auth_token
before_action :set_current_user, if: :json_request?
private
def json_request?
request.format.json?
end
# Use api_user Devise scope for JSON access
def authenticate_user!(*args)
super and return unless args.blank?
json_request? ? authenticate_api_user! : super
end
def invalid_auth_token
respond_to do |format|
format.html { redirect_to sign_in_path,
error: 'Login invalid or expired' }
format.json { head 401 }
end
end
# So we can use Pundit policies for api_users
def set_current_user
@current_user ||= warden.authenticate(scope: :api_user)
end

7. Override API SessionsController

# app/controllers/api/sessions_controller.rb
class Api::SessionsController < Devise::SessionsController
skip_before_action :verify_signed_out_user
respond_to :json
# POST /api/login
def create
unless request.format == :json
sign_out
render status: 406,
json: { message: "JSON requests only." } and return
end
# auth_options should have `scope: :api_user`
resource = warden.authenticate!(auth_options)
if resource.blank?
render status: 401,
json: { response: "Access denied." } and return
end
sign_in(resource_name, resource)
respond_with resource, location:
after_sign_in_path_for(resource) do |format|
format.json { render json:
{ success: true,
jwt: current_token,
response: "Authentication successful"
}
}
end
end
private def current_token
request.env['warden-jwt_auth.token']
end
end

8. Add “new” view in json format

Testing

curl -i -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' --data '{"api_user": {"email":"user@email.com","password":"the password"}}' http://127.0.0.1/api/login.json
TOKEN=eyJhbGcJzdWIiOiIxIiwic2NwIjoiYXBpX3.eyJqdGkiOiI2MWEwMjUwMS03NWNiLTQ4N2EtODU3Zi04YzU0ZmJkOTU2ZWEiLCVzZXIiLCJhdWQiOm51bGwsImlhdCI6MTU2NDY5MjUxMSwiZXhwIjoxNTY0Nzc4OTExfQ.7YflMWzsmrrS7mYqUGeB2a83G3UlpbhHBwA-EmjSfsAcurl -H 'Accept: application/json' -H "Authorization: Bearer ${TOKEN}" http://127.0.0.1/events/19wF000/memberships.json

Conclusion

Resources

--

--

--

Systems integrator, software developer, future gazer.

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Jump Higher With Trampoline

imageedit_5_4560060907

Meet Twake, A Modern Open-Source Collaboration Platform [Nextcloud Alternative]

Twake Document Storage

Extend AWS Cloud Formation capability with Custom resource

Developer Most Popular Language, Database, Platform and Web Frame Work

Happy Birthday Dune Network !

Kube ‘CaB’ — Add-on services in kubernetes to make it ‘Cheap’ & ‘Balanced’

Learning How to Read the Weather with Sandi Metz

What Can Service Mesh Learn from SDN?

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Brent Kearney

Brent Kearney

Systems integrator, software developer, future gazer.

More from Medium

How to Develop Linux Applications (Part 1)

Which PHP Framework Is Right for Your Application

Build A Logger With Node.js

A pile of logs

An Ubuntu Environment with Node.js