Custom authentication in Ruby on Rails
Tags: rails, beginners, tutorial, ruby
Published: 2021-07-13 11:11:24

When I was starting with Rails, I used to reach out to Devise every single time to handle the authentication. I thought myself to be a pro. You just need to install the `gem` and run a few commands and there you have it. A working authentication system!

Soon after, I decided to build an invitation system, wherein the owner of the project could invite collaborators. To my surprise, there was a gem already, and that too, it was built for applications that were already using Devise. After fiddling with the gem, I realized that I was overriding most of the `method`s just to get the functionality of the app that I desired. So after reading a handful of articles on authentication, I decided to build my own.

This article assumes that you have some basic understanding of how Rails works, what cookies are, and how cookie signing works.

## Let's start with a fresh new rails app

```bash
rails new custom-authentication -T -d postgresql
cd custom-authentication
```

The `-T` flag tells rails that we do not want the default test framework. We'll use `RSpec` to test our application.

## Installing the gems that we need

```ruby
gem "bcrypt", "~> 3.1.7"

group :development, :test do
  gem "factory_bot_rails", "~> 6.2"
  gem "rspec-rails", "~> 5.0", ">= 5.0.1"
  gem "shoulda-matchers", "~> 4.5", ">= 4.5.1"
end
```

Add these gems to your `Gemfile` if you don't have it already. We'll not be discussing what these gems do. Try googling and I'll promise you that you'll learn more about those gems from their official documentation.

### Setting up the test suite

After installing the gems, run `rails g rspec:install`. After successfully running the command, you should have the `spec` directory at the root of your application.

Create a new directory called `support` inside the `spec` directory.

Within the `spec/support/factory_bot.rb`, add these lines.

```ruby
# spec/support/factory_bot.rb

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end
```

Within the `spec/support/shoulda_matchers`, add these lines.

```ruby
# spec/support/shoulda_matchers.rb

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end
```

These are steps in setting up our test framework so that we can leverage the `method`s that the gems provide.

Now navigate to the `spec/rails_helper.rb` and uncomment the line

```ruby
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
```

which is usually found in line 27.

That should be it for now.

## Creating a user model

Create a `User` model so that we can store the users credentials and later use them to authenticate the user.

```bash
rails g model user email:string:uniq password:digest auth_token:string:uniq
```

You should have similar to these lines of code on your `db/migrate/234235235_create_users.rb`. Run `rails db:migrate` after that.

```ruby
class CreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      t.string :email
      t.string :password_digest
      t.string :auth_token

      t.timestamps
    end

    add_index :users, :email, unique: true
    add_index :users, :auth_token, unique: true
  end
end
```

>  We'll use the `auth_token` as a signed cookie.

Now navigate to `spec/factories/users.rb` and let's set up the `user` factory. In short, factories are a superset to the Rails fixtures.

```ruby
# spec/factories/users.rb

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "janethebest#{n}@example.com" }
    password { "secretpassword" }
    sequence(:auth_token) { |n| "secret_token#{n}" }
  end
end
```

Now, we'll write some specs so that in the future when we modify parts of code, we can be sure that our application still functions  as it's supposed to.

```ruby
# spec/models/users_spec.rb

require "rails_helper"

RSpec.describe User, type: :model do
  subject(:user) { build(:user) }

  describe "validations" do
    it { is_expected.to have_secure_password }

    it { is_expected.to validate_presence_of(:email) }

    it { is_expected.to validate_length_of(:email).is_at_most(255) }

    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }

    it { is_expected.to allow_value("johndoe@example.com", "johjn@exa.co.in").for(:email) }

    it { is_expected.not_to allow_value("johndoeexample.com", "johjn@exa").for(:email) }

    it { is_expected.to validate_length_of(:password).is_at_least(6) }
  end

  describe "callbacks" do
    it "normalizes the email before validation" do
      email = "   hello@example.com "
      user.email = email.upcase
      user.save!

      expect(user.email).to eq("hello@example.com")
    end

    it "generates user auth_token at random" do
      user.auth_token = nil
      user.save!

      expect(user.auth_token).to be_present
    end
  end
end
```

