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
And if you're using the OmniAuth 2.0+ gem, you'll also need to add:
# Gemfile
gem 'omniauth-rails_csrf_protection'
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:
When you click on "Register new application", you'll get a screen like this one:
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'
...
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
Some things to note about the above code:
-
from_omniauth
is a method we'll implement inside ourUser
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
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
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
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 %>
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'
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
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, therack-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'
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
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
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
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 tokenjti
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
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
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
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
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)