We have updated the content of our program. To access the current Software Engineering curriculum visit curriculum.turing.edu.
Authentication
Intro to Basic Authentication
Who Are You?
Learning Goals
- review migrations, model tests, feature tests
- review RESTful routing and non-RESTful routing
- explain the use of Authentication and why it’s important
- implement Authentication using BCrypt
- use a
flash
to send temporary content to the view
Vocabulary
- authentication
- hash
- helper_method
- “flash” message
Overview
- Creating a user in our database
- Using
bcrypt
to hash passwords that we save
What IS Authentication?
Authentication is the client proving to the application that they are who they say they are.
First, we need a user to register, which is a “create” action on a “user” resource. We’ll use a Rails form helper for the registration sequence.
From there, we want to make a “login” route, which is NOT a RESTful route so we can verify who they are.
The common pattern we see today is the use of an email address to identify a user, but a “username” can also be used. We also need a password for them. We are going to handle this interaction a little differently than we handle traditional user creation because we’re setting the groundwork to remember our user in the sessions/cookie class coming up.
Registering Users
We want to remember users who come to our application. This will require a way for a user to log in to our application, and for our application to “remember” that user.
Let’s start with a user story and a test. You can add this code to an existing repo, or if you want, we have a blank one for you here.
As a visitor
When I visit '/'
and I can click a link that says "Sign Up to Be a User"
and I can enter registration details in a form
and submit that form
Then I should see a welcome message with my username
and my user details have been saved in the database.
spec/features/users/user_can_be_created_spec.rb
require 'rails_helper'
RSpec.describe "User registration form" do
it "creates new user" do
visit root_path
click_on "Register as a User"
expect(current_path).to eq(new_user_path)
username = "funbucket13"
password = "test"
fill_in :username, with: username
fill_in :password, with: password
click_on "Create User"
expect(page).to have_content("Welcome, #{username}!")
end
end
Running the test will give us errors concerning routing and creating a controller, so let’s start making these errors go away.
config/routes.rb
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
# root "articles#index"
root "welcome#index"
end
We first start by making a route.
This is the same as get "/", to: "welcome#index"
, but now, we’ll have the root
prefix to use as a path helper. Try it! Run rails routes
and see the routes that get generated for "/"
when you write the path these two different ways.
Next, let’s create the WelcomeController
and a method for the index
action as well. We are going to be rendering some basic content so we don’t need to fetch any data or build any instance variables in our controller.
app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
def index
end
end
Now, when we run our test, we get a missing template error, so let’s create an empty view template.
$ mkdir app/views/welcome
$ touch app/views/welcome/index.html.erb
This will cause a new error in our test about a missing link.
Let’s add the link to sign up into our view.
app/views/welcome/index.html.erb
<%= link_to "Register as a User" %>
But which URI path should we send a user to who clicks on this link?
Since this is a new “user” resource, we’ll name this as we have in other Rails applications.
app/views/welcome/index.html.erb
<%= link_to "Register as a User", new_user_path %>
Be sure to use path helpers wherever you can!
We can continue to follow our errors to create a new resources route for our users to point to a controller. We need a “new” path to display the form, and a “create” path to save the form data.
config/routes.rb
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
# root "articles#index"
root "welcome#index"
resources :users, only: [:new, :create]
end
For the “new” page, once we create the route, we will have to create the users controller and we will need to create a form.
$ touch app/controllers/users_controller.rb
$ mkdir app/views/users
$ touch app/views/users/new.html.erb
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
end
end
app/views/users/new.html.erb
<%= form_with model: @user do |form| %>
<%= form.label :username, 'Username:' %>
<%= form.text_field :username %>
<%= form.label :password, 'Password:' %>
<%= form.password_field :password %>
<%= form.submit 'Create User' %>
<% end %>
When we run the tests again we now get a new error:
Failure/Error: click_on "Create User"
ActionController::RoutingError:
No route matches [POST] "/users/new"
Our form doesn’t know where we need to send the information, but we have the route in our routes.rb
. How does it not know where to send the stuff? We are using form_with with a model, @user
which hasn’t been set so we have to make a blank User
object first.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
end
Our tests will now complain about an uninitialized constant UsersController::User
. Let’s create a User model, so we’ll start with an ActiveRecord database migration.
$ rails g migration CreateUsers username:string password_digest:string
WAIT – “password_digest” ?? We were calling it “password” a minute ago.
More on that in a moment…
In the meantime, let’s run that migration.
$ rails db:migrate
We should also make sure that our User model requires that the username and password fields be populated by adding validations.
$ mkdir spec/models
$ touch spec/models/user_spec.rb
spec/models/user_spec.rb
require 'rails_helper'
describe User, type: :model do
describe "validations" do
it {should validate_presence_of(:username)}
it {should validate_uniqueness_of(:username)}
it {should validate_presence_of(:password)}
end
end
And let’s make our test pass like so:
$ touch app/models/user.rb
app/models/user.rb
class User < ApplicationRecord
validates :username, uniqueness: true, presence: true
validates_presence_of :password
end
When we run our tests, our model test is complaining that we don’t have an attribute called password
and it’s right! Look at the migration and the schema. We have a field called password_digest
that we had created, but no password
.
What’s a password_digest
?
BCrypt
- BCrypt Docs
- Rails built-in SecurePassword module
- Secure Password Requires that the object have a
password_digest
attribute that will recognize bothpassword
andpassword_confirmation
as attributes even though the attribute is calledpassword_digest
. - Built into Rails, comes out of the box in the gem file but it is commented out by default. Must uncomment to use it.
- Takes password and password_confirmation (if necessary) and encrypts it to a very long string which is hard to decrypt; this is referred to as hashing.
- Takes care of matching the
password
andpassword_confirmation
fields (if used).
So let’s get bcrypt
set up. Find the gem "bcrypt"
in your Gemfile
and uncomment it. Run bundle install
again to complete the process.
We now need to tell our model that it will be expecting a field, password
, (and password confirmation
if needed) with the has_secure_password
entry like so:
app/models/user.rb
class User < ApplicationRecord
validates :username, uniqueness: true, presence: true
validates_presence_of :password
has_secure_password
end
Run our model tests, and ONLY our model tests…
$ bundle exec rspec spec/models
And the model tests should be passing.
We now return you to your regularly-scheduled TDD
Now that we’re past the error about our missing User model, our form is complaining about a missing ‘username’ field.
The gem, launchy
has been added to your Gemfile, which means now, you can add save_and_open_page
as a command in your feature test, at some point before the failing line. This will open up a static page at the time the save_and_open_page
method was executed.
Add save_and_open_page
before the line in your test that’s currently failing, and then run your test.
If we examine the HTML on the page in our browser, we see that the form fields aren’t called username
and password
, they’re called user_username
and user_password
.
This is because the “form helper” called form_with
that we’re using is taking a User model and prefixing the names of the fields, so we can adjust our test, and carry on.
(be sure to remove the save_and_open_page
command)
spec/features/users/user_can_be_created_spec.rb
require 'rails_helper'
RSpec.describe "User registration form" do
it "creates new user" do
visit root_path
click_on "Register as a User"
expect(current_path).to eq(new_user_path)
username = "funbucket13"
password = "test"
fill_in :user_username, with: username
fill_in :user_password, with: password
click_on "Create User"
expect(page).to have_content("Welcome, #{username}!")
end
end
Whew, done.
Now, when we run our tests, it’s going to complain about there not being a create action, so we need to make one.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
end
end
Whenever we “create” something, we generally want to redirect the user if everything worked, or re-render the “new” form if something failed. We won’t worry about “sad path” right now, and just redirect back to our welcome page when we’re finished.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
redirect_to root_path
end
end
This is a friendly reminder that you should be using path helpers as much as you can!
So the issue now is that our test can’t find the username of the user that we’ve just created on the page, so now, we have to create a User, and report that to the browser.
We are “dream driving” at this point: “I really wish I had a user object that I could use to create users”
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
User.create(user_params)
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
When we run our feature test again we get this error:
Failure/Error: expect(page).to have_content("Welcome, #{username}!")
expected to find text "Welcome, funbucket13!" in "Register as a User"
Let’s look back at the UsersController. It looks like the new user is being created, so let’s add a flash message to show the welcome message.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
new_user = User.create(user_params)
flash[:success] = "Welcome, #{new_user.username}!"
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
Next, we probably need to add those flash messages to our site. Since this is the kind of mechanism we’ll want on all of our pages, let’s add it to the main application.html.erb
in our view layouts.
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>Authentication7</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<% flash.each do |name, msg| %>
<%= content_tag :div, msg, class: name %>
<% end %>
<%= yield %>
</body>
</html>
If we run our tests now they should all be passing. Congrats! You did it!
Improving User Experience
If your username is an email address, you should always LOWERCASE your user’s input before trying to save it or look it up in the database. If you want case-sensitive usernames where “Francine” is a different user than “francine” and “fRaNcInE” then you don’t need to change anything.
If you’re using emaill addresses, though, this is a better approach:
def create
user = user_params
user[:username] = user[:username].downcase
new_user = User.create(user)
flash[:success] = "Welcome, #{new_user.username}!"
redirect_to root_path
end
Logging In
On our root page, we should also be able to let people who have already created an account log in. Let’s start with a test.
As a registered user
When I visit '/'
and I click on a link that says "I already have an account"
Then I should see a login form
When I enter my username and password
and submit the form
I am redirected to the home page
and I see a welcome message with my username
and I should no longer see the link that says "I already have an account"
and I should no longer see the link that says "Register as a User"
and I should see a link that says "Log out"
$ touch spec/features/users/user_can_log_in_spec.rb
spec/features/users/user_can_log_in_spec.rb
require 'rails_helper'
RSpec.describe "Logging In" do
it "can log in with valid credentials" do
user = User.create(username: "funbucket13", password: "test")
visit root_path
click_on "I already have an account"
expect(current_path).to eq(login_path)
fill_in :username, with: user.username
fill_in :password, with: user.password
click_on "Log In"
expect(current_path).to eq(root_path)
expect(page).to have_content("Welcome, #{user.username}")
end
end
Run this test and we get a missing link error, so we add a link.
app/views/welcome/index.html.erb
<%= link_to "Register as a User", new_user_path %>
<%= link_to "I already have an account", login_path %>
Next, we need to add the route. But where should we send the user to to log in? Lets send them to a non-RESTful controller action that will relate to our process. We’ll correct this in the sessions/cookies class later.
config/routes.rb
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
# root "articles#index"
root "welcome#index"
resources :users, only: [:new, :create]
# this is not RESTful, and that's OKAY
get "/login", to: "users#login_form"
end
We’ll need to create the “login_form” method in our UsersController, and an associated view template.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
new_user = User.create(user_params)
flash[:success] = "Welcome, #{new_user.username}!"
redirect_to root_path
end
def login_form
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
$ touch app/views/users/login_form.html.erb
app/views/users/login_form.html.erb
<%= form_with url: login_path, method: :post do |form| %>
<%= form.label :username, "Username:" %>
<%= form.text_field :username %>
<%= form.label :password, "Password:" %>
<%= form.text_field :password %>
<%= form.submit "Log In" %>
<% end %>
Now that we have our form, when we run RSpec, we get a new error complaining about not having a POST route, so we’ll add that to our routes next.
config/routes.rb
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
# root "articles#index"
root "welcome#index"
resources :users, only: [:new, :create]
get "/login", to: "users#login_form"
post "/login", to: "users#login"
end
And we’ll need to create the associated controller action. This will be similar to our create
method, but we will do a User.find_by
instead of a User.create
.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
new_user = User.create(user_params)
flash[:success] = "Welcome, #{new_user.username}!"
redirect_to root_path
end
def login_form
end
def login
user = User.find_by(username: params[:username])
flash[:success] = "Welcome, #{user.username}!"
redirect_to root_path
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
We did it!
Did we do it? Did we ever actually check password?
Writing the code equivalent of a bouncer
If you look at the UsersController#login
action, we aren’t actually checking the user’s password. We want to add more testing to ensure that users can’t log in with bad credentials. Let’s add this it block to our current test file.
spec/features/users/user_can_log_in_spec.rb
it "cannot log in with bad credentials" do
user = User.create(username: "funbucket13", password: "test")
# we don't have to go through root_path and click the "I have an account" link any more
visit login_path
fill_in :username, with: user.username
fill_in :password, with: "incorrect password"
click_on "Log In"
expect(current_path).to eq(login_path)
expect(page).to have_content("Sorry, your credentials are bad.")
end
If we run the test now, it’s redirecting us back to the welcome page, but we want to remain on the login page and see a flash message.
In the UsersController#login
action, we need to check the password and handle the case when it doesn’t match. Remember, we never store a user’s actual plaintext password in the database! So we can’t do something like:
user = User.find_by(username: params[:username])
if user.passsword == params[:password]
# password matches
else
#password doesn't match
end
What we’re actually storing in the database is a hash or digest of the user’s password, so we are going need to hash the given password and see if it matches what’s in our database. Luckily, that has_secure_password
line we added to our User model gives us a handy method to do this for us called authenticate
. This method is called on a User object and takes a password as an argument:
app/controllers/users_controller.rb
def login
user = User.find_by(username: params[:username])
if user.authenticate(params[:password])
flash[:success] = "Welcome, #{user.username}!"
redirect_to root_path
else
flash[:error] = "Sorry, your credentials are bad."
render :login_form
end
end
And with that our tests should be passing and everything should now work.
Let’s Get Fancy With a Helper Method
The thing is that if we are logged in, we don’t necessarily want to see links to log in or create a user. When we think about a lot of the websites that we visit, if we are not logged in, we see links to either create a user or log in, and if we are logged in, we will see a link to log out.
Let’s dream drive a little bit.
app/views/welcome/index.html.erb
<% if current_user %>
<%= link_to "Log Out" %>
<% else %>
<%= link_to "Register as a User", new_user_path %>
<%= link_to "I already have an account", login_path %>
<% end %>
If we run our tests here, we’re going to get a failing test because we haven’t at all defined current_user
here.
Our want here is that this method, current_user
, will tell us who or if anyone is currently logged into the site. Because we will want to be able to do this throughout our application, we will define it in ApplicationController
. Because all of our controllers inherit from it, every controller will have access to this method.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def current_user
User.find(session[:user_id])
end
end
The session is where we want to store whomever is logged in so we also need to modify our UsersController
to put the user id in the session on either a user being created or a user logging in.
app/controllers/users_controller.rb
class UsersController < ApplicationController
def new
@user = User.new
end
def create
new_user = User.create(user_params)
session[:user_id] = new_user.id
flash[:success] = "Welcome, #{new_user.username}!"
redirect_to root_path
end
def login_form
end
def login
user = User.find_by(username: params[:username])
if user.authenticate(params[:password])
session[:user_id] = user.id
flash[:success] = "Welcome, #{user.username}!"
redirect_to root_path
else
flash[:error] = "Sorry, your credentials are bad."
render :login_form
end
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
Even with this code, nothing changes. We are calling this method in our view, and views don’t have access to methods we define in our controllers by default. We can give views access to methods by declaring them as helper methods.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :current_user
def current_user
User.find(session[:user_id])
end
end
And now we get a new error.
ActionView::Template::Error:
Couldn't find User with 'id'=
This is happening when no one is logged in, and session[:user_id]
is nil. Let’s add an if
statement to guard against this case.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :current_user
def current_user
User.find(session[:user_id]) if session[:user_id]
end
end
Our test is now passing, but we can make just another improvement to our code.
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :current_user
def current_user
@_current_user ||= User.find(session[:user_id]) if session[:user_id]
end
end
||=
is memoization. Ruby will look to see if the variable on the left exists, if it does it uses that value. If it doesn’t exist it preforms the operation on the right. This makes it so that if we need to check the current user multiple times over the course of one request, we don’t have to go to the database to find the user multiple times.
Takeaways
- Encrypt passwords in your database when you store them, using bcrypt
- Always lowercase usernames (especially email addresses) before storing/checking them
- Don’t hesitate to use non-RESTful routes when appropriate
Checks for Understanding
- What does Authentication mean? Why do we use it and when?
- What are the steps to implementing Authentication in a Rails app?
The completed code for this class can be found on this branch here.