Run the tests, and it should fail.

Navigate to `app/models/user.rb` and paste in those lines

```ruby
class User < ApplicationRecord
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i

  has_secure_password
  has_secure_token :auth_token

  before_validation :normalize_email

  validates :email, presence: true, length: { maximum: 255 }, uniqueness: { case_sensitive: false }, format: { with: VALID_EMAIL_REGEX }
  validates :password, presence: true, length: { minimum: 6 }, allow_blank: true

  private

  def normalize_email
    self.email = email.to_s.strip.downcase
  end
end
```

Run the tests again, and it should all pass. In short, what we're doing is that:

- We want to have a unique email address for every user and they should not be able to enter an invalid email address.
- We want to validate that the length of the password should at least be 6.
- We will be stripping out the whitespaces from the email address that the user enters through the form, and we will also be lowercasing it.

> Note: If you notice that we are passing `allow_blank: true` on the password validation. If you are worrying that the user signing up will be able to set in nil or an empty string as their password, then do not. `bcrypt` will automatically throw an error when this happens.
>
> The reason why we're doing that is to let the users not specify their password every single time they update their email address or their name.

I guess this is pretty much it for the `user` model. Now let's move on to the `users_controller`.

## Creating a `UsersController`

Run `rails g controller users` and navigate to the `routes.rb` file.

Add,

```ruby
resources :users, only: %i[create]
```

First, let's add some `specs` for the `users_controller`.

```ruby
# spec/requests/users_spec.rb

require "rails_helper"

RSpec.describe "Users", type: :request do
  let(:valid_attributes) { { email: "john@example.com", password: "secretpass" } }
  let(:invalid_attributes) { { email: "john@example.com", password: "" } }

  describe "#create" do
    context "when the request is valid" do
      it "creates the user" do
        expect do
          post users_path, params: { user: valid_attributes }
        end.to change(User, :count).by(1)
      end

      it "stores the auth_token in the cookie" do
        post users_path, params: { user: valid_attributes }

        expect(signed_cookie[:auth_token]).to eq(User.first.auth_token) # You probably do not have the `signed_cookie` method
      end
    end

    context "when the request is invalid" do
      it "returns an error" do
        post users_path, params: { user: invalid_attributes }

        expect(json.dig(:errors, :password)).to be_present # You also do not have the `json` method. Let's add them first
        expect(response).to have_http_status(:unprocessable_entity)
      end
    end
  end
end
```

Run the tests, and you'll probably get an error saying that you do not have the `signed_cookie` and the `json` method defined. Let's add them first.

```ruby
# spec/support/requests/sessions_helper.rb

module Requests
  module SessionsHelper
    def signed_cookie
      ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash).signed
    end
  end
end
```

```ruby
# spec/support/json_helper.rb

module JsonHelper
  def json
    JSON.parse(response.body, symbolize_names: true)
  end
end
```

Let's go to the `rails_helper.rb` file and `include` these `module`s

```ruby
# spec/rails_helper.rb

RSpec.configure do |config|
  config.include JsonHelper
  config.include Requests::SessionsHelper, type: :request
end
```

After including the `module`s, run the tests again and it should fail without complaining about the method not defined errors.

Navigate to the `app/controllers/users_controller.rb`

```ruby
# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def create
    user = User.new(user_params)

    if user.save
      cookies.signed.permanent[:auth_token] = user.auth_token # you could also set an expiring cookie that would expire after a certain time.
      # do your thing. Redirect?
    else
      render json: { errors: user.errors }, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password)
  end
end
```

Now, run the tests again and it should pass. We're done with signing up the user. Now let's focus on signing in the user.

## Signing in the user

We'll be creating a Plain Old Ruby Object (PORO) to handle the core logic of the authentication, like finding the `user` record and verifying if the password entered is correct or not.

Create a new file `authentication.rb` under the `app/models/` directory.

