DEV Community

Kevin Luo
Kevin Luo

Posted on • Updated on

Building a simple authentication in Rails 7 from scratch

Introduction

Ruby on Rails doesn't provide an official authentication solution despite that Rails is a highly opinionated web framework. It somehow shows how tricky authentication can be. Nonetheless, with many other features provided by Rails, it's not difficult to build our own authentication mechanism from scratch.

This article will show you how to store user credentials, let users sign up, log in and log out.

Table of Contents

Store user credentials with has_secure_password

If you want to build an authentication system, the first thing you should think of is how to store users' passwords. In the past, many systems just stored users' passwords in plain text format. (Facebook was one of them...) Sounds ridiculous. Whatever, we don't do that anymore.

The best practice for storing passwords will be storing the hash digest of the password instead of the password itself. Hash Digest? What's that? Don't worry about that at this moment. The important thing now is how do we do that. Fortunately, Rails has a built-in method called has_secure_password that can handle the task for us.

But first, let us create a new rails project:

$ rails new auth_from_scratch
$ cd auth_from_scratch
Enter fullscreen mode Exit fullscreen mode

install bcrypt

has_secure_password will use bcrypt to calculate the hash of passwords so we need to add the gem bcrypt in the Gemfile

# Gemfile
gem 'bcrypt', '~> 3.1.7'
Enter fullscreen mode Exit fullscreen mode

then do

$ bundle install
Enter fullscreen mode Exit fullscreen mode

add User model

On the table that you want to store the users' credentials, has_secure_password will utilize the column XXX_digest to store the passwords' hash digest. We then make a User model with 3 columns:

  • name
  • password_digest
  • password_confirmation

by:

$ rails g model user name password_digest password_confirmation
$ rails db:migrate
Enter fullscreen mode Exit fullscreen mode

password_confirmation is optional but it let the system have the ability to ask users to input a password twice when registering a new user, just like many websites do.

We then call has_secure_password in the User model:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password :password, validations: true
  validates :name, presence: true, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode

We can look closer at the line having has_secure_password. :password means we want to use the password_digest column to store passwords. validations: true means we want users to compare password and password_confirmation when storing passwords. In fact, these 2 options are the default options, therefore, we can write it as this:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  validates :name, presence: true, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode

Make a Signup page

Let's make a signup page so users can register themselves.

$ rails g controller users index new create
Enter fullscreen mode Exit fullscreen mode

This creates a UsersController with index, new and create actions with the following functionalities:

  • index: show all users in the system so we know we sign a user up successfully.
  • new: show a signup form
  • create: create a new user according to the name and password filled by the user

We then add the following codes:

# config/routes.rb
Rails.application.routes.draw do
  resources :users, only: [:index, :new, :create]
end
Enter fullscreen mode Exit fullscreen mode
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.all
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      flash[:notice] = "User created successfully"
      redirect_to users_path
    else
      flash[:alert] = "User not created"
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :password, :password_confirmation)
  end
end

Enter fullscreen mode Exit fullscreen mode
<!-- app/views/users/index.html.erb -->
<h1>Users#index</h1>

<%= link_to 'New User', new_user_path %>

<table>
  <thead>
    <tr>
      <th>id</th>
      <th>name</th>
    </tr>
  </thead>
  <tbody>
    <% @users.each do |user| %>
      <tr>
        <td><%= user.id %></td>
        <td><%= user.name %></td>
      </tr>
    <% end %>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/users/new.html.erb -->
<h1>Users#new</h1>
<%= form_with model: @user do |f| %>
  <% if @user.errors.any? %>
    <div>
      <ul>
        <% @user.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div>
    <%= f.label :name %><br>
    <%= f.text_field :name %>
  </div>
  <div>
    <%= f.label :password %><br>
    <%= f.password_field :password %>
  </div>
  <div>
    <%= f.label :password_confirmation %><br>
    <%= f.password_field :password_confirmation %>
  </div>
  <p>
    <%= f.submit %>
  </p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Modify the layout application.html.erb so it can display flash messages

