Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save iscott/4618dc0c85acb3daa5c26641d8be8d0d to your computer and use it in GitHub Desktop.
Save iscott/4618dc0c85acb3daa5c26641d8be8d0d to your computer and use it in GitHub Desktop.
Cheat Sheet: Simple Authentication in Rails 5 with has_secure_password

Cheat Sheet: Simple Authentication in Rails 6 with has_secure_password

The goal of this cheatsheet is to make it easy to add hand-rolled authentication to any rails app in a series of layers.

First the simplest/core layers, then optional layers depending on which features/functionality you want.

Specs
AUTHOR Ira Herman
LANGUAGE/STACK Ruby on Rails Version 4, 5, or 6
OUTCOME A user will be able to sign up, log in, and log out.

This cheatsheet is organized into the following sections:

Section
The Basics bcrypt gem and add adding a header for flash messages to Views
Sign Up Users Model, Controller, View, and Routes
Log In/Log Out Sessions Controller, View, Routes, and current_user
Changing State Changing page based on logged in or logged out and current_user helper
Authorization Restricting access based on logged in or logged out
Completed Code Summary Just want the code? Skip to the Completed Code Summary. No steps or other info

The Basics

This assumes you are adding authentication to an existing rails application. If you need to make one first, run rails new my_app -T -d postgresql then,cd my_app and rake db:create in terminal.

This also assumes you don't already have a user model, controller, views, etc. If you do, please adapt these instructions to fit your app.

Gemfile:

Run atom . in terminal (or open in your text editor of choice) to open your project.

  1. In Gemfile:

    Uncomment or add:

    gem 'bcrypt', '~> 3.1.7'
  2. In terminal:

    bundle
    • TIP: The default action of bundle is bundle install, so we can save some typing and just type bundle.

Flash Message Header:

Flash messages are the temporary messages that display at the top of a web page when a user logs in, logs out, etc.

In order to display these, we need to add a header to show up on any page if there are any flash messages to show.

The application.html.erb gets wrapped around every page on our website. So if we want to change the header, navigation, or footer we can just edit this one file.

If you haven't already set this up in your app, let's do it now:

  1. In app/views/layouts/application.html.erb:

    <!DOCTYPE html>
    ...
      <body>
       
    	<%# ----- add these lines here: ----- %>
    	
    	<% if notice %>
          	    <%= notice %>
    	<% end %>
    	
    	<% if alert %>
          	    <%= alert %>
    	<% end %>
    	
    	<%# ----- end of added lines ----- %>
    	
        <%= yield %>
      </body>
    </html>
    • notice is a shortcut to flash[:notice]
    • alert is a shortcut to flash[:alert]
    • We want to add this right before the <%= yield %> so it shows up at the top of every page.
      • <%= yield %> is where the current page we are visiting gets inserted into this site template.

Sign Up

Model:

  1. In terminal:

    rails g model user name email password_digest
    • Generates a model called app/models/user.rb and a migration for a users table with fields name, email, and password_digest all as strings.
  2. In terminal:

    rake db:migrate
    • Runs our migration file and creates the users table in the database.

    TIP: In Rails 5 you can use rails instead of rake for any of the rake commands. Example: rails db:migrate

  3. In app/models/user.rb:

    class User < ApplicationRecord
    	# ----- add these lines here: -----
    	
    	has_secure_password
    
    	# Verify that email field is not blank and that it doesn't already exist in the db (prevents duplicates):
    	validates :email, presence: true, uniqueness: true
    	
    	# ----- end of added lines -----
    end

Controller:

  1. In terminal:

    rails g controller users
    • Generates a controller called app/controllers/users_controller.rb and a folder app/views/users.
  2. In app/controllers/users_controller.rb:

    class UsersController < ApplicationController
    # ----- add these lines here: -----
    
      def new
        @user = User.new
      end
    
      def create
        @user = User.new(user_params)
        
        # store all emails in lowercase to avoid duplicates and case-sensitive login errors:
        @user.email.downcase!
        
        if @user.save
          # If user saves in the db successfully:
          flash[:notice] = "Account created successfully!"
          redirect_to root_path
        else
          # If user fails model validation - probably a bad password or duplicate email:
          flash.now.alert = "Oops, couldn't create account. Please make sure you are using a valid email and password and try again."
          render :new
        end
      end
    
    private
    
      def user_params
        # strong parameters - whitelist of allowed fields #=> permit(:name, :email, ...)
        # that can be submitted by a form to the user model #=> require(:user)
        params.require(:user).permit(:name, :email, :password, :password_confirmation)
      end
      
    # ----- end of added lines -----
    end

