DEV Community

Cover image for Advanced Usages of Devise for Rails
Aestimo K. for AppSignal

Posted on • Originally published at blog.appsignal.com

Advanced Usages of Devise for Rails

In part one of this series, we introduced Devise using an example app to explore modules, helpers, views, controllers, and routes.

In this part, we'll explore more advanced usages of Devise, specifically the use of OmniAuth, API authentication, and Authtrail.

Let's dive straight in!

Authentication with OmniAuth for Ruby

Nowadays, almost every web application you come across will offer you the option to log in via a wide selection of authentication providers, ranging from social networks like Twitter and Facebook to Google, GitHub, and many more.

In many cases, this convenient multi-provider authentication is powered by a library called OmniAuth. OmniAuth is a flexible and powerful authentication library for Ruby that allows you to integrate with multiple external providers.

It provides a simple and unified API for connecting to various OAuth providers. OmniAuth is particularly useful in situations where you want to give your users the option to sign up or log in using their social media accounts. With OmniAuth, you can easily add social login functionality to your Rails application.

When OmniAuth is used alongside the Devise gem, it becomes even easier to manage user authentication and authorization. You can take advantage of Devise's built-in authentication features, while using OmniAuth for external provider sign-in options.

Getting Started with OmniAuth and Devise

As mentioned, OmniAuth allows you to integrate with a number of third-party authentication providers. For the purposes of this article, we'll use GitHub.

Install OmniAuth Gems

In your app's Gemfile, add the following lines:

# Gemfile

gem 'omniauth'
gem 'omniauth-github' # each provider will have their own gem to work with Omniauth
Enter fullscreen mode Exit fullscreen mode

And if you're using the OmniAuth 2.0+ gem, you'll also need to add:

#  Gemfile

gem 'omniauth-rails_csrf_protection'
Enter fullscreen mode Exit fullscreen mode

The omniauth-rails_csrf_protection gem ensures that any GET requests to the OAuth flow are disabled. It also inserts a Rails CSRF token verifier before the OAuth request phase. These two actions are meant to mitigate cross-site forgery attacks aimed at the OAuth authentication flow.

Next, run bundle install to install the gems.

Create a New GitHub OAuth App

Now we need to create a new OAuth app on GitHub. This app will act as a user with authentication permissions which can be easily revoked, if needed.

The first step is to go to the settings page under your GitHub account profile. Then, on the left-hand menu, click on "Developer settings". You'll see a screen like the one below, where you can create a new OAuth app:

GitHub developer settings page

When you click on "Register new application", you'll get a screen like this one:

Create new OAuth app

Fill in the form details as follows:

  • Application name - Give your new OAuth app an appropriate name.
  • Homepage url - For now, use http://localhost:3000/. In production, you would use your app's actual homepage url.
  • Application description - Not necessary, but you can still have one if you have many apps and need to differentiate between them.
  • Authorization callback url - This is required input and will generally follow an OAuth callback url format like http://<app-url>/users/auth/<application-provider>/callback. That said, some OAuth providers like Google may not follow this format, so you need to take note of that.

With that done, click on "Register application". In the following screen, generate a new app secret and note it down someplace safe (as it will only be shown to you once).

Configure the Devise Initializer

Open up the Devise initializer config/initializers/devise.rb and navigate to the OmniAuth section specific to GitHub. It will likely be commented out, so uncomment it and edit it with your new GitHub OAuth app's ID and secret:

# config/initializers/devise.rb

...
  config.omniauth :github, ENV['GITHUB_APP_ID'], ENV['GITHUB_APP_SECRET'], scope: 'user,public_repo'
...
Enter fullscreen mode Exit fullscreen mode

Create the OmniAuth Callbacks Controller

If you have already generated Devise controllers, you'll find the OmniauthCallbacksController ready for you to customize accordingly. If yours isn't there, just create one manually, and edit it as follows:

# app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  # You can easily add more OmniAuth providers here
  ...

  def github
    @user = User.from_omniauth(request.env["omniauth.auth"])
    sign_in_and_redirect @user # sign_in_and_redirect is an OAuth method
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Some things to note about the above code:

  • from_omniauth is a method we'll implement inside our User model.
  • sign_in_and_redirect is a method inside of OAuth.

Add a Migration to Modify the User Model

We now need to add some columns to the User model, specifically the provider column and a uid column:

bundle exec rails g migration AddOmniauthToUsers provider:string uid:string
Enter fullscreen mode Exit fullscreen mode

Run bundle exec rails db:migrate to finalize this step.

Make the Devise Model Omniauthable

Here, we'll need to edit our User model by adding the Devise Omniauthable module to it:

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable
  ...
end
Enter fullscreen mode Exit fullscreen mode

After this, add the from_omni_auth method. This will be called from the Users::OmniauthCallbacksController that we just set up:

