Authentication with OAuth2 - Login
Part 20 of building a Rails 7 application
This application will need authentication of some description as part of its requirements (namely tracking who is doing what in the app). There are several paths I could have gone down for this, from a full featured authentication system to a bare bones method with no internal user management at all.
The full featured options include the de-facto standard in the Rails community, a gem called "Devise". But there are others, such as "clearance", "rodauth" and "authlogic". All of these allow for multiple ways to authenticate, such as username/password or OAuth2. Most also have other enterprise level add-ons for applying other business logic to authentication, such as password expiration, forgotten passwords, user confirmation and so on.
My requirements don't lend itself to using any of these particular authentication gems. I only need to authenticate the user and do not have any other requirements bar only allowing users from one particular organisation to authenticate. So I'm opting to use a very simple User
class and OAuth2 to log in to it.
The gem I have chosen to do this is called "OmniAuth". There were some other possible options include "Doorkeeper", or as mentioned previously, one of the full featured gems that have OAuth2 capability.
This could be quite a large piece of work so I am breaking up this blog into 2 parts, starting with Login and finishing with Logout.
To get started I need to choose an OAuth2 provider, and the simplest one that makes the most sense for this application is to use Google, especially as it already has an organisation set up that I can use to restrict users. There are other documents that go into detail on how to go about this but I basically need to set up an application at Google and retrieve the credentials to access it. These will be needed by my application.
I need to store these credentials securely and my go to for this kind of thing is the "DotEnv" gem. For now I will create the simplest possible set up, a .env
file. Later I can expand on this to cater for production, staging and any other environments needed.
.env
GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxx
GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxx
There are three gems I need to add as well:
Gemfile
# Authentication
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
And then OmniAuth needs to be inserted as middleware into the application. I can do this by creating an initializer. It uses the credentials that are stored in the .env
file.
config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET']
end
A callback route is also required so I need to add that. This is the URL that will be called after the user successfully logs in to Google.
config/routes.rb
Rails.application.routes.draw do
# Authentication
get '/auth/:provider/callback' => 'sessions#omniauth'
...
end
I've specified that it should go to a SessionsController
and call a method called omniauth
. This is what that looks like:
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def omniauth
if user.valid?
session[:user_id] = user.id
redirect_to redirect_path_after_sign_in
else
redirect_to :root
end
end
private
def user
@user ||= User.from_omniauth(request.env['omniauth.auth'])
end
def redirect_path_after_sign_in
session[:user_return_to].presence || dashboard_path
end
end
Using the auth details from the callback I attempt to find a User
, how this works I'll get to in a moment. If the user is valid the id is set into the session cookie so the user will stay logged in. After that the user is redirected to either the page they were attempting to visit or to a "Dashboard" page which at this stage is just a controller and an empty page. To make the non-dashboard redirect work I am capturing the path the user was visiting prior to needing to authenticate. Let's have a look at that in more detail.
app/controllers/concerns/authenticated.rb
module Authenticated
extend ActiveSupport::Concern
included do
before_action :snaffle_current_path, :authenticate_user!
helper_method :user_signed_in?, :current_user
end
protected
def authenticate_user!
redirect_to :root unless user_signed_in?
end
def user_signed_in?
current_user
end
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
def snaffle_current_path
session[:user_return_to] = request.fullpath
end
end
This is an Authenticated
concern that is included into the pages that require the user to be logged in to view. A before_action
callback executes snaffle_current_path
which is where I'm storing the user's current path into their session cookie. The next before_action
callback executes authenticate_user!
which will redirect them to the root
page if they are not signed in. Two helper methods are also specified here, one for setting the current_user
by looking at the user_id
stored in the session cookie, and another user_signed_in?
which is a self explanatory boolean.
The root page at the moment just has a "Log in with Google" button.
Clicking it initiates the OAuth2 login procedure, something that should look very familiar.
So now that we have a response coming back from the OAuth2 request I need to either find the user that matches that or create a new one. I do this in a class method of the User
class called from_omniauth
. It simply uses find_or_create_by!
passing in the uid
and provider
. If no user is found it is created and populated, otherwise return the user that was found.
app/models/user.rb
class User < ApplicationRecord
audited
validates :first_name, presence: true
validates :email, presence: true, uniqueness: true
class << self
def from_omniauth(response)
find_or_create_by!(uid: response[:uid], provider: response[:provider]) do |user|
user.email = response[:info][:email]
user.first_name = response[:info][:first_name]
user.last_name = response[:info][:last_name]
user.picture_url = response[:info][:image]
end
end
end
def full_name
[first_name, last_name].compact.join(' ')
end
def to_s
"#{full_name} (#{email})"
end
end
I need to add some tests now for all of this authentication work so far. There will be a problem with existing Integration and System tests too, because they previously were not expecting to need authentication, and now the user is being booted out to a login page.
To start with I can add some helper methods to the test_helper.rb
file. OmniAuth provides a way to mock the results of performing an OAuth2 request, so I can start by adding that:
test/test_helper.rb
def mock_authentication
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
{
provider: 'google_oauth2',
uid: '12345',
info: {
first_name: 'Saul',
last_name: 'Goodman',
email: 'saul.goodman@example.com'
}
}
)
end
Using that I can add two other helper methods, one that can be used by Capybara to perform a login action:
test/test_helper.rb
# Capybara login
def login
mock_authentication
visit(root_url)
click_on('Log in with Google')
end
And another that can be used in the Integration tests to make a controller request to authenticate:
test/test_helper.rb
# Controller login
def authenticate
mock_authentication
get('/auth/google_oauth2/callback')
end
Now I can add a System test for the user being redirected if they are not logged in, and fix the existing System tests so the user is logged in prior to attempting to access a restricted page.
test/system/item_sell_packs_test.rb
test 'redirects if not logged in' do
visit item_sell_packs_url
assert_current_path(root_url)
end
test 'visiting the index' do
login
visit item_sell_packs_url
assert_selector 'h1', text: 'Item Sell Packs'
end
In a similar vein I can do the same thing for the controller Integration tests:
test/controllers/item_sell_packs_controller_test.rb
test 'should redirect if not authenticated' do
get item_sell_packs_url
assert_redirected_to :root
end
test 'should get index' do
authenticate
get item_sell_packs_url
assert_response :success
end
So at this point a user will be redirected to the login page if they are not authenticated. They can then log in via Google and will be redirected to either the dashboard page or the last page they were on. Now on to implementing a Logout.
Useful links
ruby-toolbox.com/categories/rails_authentic..