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
- TIP: The default action of
bundle
isbundle install
, so we can save some typing and just typebundle
.
- TIP: The default action of
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 toflash[:notice]
alert
is a shortcut toflash[: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.
-
In terminal:
rails g model user name email password_digest
- Generates a model called
app/models/user.rb
and a migration for ausers
table with fieldsname
,email
, andpassword_digest
all asstrings
.
- Generates a model called
-
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 ofrake
for any of therake
commands. Example:rails db:migrate
- Runs our migration file and creates the
-
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
- Generates a controller called
app/controllers/users_controller.rb
and a folderapp/views/users
.
- Generates a controller called
-
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:
- This will be the
Sign Up
page.
- This will be the
-
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
- This launches the rails web (
http
) server onport 3000
of your computer.
- This launches the rails web (
-
In your web browser:
Navigate to:
http://localhost:3000/users/new
- This should be the Sign Up page
-
Create a new user.
- You should get a flash message at the top of the page telling you
Account created successfully!
- You should get a flash message at the top of the page telling you
-
Optional: To verify the user got created, open
rails c
(AKArails console
) in terminal and enter inUser.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
- Generates a controller called
app/controllers/sessions_controller.rb
and a folderapp/views/sessions
.
- Generates a controller called
-
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:
- This will be the
Log In
page.
- This will be the
-
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
- This should be the Log In page
-
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!
- You should get a flash message at the top of the page telling you
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)
-
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
- This should be the Log In page
-
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.
-
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
- Generates a controller called
app/controllers/pages_controller.rb
and a folderapp/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.
- This is a shortcut to add views and routes. Just list the page names after
- Generates a controller called
-
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
Thanks @DixieKorley @normanrs @jaykilleen @igbanam I appreciate the beers :)