# app/models/user.rb

class User < ApplicationRecord
  ...

  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user |
      user.provider = auth.provider
      user.uid = auth.uid
      user.email = auth.info.email
      user.password = Devise.friendly_token[0,20]
    end
  end

  ...

end
Enter fullscreen mode Exit fullscreen mode

Now we're left with just one more thing: adding the login links to our Devise views.

Setup the Login Links

By default, Devise will automatically add the appropriate provider's login link for you in the user registration and login views. This link will use the GET method, but we know that OmniAuth 2.0+ prefers POST requests. So, we need to disable the link and insert our own using POST requests:

# app/views/users/sessions/new.html.erb

# notice we use a button_to which results in POST requests by default

<%= button_to "Sign in with Github", user_github_omniauth_authorize_path %>
Enter fullscreen mode Exit fullscreen mode

With that, we've successfully set up a Ruby on Rails 7 app with Devise and GitHub OAuth authentication. You can grab the full source code of the companion app here.

Next, we'll get into another advanced use case: using Devise to authenticate an API call.

API Authentication with Devise for Ruby

Today, it's not uncommon for users to expect to be able to connect to your app via an API. In this section, we'll look at how we can safely authenticate such user requests using Devise.

Where browser-based authentication is generally cookie-based, most API authentication happens via tokens called JSON Web Tokens (or simply JWTs), which are passed around in the headers.

Tip: For the purposes of this section, we'll assume we're working with a Rails API-only app. To follow along, create one with rails new app_name --api

The JWT-based Authentication Flow

As we mentioned, API authentication is based on JWT tokens, and it's important that we understand how a JWT-based authentication flow happens. Basically, it follows a general sequence of steps as outlined below:

  • A user client makes a call to the API app.
  • The API app responds with a JSON Web Token (JWT), an authentication token that can be used in place of cookies.
  • Subsequent requests by the user client are made using this token in the Authorization header.
  • The user can then hit the Devise 'session destroy' action, which results in the destruction of the token and the user being logged out.

Now, let's put this flow into action, starting with what is termed cross-origin resource sharing (CORS).

Setting Up CORS

CORS sets up our API app to allow requests from external sources. CORS is a HTTP-based security policy that defines how external requests will be handled by your application. By default, CORS will block any request that originates from a domain different to the one that made the initial request (in other words, a request coming from a different "origin").

To handle CORS properly, we'll use the nifty gem rack-cors. In the Gemfile, uncomment the line below, then run bundle install:

# Gemfile
gem 'rack-cors'
Enter fullscreen mode Exit fullscreen mode

Also, open up a corresponding CORS initializer file and modify it as below:

# 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],
      expose: %w[Authorization Uid]
  end
end
Enter fullscreen mode Exit fullscreen mode

Some important notes about what we've just done:

  • origins "*" - Simply means that our API app can now receive requests from any other source.
  • expose: %w[Authorization Uid] - By default, the rack-cors gem doesn't expose the authorization and UID headers, but we need them since we'll be passing authorization tokens through.

With that done, let's install Devise and the accompanying Devise-JWT gem.

Add Devise and Devise-JWT Gems to Your Rails App

The devise-jwt gem is an extension of Devise that will allow us to work with JWT tokens. Add the gems to Gemfile, then run bundle install:

# Gemfile

gem 'devise'
gem 'devise-jwt'
Enter fullscreen mode Exit fullscreen mode

Run the Devise install generator bundle exec rails g devise:install.

Generate and Configure the Models

We need to set up two models: the normal Devise user model (bundle exec rails g devise User), and a model that we'll use for the revocation strategy (in other words, how a user will sign out of the API):

bundle exec rails g model jwt_revocation
Enter fullscreen mode Exit fullscreen mode

We modify the normal Devise user model for API authentication by adding the JWT token authenticatable module and defining the token revocation strategy to use our second model, JwtDenylist:

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
  :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
Enter fullscreen mode Exit fullscreen mode

Next, we configure our second model by referencing the revocation strategy and the revocation table to be used:

# app/models/jwt_denylist.rb

class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist
  self.table_name = 'jwt_denylist'
end
Enter fullscreen mode Exit fullscreen mode

In the following section, we'll outline token revocation and why we need it.

The Importance of Token Revocation

Why is token revocation important? Because JWT tokens are stateless. The server knows nothing about them other than signing them. In such a scenario, the server has no way to sign out a user by revoking the corresponding token. Since there's no way to revoke individual tokens, we need to build one out and tell the server to use it.

When revoking a token, what's really happening under the hood is that a unique piece of the token, the jti (JWT ID), is extracted and used according to the defined revocation strategy.

