Lesson n°5

Insecure session

What we will build

Now that our users are able to register, we need to allow them to sign in. If unauthenticated users try to access the private dashboard page, they will be redirected to the sign-in page and shown an error flash message.

Sign-in page

If they then enter an invalid user/password combination, the sign-in form will be re-rendered with an error message:

Invalid email/password combination

Finally, if they enter a valid user/password combination, they will be redirected to the dashboard page with a success flash message:

Successful sign-in

Now that we have a clear idea of what we will build in this lesson, it's time to start coding!

Preparing sign-in

The first step is to add a new route in our routes file in order to display the sign-in form:

# config/routes.rb

Rails.application.routes.draw do
  # All the previous routes
  resource :session, only: %i[new create]
end

Once again, we use the singular resource method for our sessions. This is because users will only have one session at a time. Another reason is that we will also add a destroy action later to allow users to sign out. With singular resources, the routes will look like this:

Prefix      Verb   URI Pattern            Controller#Action
new_session GET    /session/new(.:format) sessions#new
session     DELETE /session(.:format)     sessions#destroy
            POST   /session(.:format)     sessions#create

If we didn't use a singular resource, the routes would look like this:

Prefix      Verb   URI Pattern             Controller#Action
sessions    POST   /sessions(.:format)     sessions#create
new_session GET    /sessions/new(.:format) sessions#new
session     DELETE /sessions/:id(.:format) sessions#destroy

The main difference here is that singular resources don't have an :id parameter in the URI pattern. This is what we want because, as we'll see later, we will store the session_id in a cookie, so it's unnecessary to also have it in the URL to sign out.

Let's now focus on the #new and #create actions in order to allow our users to sign in. Later, we will implement the #destroy action to allow them to sign out.

Now that our routes are ready, it's time to create the corresponding controller:

# app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  def new
  end

  def create
    if user = User.authenticate_by(session_params)
      # Add sign-in logic later
      redirect_to dashboard_path, notice: "Logged in with #{user.email}"
    else
      flash.now[:notice] = "Invalid email or password"
      render :new, status: :unprocessable_entity
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end

The #new action is just here to allow us to display the sign-in form.

The #create action is much more interesting. We use the authenticate_by method provided by Rails. Under the hood, this method tries to find a user in the database using the email address from the params. If no user is found, it returns nil. If it finds a user, it hashes the password provided in the params with bcrypt and compares it with the password_digest value in the database. If the hashes are equal, it returns the User instance. If the hashes are different, it returns nil:

# Example of the authenticate_by method:

User.create! email: "[email protected]", password: "password"

User.authenticate_by email: "[email protected]", password: "password"
# => nil
User.authenticate_by email: "[email protected]", password: "wrongpassword"
# => nil
User.authenticate_by email: "[email protected]", password: "password"
# => #<User email: "[email protected]"...>

We will go back to the actual sign-in logic later. For now, we just redirect the user to the dashboard if a valid email/password combination is provided by a user, and we display an error message otherwise.

Let's add a view for our sign-in form:

<%# app/views/sessions/new.html.erb %>

<h1>Sign in</h1>

<%= form_with scope: :session, url: session_path do |f| %>
  <div>
    <%= f.label :email %>
    <%= f.email_field :email %>
  </div>

  <div>
    <%= f.label :password %>
    <%= f.password_field :password %>
  </div>

  <%= f.submit "Sign in" %>
<% end %>

The only surprising thing here might be the scope: :session option in the form_with method. It allows us to nest our email and password params in a session key like this:

{ session: { email: "[email protected]", password: "password" } }

Then in the controller, we can access the params like this:

def session_params
  params.require(:session).permit(:email, :password)
end

While this scope option is completely optional, I like to use it because it makes the params in the controller more explicit, but it's only a matter of personal preference.

In order to make the application usable by our users, we should now add a link to the sign-in form in the navbar:

<%# app/views/layouts/application.html.erb %>

<!DOCTYPE html>
<html>
  <head>
    <%# ... %>
  </head>
  <body>
    <header>
      <nav>
        <ul>
          <li><%= link_to "Home", root_path %></li>
          <li><%= link_to "Dashboard", dashboard_path %></li>
          <li><%= link_to "Sign in", new_session_path %></li>
          <li><%= link_to "Sign up", new_registration_path %></li>
        </ul>
      </nav>
    </header>
    <main>
      <%# ... %>
    </main>
  </body>
</html>

Finally, we can test our sign-in form. If we try to sign in with an invalid email/password combination, we should see an error flash message. If we sign in with a valid email/password combination, we should be redirected to the dashboard with a success message.

With all this in place, we are now ready to implement the actual sign-in logic.

Authentication concern

The first version that we are going to code together will have huge security vulnerabilities. The goal is to learn how to recognize and fix them. We will remove them one by one until we have a system that is suitable for a real production application.

Let's start working on our first authentication version. Let's open the SessionsController and update the #create action:

# app/controllers/sessions_controller.rb

