Cheat Sheet: Simple Authentication in Rails 5 with has_secure_password
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. |
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 |
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.
Run atom .
in terminal (or open in your text editor of choice) to open your project.
In Gemfile
:
Uncomment or add:
gem 'bcrypt', '~> 3.1.7'
In terminal:
bundle
bundle
is bundle install
, so we can save some typing and just type bundle
.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:
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]
<%= 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.In terminal:
rails g model user name email password_digest
app/models/user.rb
and a migration for a users
table with fields name
, email
, and password_digest
all as strings
.In terminal:
rake db:migrate
users
table in the database.TIP: In Rails 5 you can use
rails
instead ofrake
for any of therake
commands. Example:rails db:migrate
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
In terminal:
rails g controller users
app/controllers/users_controller.rb
and a folder app/views/users
.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
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
Create a blank file app/views/users/new.html.erb:
Sign Up
page.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 ----- %>
In terminal:
rails s
http
) server on port 3000
of your computer.In your web browser:
Navigate to:
http://localhost:3000/users/new
Create a new user.
Account created successfully!
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.
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.
In terminal:
rails g controller sessions
app/controllers/sessions_controller.rb
and a folder app/views/sessions
.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
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
Create a blank file app/views/sessions/new.html.erb:
Log In
page.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 ----- %>
Assuming your rails s
is still running...
In your web browser:
Navigate to:
http://localhost:3000/login
Log in as the user you created in the previous section.
Successfully logged in!
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
Or if there isn't
We can also pull user info from it like this:
current_user.name
current_user
helper: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.
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>
Assuming your rails s
is still running...
In your web browser:
Navigate to:
http://localhost:3000/login
Try logging out and logging in. See if the header changes info and nav links based on state.
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.
authorize
helper: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
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:
In terminal:
rails g controller pages secret
app/controllers/pages_controller.rb
and a folder app/views/pages
.app/views/pages/secret.html.erb
and adds the route for us automatically.
rails g controller pages
separated by spaces.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]
Assuming your rails s
is still running...
In your web browser:
Navigate to:
http://localhost:3000/pages/secret
If you are logged in you should see the page. If not, it will redirect you to the login page.
Log out and try it again.
Congrats, you've just added authentication to your rails app :)
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