Authentication with OAuth2 - Login

Photo by FLY:D on Unsplash

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.

image.png

Clicking it initiates the OAuth2 login procedure, something that should look very familiar.

image.png

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.

ruby-toolbox.com/categories/rails_authentic..

medium.com/@jenn.leigh.hansen/google-oauth2..

github.com/bkeepers/dotenv