```ruby
# app/models/authentication.rb

class Authentication
  def initialize(params)
    @email = params[:email].to_s.downcase
    @password = params[:password]
  end

  def user
    @user ||= User.find_by(email: @email)
    return unless @user

    @user.authenticate(@password) ? @user : nil
  end

  def authenticated?
    user.present?
  end
end
```

Let's unit test this `class`

```ruby
# spec/models/authentication_spec.rb

require "rails_helper"

RSpec.describe Authentication do
  describe "#user" do
    it "returns the user if present" do
      user = create(:user)
      auth = described_class.new(email: user.email, password: user.password)

      expect(auth.user).to eq(user)
    end

    it "returns the user for case insensitive email" do
      user = create(:user)
      auth = described_class.new(email: user.email.upcase, password: user.password)

      expect(auth.user).to eq(user)
    end

    it "returns nil if user is not found" do
      auth = described_class.new(email: "invalid@email.com", password: "password")

      expect(auth.user).to be_nil
    end

    it "returns nil if user's credentials do not match" do
      user = create(:user)
      auth = described_class.new(email: user.email, password: "invalidpassword")

      expect(auth.user).to be_nil
    end
  end

  describe "#authenticated?" do
    it "returns true if user is found" do
      user = create(:user)
      auth = described_class.new(email: user.email, password: user.password)

      expect(auth).to be_authenticated
    end

    it "returns false if user is not found" do
      auth = described_class.new(email: "invalid@email.com", password: "password")

      expect(auth).not_to be_authenticated
    end
  end
end
```

### Generating the `SessionsController`

Run `rails g controller sessions` and navigate to the `routes.rb` file.

```ruby
resources :sessions, only: %i[create]
```

Navigate to the `spec/requests/sessions_spec.rb` and let's write some specs.

```ruby
# spec/requests/sessions_spec.rb

require "rails_helper"

RSpec.describe "Sessions", type: :request do
  describe "#create" do
    context "when the request is valid" do
      it "signs the user" do
        user = create(:user)
        post sessions_path, params: { email: user.email, password: user.password }

        expect(signed_cookie[:auth_token]).to eq(user.auth_token)
      end
    end

    context "when the request is invalid" do
      it "does not sign the user" do
        post sessions_path, params: { email: "invalid@example.com", password: "helloworld" }

        expect(signed_cookie[:auth_token]).to be_nil
        expect(json.dig(:errors, :invalid)).to be_present
        expect(response).to have_http_status(:unprocessable_entity)
      end
    end
  end
end
```

Run the tests and it should fail. Let's get those specs passing!

```ruby
# app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def create
    auth = Authentication.new(params) # remember this class?

    if auth.authenticated?
      cookies.signed.permanent[:auth_token] = auth.user.auth_token
      # do your thing. Redirect to some page?
    else
      render json: { errors: { invalid: ["credentials"] } }, status: :unprocessable_entity
    end
  end
end
```

Run the tests again and it should all pass.

## How the heck do we define the `current_user` method now?

Devise and other libraries provide us a handful of helper methods such as `current_user`, `authenticate_user`, etc.

We'll do this the new way. We'll be leveraging the `ActiveSupport::CurrentAttributes` class to make the authenticated user available globally. I know globals are controversial, but hey, why not for the sake of the tutorial.

Create a file `current.rb` within the `app/models/` directory.

```ruby
# app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :user
end
```

Now navigate to the `application_controller.rb` file and add these lines.

```ruby
# app/controllers/application_controller.rb

before_action :set_current_user

private

def set_current_user
  Current.user = User.find_by(auth_token: cookies.signed[:auth_token]) 
end
```

Now, after every single request, the `set_current_user` method will fire up and will try to set the `Current.user`.

For example:

- User signs up
- The cookie gets stored.
- The user gets redirected to another page.
- The `set_current_user` fires up and sets the `Current.user` for you to be used in your app.

### A quick note

- Since `Current.user` fires up in each request, please do not use it within your background jobs.

## Conclusion

So there you have it. A fully working authentication system that you can customize to your needs. Although we did not write the `views`, I assure you that it's pretty simple. Try fiddling with the system and you can come up with even better solutions. 

That's it for now. Thank you for taking the time to read through. You rock!