<!-- app/views/layouts/application.html.erb  -->
<body>
  <% flash.each do |type, msg| %>
    <div>
      <%= msg %>
    </div>
  <% end %>

  <%= yield %>
</body>
Enter fullscreen mode Exit fullscreen mode

Testing

  1. We can go to the /users page and there's no user.

    • User index page
  2. Click the link New User and go to the /users/new page. Then enter the username and password you want in the form.

    • New User page
  3. After submitting the form, you should be redirected to the /users page. At this moment, you can see there's the user you just created and it also shows the flash message "User created successfully"

  • User created
  1. That's nice! We then want to know if it can really validate the password we enter. We go back to the /users/new page and enter password fields differently on purpose.

    • invalid user
  2. It won't create the user. it shows the error message Password confirmation doesn't match Password.

    • invalid user rejected

Checkpoint 1 - database

We can check how the user credential is stored. Open rails console and execute User.first you will get this:

#<User:0x0000000109e013c0
 id: 1,
 name: "Kevin",
 password_digest: "[FILTERED]",
 password_confirmation: nil,
 created_at: Sat, 03 Jun 2023 16:14:15.155800000 UTC +00:00,
 updated_at: Sat, 03 Jun 2023 16:14:15.155800000 UTC +00:00>
Enter fullscreen mode Exit fullscreen mode

The username, Kevin, is stored and the password_digest shows [FILTERED].

We can also go to the database to check it out.

-- $ sqlite3 db/development.sqlite3
SELECT name, password_digest FROM users;
name   password_digest                                             
-----  ------------------------------------------------------------
Kevin  $2a$12$eIDbEN3j5T4pS//ra.QaN.8lQGvXu8aElqn9ypmDxZgA6Nz9IUatW
Enter fullscreen mode Exit fullscreen mode

You'll find the password_digest stores a gibberish string $2a$12$eIDbEN3j5T4pS//ra.QaN.8lQGvXu8aElqn9ypmDxZgA6Nz9IUatW which is actually the digest of bcrypt.

Login

The most important thing in an authentication system is to let users sign in; otherwise, it's kind of meaningless...

Build a Login page

We first create a UserSessionsController by:

$ rails g controller user_sessions new create
Enter fullscreen mode Exit fullscreen mode

It will have 2 actions:

  • new: show the login page with the login form
  • create: use the entered username and password to log in the user
# app/controllers/application_controller.rb
class UserSessionsController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.find_by(name: params[:user][:name])

    if @user && @user.authenticate(params[:user][:password])
      session[:user_id] = @user.id
      redirect_to root_path
    else
      flash[:alert] = "Login failed"
      redirect_to new_user_session_path
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We should take a closer look at create because it's the critical part.

@user.authenticate

User.find_by(name: params[:user][:name]) finds the user by the username entered. Then we call authenticate method on that user object to verify the password entered.
User#authenticate is also a method provided by has_secure_password. If the hash digest of the password entered matches the password digest stored in the database, it returns true; otherwise, it returns false.

We can utilize this method to verify whether the credential is correct or not.

session[:user_id] = @user.id

How does the system memorize a user who has logged in? Rails provides a simple but effective storage mechanism called session, it defaults to use cookies to store information. We can store the logging-in user's id as user_id so the next time when a controller checks the session, it knows it's a logging-in user.

We'll discuss more session later in this article. Let's continue adding code.

Add Login page view

<!-- app/views/user_sessions/new.html.erb -->
<h1>Login page</h1>
<%= form_with model: @user, url: user_sessions_path do |f| %>
  <div>
    <%= f.label :name %><br>
    <%= f.text_field :name %>
  </div>
  <div>
    <%= f.label :password %><br>
    <%= f.password_field :password %>
  </div>
  <p>
    <%= f.submit 'Login' %>
  </p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Add a helper method :current_user

