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
- Introduction
- Store user credentials with has_secure_password
- Login
- Log out
- Other
- Conclusion
- Code Repository
- References
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
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'
then do
$ bundle install
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
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
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
Make a Signup page
Let's make a signup page so users can register themselves.
$ rails g controller users index new create
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 thename
andpassword
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
# 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
<!-- 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>
<!-- 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 %>
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>
Testing
-
We can go to the
/users
page and there's no user. -
Click the link New User and go to the
/users/new
page. Then enter the username and password you want in the form. 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"
-
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. -
It won't create the user. it shows the error message
Password confirmation doesn't match Password
.
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>
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
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
It will have 2 actions:
-
new
: show the login page with the login form -
create
: use the enteredusername
andpassword
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
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 %>
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
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
<!-- 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' %>
<!-- app/views/pages/secret.html.erb -->
<h1>This is the secret page</h1>
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
Test
-
Go to the root page. You can enter it because it's a public page
-
Click the Secret page link, and you will get
401 Unauthorized
-
Go back and click the Login link. Enter the credential and submit the form.
-
You will log in to the system. The index page will welcome you with your user's name.
-
Click the Secret page link. You can enter the secret page now!
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
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
}
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']
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.
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] %>
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
Remember to add the corresponding routes
# config/routes.rb
resources :user_sessions, only: [:new, :create, :destroy]
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' %>
Test
- Go to the root page and click Log out link. You should log out successfully.
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)
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)
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.
Setup very basic authentication with Devise in Rails 7
Kevin Luo ・ May 15 '23
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 😉
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)
this is great! you have a huge talent for explaining things! thanks for this!
Welcome 🤗.
Great article !
Great article !
part 3 is broken?
@javid_freeman_fe33414813d I think Dev.to is having some issues now. I haven't changed anything more than a year 😄
@javid_freeman_fe33414813d without doing anything, the article goes back to life by itself 😆