DEV Community

Cover image for Custom authentication in Ruby on Rails
Abeid Ahmed
Abeid Ahmed

Posted on • Edited on

Custom authentication in Ruby on Rails

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 methods 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

rails new custom-authentication -T -d postgresql cd custom-authentication 
Enter fullscreen mode Exit fullscreen mode

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

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 
Enter fullscreen mode Exit fullscreen mode

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.

# spec/support/factory_bot.rb RSpec.configure do |config| config.include FactoryBot::Syntax::Methods end 
Enter fullscreen mode Exit fullscreen mode

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

# spec/support/shoulda_matchers.rb Shoulda::Matchers.configure do |config| config.integrate do |with| with.test_framework :rspec with.library :rails end end 
Enter fullscreen mode Exit fullscreen mode

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

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

Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } 
Enter fullscreen mode Exit fullscreen mode

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.

rails g model user email:string:uniq password:digest auth_token:string:uniq 
Enter fullscreen mode Exit fullscreen mode

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

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 
Enter fullscreen mode Exit fullscreen mode

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.

# 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 
Enter fullscreen mode Exit fullscreen mode

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.

# 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 
Enter fullscreen mode Exit fullscreen mode

Run the tests, and it should fail.

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

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 
Enter fullscreen mode Exit fullscreen mode

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,

resources :users, only: %i[create] 
Enter fullscreen mode Exit fullscreen mode

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

# 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 
Enter fullscreen mode Exit fullscreen mode

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.

# 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 
Enter fullscreen mode Exit fullscreen mode
# spec/support/json_helper.rb module JsonHelper def json JSON.parse(response.body, symbolize_names: true) end end 
Enter fullscreen mode Exit fullscreen mode

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

# spec/rails_helper.rb RSpec.configure do |config| config.include JsonHelper config.include Requests::SessionsHelper, type: :request end 
Enter fullscreen mode Exit fullscreen mode

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

Navigate to the app/controllers/users_controller.rb

# 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 
Enter fullscreen mode Exit fullscreen mode

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.

# 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 
Enter fullscreen mode Exit fullscreen mode

Let's unit test this class

# 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 
Enter fullscreen mode Exit fullscreen mode

Generating the SessionsController

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

resources :sessions, only: %i[create] 
Enter fullscreen mode Exit fullscreen mode

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

# 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 
Enter fullscreen mode Exit fullscreen mode

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

# 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 
Enter fullscreen mode Exit fullscreen mode

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.

# app/models/current.rb class Current < ActiveSupport::CurrentAttributes attribute :user end 
Enter fullscreen mode Exit fullscreen mode

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

# 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 
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (2)

Collapse
 
efranco89 profile image
Enrique Franco

Thanks, this has helped me to understand a few definitions and how authentication really works, I have only one comment in the SessionController when auth.authenticated? is true the cookies.signed.permanent[:auth_token] is equal to auth.user.auth_token instead of only user.auth_token if you leave like that won't work

Collapse
 
abeidahmed profile image
Abeid Ahmed

Nice catch! Thanks for pointing it out.