Let's add a helper method current_user so we can get the current logged-in user easily. A helper_method declared in controllers can be accessed in the views, too.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  helper_method :current_user

  def current_user
    # If session[:user_id] is nil, set it to nil, otherwise find the user by id.
    @current_user ||= session[:user_id] && User.find_by(id: session[:user_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Add a public page and a restricted page

We need some pages to let us see the difference between the user before and after logging in. We add 2 pages here.

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  # index is a public page
  def index
  end

  # secret is a private page, only logged-in user can enter
  def secret
    if current_user.blank?
      render plain: '401 Unauthorized', status: :unauthorized
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/pages/index.html.erb -->
<% if current_user %>
  <h1>Welcome, <%= current_user.name %></h1>
<% else %>
  <h1>This is the index page</h1>
  <%= link_to 'Login', new_user_session_path %><br>
<% end %>
<%= link_to 'Secret page', '/pages/secret' %>
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/pages/secret.html.erb -->
<h1>This is the secret page</h1>
Enter fullscreen mode Exit fullscreen mode

Add routes

Don't forget to add routes. Here we set the root page to pages#index

Rails.application.routes.draw do
  root 'pages#index'
  get 'pages/secret'
  resources :user_sessions, only: [:new, :create]
  resources :users, only: [:index, :new, :create]
end
Enter fullscreen mode Exit fullscreen mode

Test

  1. Go to the root page. You can enter it because it's a public page

    • Root page
  2. Click the Secret page link, and you will get 401 Unauthorized

    • 401
  3. Go back and click the Login link. Enter the credential and submit the form.

    • Login page
  4. You will log in to the system. The index page will welcome you with your user's name.

    • Logged in
  5. Click the Secret page link. You can enter the secret page now!

    • Secret page

Checkpoint 2 - Session

We use session to store the information of logged-in users. However, it still seems a little vague. And most importantly, is it secure? We can check what the session looks like now.

First, we add binding.break to stop the execution. binding.break is from the gem debug.

def index
  binding.break
end
Enter fullscreen mode Exit fullscreen mode

Go to the root page, http://localhost:3000, when it stops. We can go to the terminal and check how the session stores the information.

session.to_hash
{
  "session_id"=>"09c4f96fdf7b2659c769aa4041e5d1a0",
  "_csrf_token"=>"e52CWauVPEQTi2mgPAx91wtwO81uVnIBeiDar5_K0r8", 
  "user_id"=>1
}
Enter fullscreen mode Exit fullscreen mode

You'll find that session is just a Hash-like structure. You can see "user_id" => 1 is stored in session. I hope it makes more sense when you do something like

session['something'] = 'some value'
session['something']
Enter fullscreen mode Exit fullscreen mode

We mentioned in session[:user_id] that session is stored in cookies. Does that mean we can see the information in the browser? If you're using Chrome, open the Application tab and click Storage > Cookies > http://localhost:3000, you will see there's a key called _auth_from_scratch_session in the cookie, the session object is stored there. However, if you check its value, you'll find it's not a key-value object as we expect, instead, it's a gibberish string.

Session in cookie

In fact, session is encrypted so it cannot be read by browsers. It uses the secret_key_base to do the encryption (You can get the secret by Rails.application.credentials[:secret_key_base]). Therefore, generally speaking, storing data in session is pretty secure. It's worth mentioning that the session is also signed so it's tamperproof.

The name session is not intuitive for the beginners, in my opinion, it should be called encrypted_cookie 😁

What if Javascript code wants to read the session data?

You can use embeded ruby like the code below to share information in the browser. However, be very careful and very picky about what information you want to reveal.

var userId = <%= session[:user_id] %>
Enter fullscreen mode Exit fullscreen mode

Log out

The last thing is to provide a way for the users to log out. It's very simple. All we need to do is to empty session[:user_id].

Add destroy action for logging out

We can add a destroy action in

def destroy
  session[:user_id] = nil
  redirect_to root_path
end
Enter fullscreen mode Exit fullscreen mode

Remember to add the corresponding routes

# config/routes.rb
resources :user_sessions, only: [:new, :create, :destroy]
Enter fullscreen mode Exit fullscreen mode

Add logout link in the view

The destroy action only accepts DELETE HTTP action. We're using turbo-rails by default so we just need to add data: { turbo_method: :delete } in the link_to

<!-- app/views/pages/index.html.erb -->
<% if current_user %>
  <h1>Welcome, <%= current_user.name %></h1>
  <%= link_to 'Log out', user_session_path(current_user), data: { turbo_method: :delete } %><br>
<% else %>
  <h1>This is the index page</h1>
  <%= link_to 'Login', new_user_session_path %><br>
<% end %>
<%= link_to 'Secret page', '/pages/secret' %>
Enter fullscreen mode Exit fullscreen mode

Test

  1. Go to the root page and click Log out link. You should log out successfully.

Logout link

Other

Parameters filtering

All the passwords filled in by users will be sent in plaintext. If you're careless, the log will record the password input by users.

Fortunately, has_secure_password handles that for us, too. Let's take a look at the HTTP payloads for registering a new user and logging in:

HTTP payload for registering a new user

Started POST "/users" for ::1 at 2023-06-03 12:14:14 -0400
Processing by UsersController#create as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"name"=>"Kevin", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create User"}
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/users_controller.rb:13:in `create'
  User Create (2.0ms)  INSERT INTO "users" ("name", "password_digest", "password_confirmation", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["name", "Kevin"], ["password_digest", "[FILTERED]"], ["password_confirmation", "[FILTERED]"], ["created_at", "2023-06-03 16:14:15.155800"], ["updated_at", "2023-06-03 16:14:15.155800"]]
  ↳ app/controllers/users_controller.rb:13:in `create'
  TRANSACTION (0.7ms)  commit transaction
  ↳ app/controllers/users_controller.rb:13:in `create'
Redirected to http://localhost:3000/users
Completed 302 Found in 255ms (ActiveRecord: 2.7ms | Allocations: 4113)
Enter fullscreen mode Exit fullscreen mode

HTTP payload of logging in

Started POST "/user_sessions" for ::1 at 2023-06-03 16:00:13 -0400
Processing by UserSessionsController#create as TURBO_STREAM
  Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"name"=>"Kevin", "password"=>"[FILTERED]"}, "commit"=>"Create User"}
  User Load (1.7ms)  SELECT "users".* FROM "users" WHERE "users"."name" = ? LIMIT ?  [["name", "Kevin"], ["LIMIT", 1]]
  ↳ app/controllers/user_sessions_controller.rb:7:in `create'
Redirected to http://localhost:3000/
Completed 302 Found in 268ms (ActiveRecord: 1.7ms | Allocations: 1290)
Enter fullscreen mode Exit fullscreen mode

The password and password_confirmation are both masked with [FILTERED].

Conclusion

You now know how to build a very basic authentication mechanism. Storing password hash digests and memorizing and sending signed-in users' information with signed messages are common practices. You can extend this idea to see if you can add more features like resetting the user's password, sending confirmation letters, etc.

To be honest, in practice, it is still recommended to use an existing gem, such as devise to conduct authentication. What? You say you just finished this article by following every step and then I tell you just to use existing gems? Yes 😆 Although you can build it from scratch, it doesn't mean you should do that. There's no reason for you to reinvent the wheel.

However, making authentication is a very good practice that makes you understand the functionalities behind the scenes. The lesson you learn through building authentication will be invaluable and not limited to only Ruby on Rails but have a general idea for authentication and authorization of web applications.

If you think this article is helpful, you can buy me a coffee to encourage me 😉
Buy Me A Coffee

Code Repository

https://github.com/kevinluo201/auth_from_scratch

References

https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html
https://guides.rubyonrails.org/security.html#sessions

Top comments (7)

Collapse
 
brunooo profile image
Bruno

this is great! you have a huge talent for explaining things! thanks for this!

Collapse
 
sirneij profile image
John Owolabi Idogun

Welcome 🤗.

Collapse
 
kumarkalyan profile image
Kumar Kalyan

Great article !

Collapse
 
oztofer profile image
Ferit

Great article !

Collapse
 
javid_freeman_fe33414813d profile image
Javid Freeman

part 3 is broken?

Collapse
 
kevinluo201 profile image
Kevin Luo

@javid_freeman_fe33414813d I think Dev.to is having some issues now. I haven't changed anything more than a year 😄

Collapse
 
kevinluo201 profile image
Kevin Luo

@javid_freeman_fe33414813d without doing anything, the article goes back to life by itself 😆