def create
  if user = User.authenticate_by(session_params)
    sign_in user
    redirect_to dashboard_path, notice: "Logged in with #{user.email}"
  else
    flash.now[:notice] = "Invalid email or password"
    render :new, status: :unprocessable_entity
  end
end

In the snippet above, we call a sign_in method that we haven't defined yet. This method has to live somewhere. In order to keep our code nice and tidy, we are going to use a Rails concern.

A Rails concern is simply a module that we can use to share/organize pieces of code. In our case, let's create a new concern called Authentication that we'll use to code all authentication-related methods:

# app/controllers/concerns/authentication.rb

module Authentication
  def sign_in(user)
    # TODO: Implement sign-in logic
  end
end

We can now include this concern in ApplicationController:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Authentication
end

As our SessionsController inherits from ApplicationController, it will have access to the sign_in method.

By adding this concern, we created a place where all authentication-related code can live. Grouping pieces of code that belong together like this will make our lives easier once our application grows by making it easier to find what we are looking for.

Let's now implement a very insecure sign-in method:

# app/controllers/concerns/authentication.rb

module Authentication
  def sign_in(user)
    # Warning: This is not the final implementation and is currently insecure
    cookies.permanent[:user_id] = user.id
  end
end

Once a user successfully authenticates with a valid email/password combination, we can store their id in the cookies thanks to Rails' cookies method. The permanent method means storing the cookie for the next 20 years in the user's browser.

Once the cookie is stored in the user's browser, it will be sent in the headers of every request made to our server unless it's cleared by the user, or, as we'll see later, by our Rails application in the sign_out action. This means that once signed in, whenever the user makes a request to our application, we can access the user's id in the cookies and use it to find the user in the database.

Let's use this feature to force authentication before accessing the dashboard. The first step is to add a before_action in the DashboardsController:

# app/controllers/dashboards_controller.rb

class DashboardsController < ApplicationController
  before_action :require_authentication

  def show
  end
end

We can implement the require_authentication method in the Authentication concern:

# app/controllers/concerns/authentication.rb

module Authentication
  def require_authentication
    restore_authentication || request_authentication
  end

  def restore_authentication
    User.find_by(id: cookies[:user_id])
  end

  def request_authentication
    redirect_to new_session_path, notice: "You must be signed in to perform that action"
  end

  # All the previous code
end

The require_authentication method first tries to find a user by the id stored in the cookies. As we saw earlier, when a user signs in, we store their id in the cookies. The cookies are then sent to the server with every request until they log out or the cookies are cleared, and we can access this value in Rails with cookies[:user_id].

If such a user is found, the method returns the user object, which is a truthy value, and the before_action filter will let the request go through. If no user is found, the method redirects the user to the sign-in page.

You can now try to access the dashboard page in a new private window. You should be redirected to the sign-in page with a message saying "You must be signed in to perform that action".

Accessing the current user

While the require_authentication action above works fine to prevent unauthenticated users from accessing the dashboard, it would be nice to display the user's email address in the dashboard view.

Let's improve the require_authentication method to store the user object in a global variable:

# app/controllers/concerns/authentication.rb

module Authentication
  # All the previous code

  def restore_authentication
    Current.user = User.find_by(id: cookies[:user_id])
  end

  # All the previous code
end

The Current object is a simple class that we can use to store global variables in Ruby on Rails. To make it work, we need to define the Current class in the models and make it inherit from ActiveSupport::CurrentAttributes:

# app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :user
end

Before accessing the dashboard, if a user is signed in, we now store the corresponding user object in Current.user. This means we can now access this User instance in the controllers, the models, and the views. Let's add the user's email address to the dashboard view:

<%# app/views/dashboards/show.html.erb %>

<h1>
  Private dashboard
</h1>
<p>
  Welcome <%= Current.user.email %>,
</p>
<p>
  This page should only be accessible to authenticated users
</p>

If you now sign in a user with a valid email/password combination, you should be redirected to the dashboard and see a welcome message with the user's email address.

That was quite a lot of code. We have a first "working" version of our authentication system. However, it has a lot of security vulnerabilities. We'll discuss them and see how we can fix them later in the course.

Signing in on sign-up

There is one small user experience improvement we can make before discussing the security vulnerabilities: we should sign in our users right after they create their account. This way, they don't have to sign in manually right after signing up which would be a bit cumbersome.

If you remember well, we added the sign_in method in the Authentication concern. This concern is then included in ApplicationController. Since our RegistrationsController inherits from ApplicationController, it already has access to the sign_in method. We can simply reuse it to sign in our users when they register:

# app/controllers/registrations_controller.rb

# All the previous code
def create
  @user = User.new(user_params)

  if @user.save
    sign_in @user
    redirect_to dashboard_path, notice: "You have successfully registered!"
  else
    render :new, status: :unprocessable_entity
  end
end
# All the previous code

Well, that was easy thanks to how we organized the code! Let's test in the browser and sign up with a new user. We should be redirected to the dashboard right after registering and we should see the welcome message with the user's email address.

In the next chapter, we'll discuss the security vulnerabilities of our current implementation and see how we can fix them.