Of course, this raises another question of what a token revocation strategy is. In a nutshell, it's a definition of how token revocation will be handled by your server. There are three basic revocation strategies:

  • JTIMatcher strategy - Here, a unique column called "jti" is added to the user model, which also acts as the revocation table. Whenever a user makes a request, the jti in the header is matched against the stored tokens and access is allowed only when a match is found.
  • Denylist strategy - For this one, the jti and the token expiry (exp of revoked tokens) are stored in a database table. For every request made by a user, a check is made against this table comparing the user's current token jti against the revoked ones in the database. If a match is found, that user's requests are denied.
  • Allowlist strategy - In a way, this strategy is similar to the first strategy, except that here the table storing the JWT IDs is in a one-to-many relationship with another table storing the user tokens. Whenever a request is made, the user's jti stored in the Allowlist table is matched against what is stored in the matching table with tokens. Access is only allowed if a match is found.

Obviously, this is a very simplified overview of token revocation, and you can learn more about it here.

Set Up a Signing Key for JWT Tokens

Since we'll be using secure tokens to authenticate users and their requests, we need a way of signing them. This is where our secret key comes in. It is recommended that you generate a new key different from the Rails secret key, the secret_key_base.

Run bundle exec rake secret to generate a unique key, then edit the Devise initializer to include this key:

# config/initializers/devise.rb
Devise.setup do |config|
  ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET']
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Finally, let's set up the controllers.

Controller Set Up

The final step to implement Devise for API authentication is to set up our controllers. For the sake of simplicity, we'll set up two controllers: one to handle registration and the other one for sessions.

Let's create these manually, starting with the registrations controller:

# app/controllers/users/registrations/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController

 respond_to :json

 private

 def respond_with(resource, _opts={})
  successful_registration && return if resource.persisted?
  failed_registration
 end

 def successful_registration
  render json: { message: "You've registered successfully", user: current_user }, status: :ok
 end

 def failed_registration
  render json: { message: "Something went wrong. Please try again" }, status: :unprocessable_entity
 end

end
Enter fullscreen mode Exit fullscreen mode

Here's what's going on with this controller:

  • We configure it to respond to requests with JSON.
  • We specify a respond_with action that returns the result of a successful or a failed registration.

And now for the sessions controller:

# app/controllers/users/sessions/sessions_controller.rb

class Users::SessionsController < Devise::SessionsController

 respond_to :json

 private

 def respond_with(resource, _opts={})
  render json: { message: "Welcome, you're in", user: current_user }, status: :ok
 end

 def respond_to_on_destroy
  successful_logout && return if current_user
  failed_logout
 end

 def successful_logout
  render json: { message: "You've logged out" }, status: :ok
 end

 def failed_logout
  render json: { message: "Something went wrong." }, status: :unauthorized
 end

end
Enter fullscreen mode Exit fullscreen mode

Just like the registrations controller, here we specify that the controller will respond with JSON. We also define a respond_with action for when a user successfully logs in and a respond_to_on_destroy to handle a user signing out.

And with that, you should have a working API authentication flow powered by Devise and JWT tokens!

Our final section will take a quick look at how you can track user logins using Devise and Authtrail.

Tracking Devise Logins with Authtrail

Let's say you want to send your app users a notification email whenever someone logs in to their account, with details such as the IP address and the timestamp of the login. How can you accomplish this?

You'll need to track user logins, then use that information in the notification emails you send your users. But first, you'll need a way of tracking user logins. This can be accomplished using a nifty gem called Authtrail, which also pairs well with Devise.

Installing Authtrail

The first step is to install the gem with bundle add authtrail. Additionally, since you'll be storing user-identifiable information such as emails and IP addresses in your app database, it's highly recommended that you encrypt this data in production using a combination of Lockbox and Blindindex gems.

Next, run the Authtrail generator to create its initializer and an accompanying table migration that will store the login data:

# Notice here we run the generator with the encryption flag set to 'lockbox', you can also indicate --encryption=none for no encryption

bundle exec rails g authtrail:install --encryption=lockbox
bundle exec rails db:migrate
Enter fullscreen mode Exit fullscreen mode

How Authtrail Works

Whenever a user tries to log in, a new Authtrail record is created with the following important details:

  • The login email address used
  • Whether the login was successful or not
  • The reason a login failed (if the login was a failure)
  • The user's IP address, referrer, and a lot more

You can then use this information as you wish. For example, you can send a notification email to a user, including the email and IP address information, to let them know that a login attempt was made on their account.

Go through Authtrail's documentation to see all the possibilities available to you.

Wrapping Up

In this series, we took a deep dive into the Devise gem.

First, we got to grips with the basics of Devise, including how its modules, helpers, views, controllers, and routes work. In this second and final part, we explored how to use Devise with OAuth, Authtrail, and for API authentication.

Hopefully, this series will serve as a helpful guide for all things Devise authentication.

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)