Routes:

  1. In config/routes.rb:

    Rails.application.routes.draw do
    
    	# ----- add these lines here: -----
    
    	# Add a root route if you don't have one...
    	# We can use users#new for now, or replace this with the controller and action you want to be the site root:
    	root to: 'users#new'
      	
      	# sign up page with form:
    	get 'users/new' => 'users#new', as: :new_user
    	
    	# create (post) action for when sign up form is submitted:
    	post 'users' => 'users#create'
    
      	# ----- end of added lines -----
    	
    end
    

Views:

  1. Create a blank file app/views/users/new.html.erb:

    • This will be the Sign Up page.
  2. In app/views/users/new.html.erb:

    <%# ----- add these lines here: ----- %>
    
    <h1>Sign Up</h1>
    
    <%= form_for @user do |f| %>
      <div>
        <%= f.label :name %>
        <%= f.text_field :name, autofocus: true %>
      </div>
      <div>
        <%= f.label :email %>
        <%= f.text_field :email %>
      </div>
    
      <div>
        <%= f.label :password %>
        <%= f.password_field :password %>
      </div>
      <div>
        <%= f.label :password_confirmation %>
        <%= f.password_field :password_confirmation %>
      </div>
      <div>
        <%= f.submit "Sign up!" %>
      </div>
    <% end %>
      
    <%# ----- end of added lines ----- %>

Testing it out:

  1. In terminal:

    rails s
    • This launches the rails web (http) server on port 3000 of your computer.
  2. In your web browser:

    Navigate to:

    http://localhost:3000/users/new
    
    • This should be the Sign Up page
  3. Create a new user.

    • You should get a flash message at the top of the page telling you Account created successfully!
  4. Optional: To verify the user got created, open rails c (AKA rails console) in terminal and enter in User.all. If it worked, you'll see your new user in there. Now exit rails console.


Log In/Log Out

Model:

For log in/log out (AKA Sessions), we don't use a model since we are going to store a value in a type of cookie instead.

The cookie lives in the user's web browser, but we can get and set data in it using using the rails session object.

For example if we wanted to save a user's favorite color using a cookie we could do this:

session[:favorite_color] = "blue"

We'll do this all from the controller, so no need to create a model.

Controller:

  1. In terminal:

    rails g controller sessions
    • Generates a controller called app/controllers/sessions_controller.rb and a folder app/views/sessions.
  2. In app/controllers/sessions_controller.rb:

    class SessionsController < ApplicationController
    # ----- add these lines here: -----
    
      def new
        # No need for anything in here, we are just going to render our
        # new.html.erb AKA the login page
      end
    
      def create
        # Look up User in db by the email address submitted to the login form and
        # convert to lowercase to match email in db in case they had caps lock on:
        user = User.find_by(email: params[:login][:email].downcase)
        
        # Verify user exists in db and run has_secure_password's .authenticate() 
        # method to see if the password submitted on the login form was correct: 
        if user && user.authenticate(params[:login][:password]) 
          # Save the user.id in that user's session cookie:
          session[:user_id] = user.id.to_s
          redirect_to root_path, notice: 'Successfully logged in!'
        else
          # if email or password incorrect, re-render login page:
          flash.now.alert = "Incorrect email or password, try again."
          render :new
        end
      end
    
      def destroy
        # delete the saved user_id key/value from the cookie:
        session.delete(:user_id)
        redirect_to login_path, notice: "Logged out!"
      end
      
    # ----- end of added lines -----
    end

