User Authentication
User Authentication is an important part of most web applications.
There are gems that can provide awesome authentication functionality out of the box, like Devise and Clearance.
However, it's also a good exercise in learning to implement our own authentication and understand the concepts for building out such a functionality.
And after you are familiar with the concepts, using any of those gems above would seem less intimidating.
In this guide, we'll build an extremely simple User Authentication:
- User Sign Up (with Email/Password)
- User Sign In/Out
The following will be out of scope:
- User Sign Up/In with Social Media Accounts
- Email Confirmation of Account
- Reset Password
- Remember Me
- User Tracking
- Account Lockdown (when failed sign in > X times)
1. User Model
Let's build our basic user model (completed as an exercise previously):
$> rails generate Model User name:string email:string
This would generate an empty user.rb
and a migration for the users table. Run the migration:
$> rake db:migrate
Finally, we'll add basic validations to the User model:
class User < ActiveRecord::Base
validates_presence_of :name, :email
validates_format_of :email, with: /@/
end
2. Uniqueness of Email
The User Authentication that we'll be building will user Email and Password as login credentials.
For this to happen, email
has to be unique in the database, so that there's no ambiguity for user login.
To ensure uniqueness for the email
column, we can do it at the code level or database level.
Let's do it on both levels to ensure that no one gets through the crack.
Firstly, for the database, we need to add an unique index:
$> rails generate migration add_index_to_users_email
Open up the generated migration file and update the contents:
class AddIndexToUsersEmail < ActiveRecord::Migration
def change
add_index :users, :email, unique: true
end
end
Secondly, add the uniqueness at the code level, specifically, in the User model:
validates_uniqueness_of :email
Depending on the database that you are using, searching could be case sensitive or case insensitive:
PostgreSQL (Case Sensitive) | MySQL (Case Insensitive) | |
---|---|---|
User.find_by(email: '[email protected]') | User A | User A |
User.find_by(email: '[email protected]') | User B | User A |
From the example above, you can see the impact of case sensitivity on database queries.
Usually in User Authentication, we would prefer our queries to be case insensitive,
so that [email protected]
and [email protected]
both returns the same user.
To do that, it's actually easier then to ensure that we downcase the email before saving to the database. At the same time, our uniqueness index (on the code level) should be case insensitive too.
Let's update our User model again, and you should end up with:
class User < ActiveRecord::Base
validates_presence_of :name, :email
validates_format_of :email, with: /@/
validates_uniqueness_of :email, case_sensitive: false
before_save { self.email = email.downcase }
end
3. Password Fields
How do we handle passwords?
It is important to remember that we NEVER STORE PASSWORD IN CLEAR in the database. They have to be encrypted.
Comparison of passwords are also performed on the encrypted versions, instead of the original password.
In this way, it's difficult to find out the original password for a user (even for developers).
For encryption to work, we'll use the bcrypt gem in Gemfile (find and uncomment) then run `bundle install:
gem 'bcrypt', '~> 3.1.7' #
Rails provides a convenience method for use to do this: has_secure_password
and we can use it in User model:
class User < ActiveRecord::Base
has_secure_password
# other code ...
end
Referencing the Guide,
we'll need to add a new column password_digest
to users:
$> rails generate migration add_password_digest_to_users password_digest:string
Run the migration:
$> rake db:migrate
4. User Sign Up
"User Sign Up" is equivalent to "Creation of User", except that this is open to public.
(You might have completed this from the earlier exercise)
Let's first add the routes in config/routes.rb
:
resources :users
Next, let's create a UsersController:
class UsersController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(params.require(:user).permit(:name, :email, :password, :password_confirmation))
if @user.save
redirect_to @user
else
render :new
end
end
def show
@user = User.find(params[:id])
end
end
By now, you should be familiar with how a Rails controller and actions work..
We have create 3 actions (or methods), where new
will render app/views/users/new.html.erb
and display a form for users to sign up with "Name", "Email", "Password" and "Password Confirmation".
create
will do the actual creation of a User in the database with the 4 whitelisted attributes,
and redirect to show
on success, or re-render new
on failure.
show
will display the details of a user for now. Later, it should display the grams of this user.
Then, we'll go ahead and create our views, starting with new.html.erb
:
<% if="" @user.errors.any?="" %="">
<% @user.errors.full_messages.each="" do="" |msg|="" %="">
- <%= msg="" %="">
<% end="" %="">
<% end="" %="">
<%= form_for(@user)="" do="" |f|="" %="">
<%= f.label="" :name="" %="">
<%= f.text_field="" :name,="" class:="" 'form-control'="" %="">
<%= f.label="" :email="" %="">
<%= f.text_field="" :email,="" class:="" 'form-control'="" %="">
<%= f.label="" :password="" %="">
<%= f.password_field="" :password,="" class:="" 'form-control'="" %="">
<%= f.label="" :password_confirmation="" %="">
<%= f.password_field="" :password_confirmation,="" class:="" 'form-control'="" %="">
<%= f.submit="" 'Sign="" Up',="" class:="" 'btn="" btn-success'="" %="">
<% end="" %="">
And for show
, we can just do something as simple as (if you already have something, just leave it):
<%= @user.inspect="" %="">
Now, any user can sign up!
(But we lack the links to go to the sign up page in the navigation bar.)
5. Sign In, Sign Out
To manage "Sign In" and "Sign Out", we need a SessionsController
which will manage the session for a user:
class SessionsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:session][:email].downcase)
if @user && @user.authenticate(params[:session][:password])
session[:user_id] = @user.id
redirect_to root_path
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
session.delete(:user_id)
@current_user = nil
redirect_to root_path
end
end
new
will display a "Sign In" form which will have "Email" and "Password" as the form fields.
On submission of the "Sign In" form, it will POST
to create
which will attempt to login the user:
- Find user by email (downcase, because we store all our emails as downcase)
- Use the
authenticate
method provided byhas_secure_password
to compare encrypted passwords
If login is successful, the User's id
will be saved to session
which will be available to other requests.
Otherwise, it will re-render the "Sign In" form.
flash.now
will allow Flash messages to be used for the current request (whileflash
will pass the message to the next request).
The destroy
method is for logout where the session
will be cleared and redirected to root_path
.
The "Sign In" form at new.html.erb
will be like this:
<%= form_for="" :session,="" url:="" sign_in_path="" do="" |f|="" %="">
<%= f.label="" :email="" %="">
<%= f.text_field="" :email,="" class:="" 'form-control'="" %="">
<%= f.label="" :password="" %="">
<%= f.password_field="" :password,="" class:="" 'form-control'="" %="">
<%= f.submit="" 'Sign="" In',="" class:="" 'btn="" btn-success'="" %="">
<% end="" %="">
Add these to config/routes.rb
to make our SessionsController
accessible:
get 'sign_in' => 'sessions#new'
post 'sign_in' => 'sessions#create'
delete 'sign_out' => 'sessions#destroy'
Finally, let's add the "Sign Up", "Sign In" and "Sign Out" links to our layout layouts/application.html.erb
,
and also allow flash[:error]
to be displayed:
<!DOCTYPE html>
<html>
<head>
<title>Minigram</title>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
<%= csrf_meta_tags %>
<script src="//widget.cloudinary.com/global/all.js" type="text/javascript"></script>
</head>
<body>
<nav class="navbar">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="<%= root_path %>">minigram</a>
</div>
<div id="navbar" class="collapse navbar-collapse pull-right">
<ul class="nav navbar-nav">
<!-- !!!!!OVER HERE!!!!! --->
<% if current_user %>
<li><%= link_to 'My Minigram', user_path(current_user) %></li>
<li><%= link_to 'Sign Out', sign_out_path, method: :delete %></li>
<% else %>
<li><%= link_to 'Sign Up', new_user_path %></li>
<li><%= link_to 'Sign In', sign_in_path %></li>
<% end %>
<!-- !!!!!OVER HERE!!!!! --->
</ul>
</div>
</div>
</nav>
<div class="container">
<% if flash[:notice] %>
<div class="alert alert-info"><%= flash[:notice] %></div>
<% end %>
<!-- !!!!!OVER HERE!!!!! -->
<% if flash[:error] %>
<div class="alert alert-danger"><%= flash[:error] %></div>
<% end %>
<!-- !!!!!OVER HERE!!!!! -->
<%= yield %>
</div>
</body>
</html>
Notice that in the new code which we just added, we checked for if current_user
.
This method current_user
doesn't actually exist and we need to add it into ApplicationController
:
class ApplicationController < ActionController::Base
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception
helper_method :current_user
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
end
Basically, it will find the User using the session[:user_id]
which we stored when a user logins successfully.
The helper_method :current_user
then exposes this method so that it can used in the views.
6. Associate User to Grams
A user can now "Sign Up", "Sign In" and "Sign Out", but we are still seeing all grams from the database.
Let's make it such that I can see a user's Grams if I browse to /users/winston
.
To do that, we need to associate User and Grams, so that a User has_many
Grams, and a Gram belongs_to
a User.
Let's start with the migration:
$> rails generate migration add_user_id_to_grams user_id:integer:index
This will generate a migration that adds a new column user_id
to the grams table, with index. Run migration:
$> rake db:migrate
Next, let's add our associations:
class User < ActiveRecord::Base
has_many :grams
# other code...
end
class Gram < ActiveRecord::Base
belongs_to :users
# other code...
end
Now, let's think about it. If we create a Gram right now, how do we know which User it belongs to? We don't!
Hence we need to make sure that we assign the User when a Gram is created:
def create
@gram = Gram.new(params.require(:gram).permit(:title, :content, :country_code, :image, :image_cache))
# !!!!! Associate a Gram to a User !!!!!
@gram.user = current_user
if @gram.save
redirect_to @gram, notice: 'You have successfully grammed!'
else
render :new
end
end
And when we access users/winston
(which is the UsersController's show
action), we need to display specifically the Grams for this user only:
def show
@user = User.find(params[:id])
@grams = @user.grams.page(params[:page]).per(5)
end
Let's display the Grams in users/show.html.erb
:
<% @grams.each="" do="" |gram|="" %="">
Me
<%= link_to="" time_ago_in_words(gram.created_at),="" gram_path(gram)="" %="">
<%= link_to="" icon('trash-o'),="" gram_path(gram),="" method:="" :delete,="" data:="" {="" confirm:="" 'Are="" you="" sure="" want="" to="" delete="" this="" Gram?'}="" %="">
<%= image_tag="" gram.image_url(:cropped)="" %="">
<% end="" %="">
<%= paginate="" @grams="" %="">
7. Protect Gram Creation
We only want users who have signed in to be able to create grams.
Let's first hide the new gram link in our grams/index.html.erb
page:
<% if="" current_user="" %="">
<%= link_to="" icon('camera'),="" new_gram_path="" %="">
<% end="" %="">
That's great! But if a user already knows the link to the new Gram form, then he/she can still access it.
Hence we need to protect the new
and create
methods in GramsController from unauthenticated user too.
Let's make the updates to GramsController:
class GramsController < ApplicationController
before_action :authenticate_user, only: [:new, :create]
# other code...
# at the bottom of the file
private
def authenticate_user
redirect_to root_path unless current_user
end
end
before_action
will run before new
or create
is executed and it will check if current_user
exists.
If not, then redirection to root_path
occurs.
8. Author Can Delete Gram
At the same time, you can only delete your own gram and not others gram.
Let's first make sure that only gram authors can see the delete link in the views.
Starting with grams/index.html.erb
:
<% if="" gram.user="=" current_user="" %="">
<%= link_to="" icon('trash-o'),="" gram_path(gram),="" method:="" :delete="" %="">
<% end="" %="">
And with grams/show.html.erb
:
<% if="" gram.user="=" current_user="" %="">
<%= link_to="" icon('trash-o'),="" gram_path(@gram),="" method:="" :delete="" %="">
<% end="" %="">
In the GramsController
, we need to protect the destroy
method as well, and we just have to update the before_action
to include destroy
.
before_action :authenticate_user, only: [:new, :create, :destroy]
It is also a good practice to make sure that whenever we run the destroy
method, the gram belongs to the current_user
. Hence, we'll update our find
method in the destroy
method:
def destroy
@gram = current_user.grams.find(params[:id])
@gram.destroy
redirect_to grams_path
end
Congrats. You have built a very solid authentication from scratch.