Routes:

  1. In config/routes.rb:

    Rails.application.routes.draw do
    
    	root to: 'users#new'
    
    	get 'users/new' => 'users#new', as: :new_user
    	post 'users' => 'users#create'
    	
    	# ----- add these lines here: -----
    	
    	# log in page with form:
    	get '/login'     => 'sessions#new'
    	
    	# create (post) action for when log in form is submitted:
    	post '/login'    => 'sessions#create'
    	
    	# delete action to log out:
    	delete '/logout' => 'sessions#destroy'  
      	
      	# ----- end of added lines -----
    	
    end

Views:

  1. Create a blank file app/views/sessions/new.html.erb:

    • This will be the Log In page.
  2. In app/views/sessions/new.html.erb:

    <%# ----- add these lines here: ----- %>
    
    <h1>Log In</h1>
    <%= form_for :login do |f| %>
      <div>
        <%= f.label :email %>
        <%= f.text_field :email, autofocus: true %>
      </div>
      <div>
        <%= f.label :password %>
        <%= f.password_field :password %>
      </div>
      <div>
        <%= f.submit "Log In" %>
      </div>
    <% end %>
      
    <%# ----- end of added lines ----- %>

Testing it out:

Assuming your rails s is still running...

  1. In your web browser:

    Navigate to:

    http://localhost:3000/login
    
    • This should be the Log In page
  2. Log in as the user you created in the previous section.

    • You should get a flash message at the top of the page telling you Successfully logged in!

Changing State (current_user and Log In/Log Out Nav Links)

Being "Logged in" or "Logged out" doesn't do us any good unless the application dynamically changes based on that state.

Here's how to make our application show which user is logged in and give options to sign up, log in, or sign out depending on state (logged in or out).

Let's start by making a current_user helper that we can call from any controller or view.

It will let us check if there is a current_user

  • Which lets us reflect a logged in state

Or if there isn't

  • Which lets us reflect a logged out state

We can also pull user info from it like this:

current_user.name
  • Which will give us the name of the user who is currently logged in.
  • We can do that with any field on that user's record in the db (email, phone, etc)

Adding a current_user helper:

  1. In app/controllers/application_controller.rb:

    class ApplicationController < ActionController::Base
      protect_from_forgery with: :exception
    
      # ----- add these lines here: -----
    
      # Make the current_user method available to views also, not just controllers:
      helper_method :current_user
      
      # Define the current_user method:
      def current_user
        # Look up the current user based on user_id in the session cookie:
        @current_user ||= User.find(session[:user_id]) if session[:user_id]
      end
    
      # ----- end of added lines -----
    
    end
      

    TIP: The ||= part ensures this helper doesn't hit the database every time a user hits a web page. It will look it up once, then cache it in the @current_user variable.

    This is called memoization and it helps make our app more efficient and scalable.

Showing a Logged in or Logged out header:

  1. In app/views/layouts/application.html.erb:

    <!DOCTYPE html>
    ...
      <body>
       
        <%# ----- add these lines here: ----- %>
    	
        <% if current_user %>
          <!-- current_user will return true if a user is logged in -->
          <%= "Logged in as #{current_user.email}" %> | <%= link_to 'Home', root_path %> | <%= link_to 'Log Out', logout_path, method: :delete %>
        <% else %>
          <!-- not logged in -->
          <%= link_to 'Home', root_path %> | <%= link_to 'Log In', login_path %> or <%= link_to 'Sign Up', new_user_path %>
        <% end %>
        <hr>
    	
        <%# ----- end of added lines ----- %>
    	
        <% if notice %>
          <%= notice %>
        <% end %>
    	
        <% if alert %>
          <%= alert %>
        <% end %>
        <%= yield %>
      </body>
    </html>

Testing it out:

Assuming your rails s is still running...

  1. In your web browser:

    Navigate to:

    http://localhost:3000/login
    
    • This should be the Log In page
  2. Try logging out and logging in. See if the header changes info and nav links based on state.


Authorization (restricting access to pages)

Now that we have sign up, log in, and current_user -- we can ristrict access to specified pages unless a user is logged in.

First we'll add an authorize helper method, then we'll use it to force users to log in before they can access a specified page.

Adding an authorize helper:

  1. In app/controllers/application_controller.rb:

    class ApplicationController < ActionController::Base
      protect_from_forgery with: :exception
    
      helper_method :current_user
      
      def current_user
        @current_user ||= User.find(session[:user_id]) if session[:user_id]
      end
    
      # ----- add these lines here: -----
    
        # authroize method redirects user to login page if not logged in:
        def authorize
          redirect_to login_path, alert: 'You must be logged in to access this page.' if current_user.nil?
        end
        
      # ----- end of added lines -----
    
    end
      

Restrict access to a page:

Let's generate a pages_controller with a secret page.

We will require users to be logged in before they can view the secret page:

Controller:

  1. In terminal:

    rails g controller pages secret
    • Generates a controller called app/controllers/pages_controller.rb and a folder app/views/pages.
    • Also generates a view called app/views/pages/secret.html.erb and adds the route for us automatically.
      • This is a shortcut to add views and routes. Just list the page names after rails g controller pages separated by spaces.
  2. In app/controllers/pages_controller.rb:

    class PagesController < ApplicationController
      # ----- add these lines here: -----
      
      # Restrict access so only logged in users can access the secret page:
      before_action :authorize, only: [:secret]
      
      # ----- end of added lines -----
      
      def secret
      end
    end

    TIP: You can restrict more than one page by using comma separated values:

    before_action :authorize, only: [:secret, :index, :edit]

    or all pages except those listed:

    before_action :authorize, except: [:index, :show]

Testing it out:

Assuming your rails s is still running...

  1. In your web browser:

    Navigate to:

    http://localhost:3000/pages/secret
    
  2. If you are logged in you should see the page. If not, it will redirect you to the login page.

  3. Log out and try it again.

Congrats, you've just added authentication to your rails app :)


Completed Code Summary

Here's a quick summary of all the code from this cheat sheet:

Gemfile:

Added line:

gem 'bcrypt', '~> 3.1.7'

config/routes.rb:

Rails.application.routes.draw do
  # Add a root route if you don't have one...
  # We can use users#new for now, or replace this with the controller and action you want to be the site root:
  root to: 'users#new'

  # sign up page with form:
  get 'users/new' => 'users#new', as: :new_user

  # create (post) action for when sign up form is submitted:
  post 'users' => 'users#create'

  # log in page with form:
  get '/login' => 'sessions#new'

  # create (post) action for when log in form is submitted:
  post '/login' => 'sessions#create'

  # delete action to log out:
  delete '/logout' => 'sessions#destroy'
  
  # OPTIONAL secret page (requires a user to be signed in):
  get 'pages/secret' => 'pages#secret'

end

app/controllers/application_controller.rb:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  # Make the current_user method available to views also, not just controllers:
  helper_method :current_user

  # Define the current_user method:
  def current_user
    # Look up the current user based on user_id in the session cookie:
    @current_user ||= User.find(session[:user_id]) if session[:user_id]
  end
  
  # authroize method redirects user to login page if not logged in:
  def authorize
    redirect_to login_path, alert: 'You must be logged in to access this page.' if current_user.nil?
  end
  
end

app/views/layouts/application.html.erb:

<!DOCTYPE html>
<html>
  <head>
    <title>MyApp</title>
    <%= csrf_meta_tags %>

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>

    <!-- show nav links -->
    <% if current_user %>
      <!-- current_user will return true if a user is logged in -->
      <%= "Logged in as #{current_user.email}" %> | <%= link_to 'Home', root_path %> | <%= link_to 'Log Out', logout_path, method: :delete %>
    <% else %>
      <!-- not logged in -->
      <%= link_to 'Home', root_path %> | <%= link_to 'Log In', login_path %> or <%= link_to 'Sign Up', new_user_path %>
    <% end %>
    <hr>
    <!-- end -->

    <!-- show flash message if any: -->
    <% if notice %>
      <%= notice %>
    <% end %>

    <% if alert %>
      <%= alert %>
    <% end %>
    <!-- end -->

    <%= yield %>
  </body>
</html>

app/models/user.rb:

class User < ApplicationRecord
  has_secure_password

  # Verify that email field is not blank and that it doesn't already exist in the db (prevents duplicates):
  validates :email, presence: true, uniqueness: true

end

app/controllers/users_controller.rb:

class UsersController < ApplicationController

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    # store all emails in lowercase to avoid duplicates and case-sensitive login errors:
    @user.email.downcase!

    if @user.save
      # If user saves in the db successfully:
      flash[:notice] = "Account created successfully!"
      redirect_to root_path
    else
      # If user fails model validation - probably a bad password or duplicate email:
      flash.now.alert = "Oops, couldn't create account. Please make sure you are using a valid email and password and try again."
      render :new
    end
  end

  private

  def user_params
    # strong parameters - whitelist of allowed fields #=> permit(:name, :email, ...)
    # that can be submitted by a form to the user model #=> require(:user)
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end

end

app/views/users/new.html.erb:

<h1>Sign Up</h1>

<%= form_for @user do |f| %>
  <div>
    <%= f.label :name %>
    <%= f.text_field :name, autofocus: true %>
  </div>
  <div>
    <%= f.label :email %>
    <%= f.text_field :email %>
  </div>

  <div>
    <%= f.label :password %>
    <%= f.password_field :password %>
  </div>
  <div>
    <%= f.label :password_confirmation %>
    <%= f.password_field :password_confirmation %>
  </div>
  <div>
    <%= f.submit "Sign up!" %>
  </div>
<% end %>

app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController

  def new
    # No need for anything in here, we are just going to render our
    # new.html.erb AKA the login page
  end

  def create
    # Look up User in db by the email address submitted to the login form and
    # convert to lowercase to match email in db in case they had caps lock on:
    user = User.find_by(email: params[:login][:email].downcase)

    # Verify user exists in db and run has_secure_password's .authenticate()
    # method to see if the password submitted on the login form was correct:
    if user && user.authenticate(params[:login][:password])
      # Save the user.id in that user's session cookie:
      session[:user_id] = user.id.to_s
      redirect_to root_path, notice: 'Successfully logged in!'
    else
      # if email or password incorrect, re-render login page:
      flash.now.alert = "Incorrect email or password, try again."
      render :new
    end
  end

  def destroy
    # delete the saved user_id key/value from the cookie:
    session.delete(:user_id)
    redirect_to login_path, notice: "Logged out!"
  end

end

app/views/sessions/new.html.erb:

<h1>Log In</h1>
<%= form_for :login do |f| %>
  <div>
    <%= f.label :email %>
    <%= f.text_field :email, autofocus: true %>
  </div>
  <div>
    <%= f.label :password %>
    <%= f.password_field :password %>
  </div>
  <div>
    <%= f.submit "Log In" %>
  </div>
<% end %>

Addiional keywords: tutorial, how-to, bcrypt, hand-roll, roll-your-own

@jaykilleen
Copy link

Great guide! It is clear a lot of work has gone into making this easy to understand and with clear direction from start to finish.

I appreciate your efforts and even reading this in RAW you can see some real passion in what you do. I don't know you but if we ever meet remind me to buy you 🍻

@igbanam
Copy link

igbanam commented May 27, 2019

I also want to buy you a 🍺 whenever I see you in person. This is awesome!

@cheshireoctopus
Copy link

Fantastic guide. Very helpful and straightforward.

Might be incorrect, but looks as if the config/routes.rb sub-section within the Completed Code Summary section is missing the route for the secret page: get 'pages/secret'.

Thank you for your time putting this together.

@jldivemaster
Copy link

Loved this tutorial. Sums the whole thing up beautifully, even for a beginner!

@suryaajha
Copy link

Thanks for such a nice article.

@wesley974
Copy link

Are you available for mentoring, getting help with Rails ?

@iscott
Copy link
Author

iscott commented Aug 5, 2020

Thanks @DixieKorley @normanrs @jaykilleen @igbanam I appreciate the beers :)

@iscott
Copy link
Author

iscott commented Aug 5, 2020

Thanks @cheshireoctopus - added it

@iscott
Copy link
Author

iscott commented Aug 5, 2020

@iscott
Copy link
Author

iscott commented Aug 5, 2020

Thanks @wesley974 - I'm just seeing your comment now, so you probably found some help by now. But if not, PM me here: https://www.linkedin.com/in/iraherman/

I'm also teaching classes and mentoring at General Assembly. More details here

@timjquigg
Copy link

Very well written and a great help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment