DEV Community

Levi
Levi

Posted on

Authentication and Authorization à la Rails bcrypt

Introduction

User accounts are an indispensable feature of web applications. They allow us to create persistent, tailored experiences for the people using our site. They also help us better track how our application is used. Despite their usefulness, implementing user accounts can be akin to opening Pandora's box of troubles.

Pandora's Box

We live in an age of savvy sackers of digital domains. While we've opened a box of possibilities, they've brought a host of problems with them! How do we stand a chance? We lean on others' work and trade configuration for convention. But before we find some shoulders to stand on, let's get a sense of the problem we're trying to solve.

Signing Up And Signing In

Signing Up and Signing In are actions with which we are all familiar. They allow web applications to authenticate and authorize their users.

When we Sign Up, we create a means by which a web application determines that we are who we say we are. Signing Up happens once in the lifetime of a user in an application. It creates a new user instance in the backend of the server with information that can be use to authenticate that user. Authentication asks the question, "Are you who you say you are?" When we sign in, we are authenticated by the application based on the username and password we provide, and consequently given access to different features of the application.

We are engaged in single-factor authentication when we only have to provide one type of information to authenticate ourselves. As you might imagine, single-factor authentication is not the best means of securing user accounts, as anyone with a username and password can access an account. That's where multi-factor authentication comes into play. We encounter multi-factor authentication pretty well every day, when a site asks us to enter a randomly generated code sent to our phones in addition to our username and password, or when an ATM machine requires us to enter a PIN number and insert our debit card. We won't build multi-factor authentication in this post, but it's important to be aware of.

Adam Being Authenticated

Once we're signed in and authenticated by an application, it will check to see if we are authorized to perform the actions we attempt to perform. Authorization asks the question, "Are you allowed to do that?" The means by which a web application authorizes a user is through cookies, specifically, session cookies.

A cookie is a bit of data stored on a user's browser. Web applications can store all kinds of information in cookies to track a user's state and history. A session cookie is a cookie that lasts for the duration of a session on a web application, typically from sign/log in to sign/log out.

When a user signs in, the user's browser is given a cookie by the web application identifying that user. The authorization of the user can then be checked when HTTP requests are made of the application.

Signing up happens once in the lifetime of user on a web application. It gives the application a means of authenticating the user, or checking to see that they are who they say they are, through a username, password, and any other required information. When a user signs in, they are authenticated and given an identifying session cookie. As the user interacts with the application, the application can check to see that the user is authorized, or permitted, to perform an action by referencing the user's cookie.

Authentication with bcrypt

Authentication is a web application's way of checking to see that a user is who they say they are. There are several Ruby gems already written to facilitate this process. devise is one of the most popular, along with omniauth and doorkeeper. We're not going to use any of those! For the purposes of understanding the process of authentication and authorization, we'll be using a gem that's been staring you in the face since you first opened a Rails Gemfile: bcrypt. Uncomment that guy!

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

# ruby '2.5.1'

...

# Use Redis adapter to run Action Cable in production
# gem 'redis', '~> 4.0'
# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'

# Use ActiveStorage variant
# gem 'mini_magick', '~> 4.8'

...

bcrypt the Ruby gem is based on bcrypt the OpenBSD hashing algorithm. Given any string, such as a password, the hash will scramble the string along with a dash of random characters (known as salt) in such a way that the process cannot be reversed or guessed. bcrypt the Ruby gem allows us to use the bcrypt algorithm to hash passwords, and it also manages storing the salt used in hashing so that we can later authenticate our users.

pw = BCrypt::Password.create('P4ssW02d!')
# => "$2a$12$YNW0EG8VwLmy1l9yxMeyAOaen/Yhx7LTBJR6G7jnG2WMkr9fo7aO6"

pw == 'P4ssW02d!'
# => true

There is a lot that bcrypt doesn't do for us. We need to alter our user database table, model, controller, and views to make use of bcrypt's functionality.

bcrypt and the User Model

In order to use bcrypt, our user table must have a password_digest attribute. In your Rails projects, you might execute the following: rails generate resource User username password_digest. After migrating the table, we should have a schema similar to the following:

  create_table "users", force: :cascade do |t|
    t.string "username"
    t.string "password_digest"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

Note that we're storing a password digest, not a password. Never, ever, for any reason, whatsoever, even if your mother asks you too, even if you think it will cure your foot fungus, never store passwords in plain text.

We must also add has_secure_password to our User model. You'll notice that I've added some validations to ensure our users are created with a unique username and non-unique password. Do not require unique passwords, as it will give ne'er-do-wells the information and confidence they need to gain access to user accounts.

class User < ApplicationRecord
  has_secure_password
  validates :username, presence: true, uniqueness: true
  validates :password, presence: true
end

has_secure_password is a helper method for models that gives them access to methods which will aid in authentication. We can test them in rails console.

usr = User.create(username: "cheetochester", password: "Ch33zy$", password_confirmation: "Ch33zy$")
# => #<User id: 4, username: "cheetochester", password_digest: "$2a$12$Io.kXzHPXoGZYzuWBawSFOawjvGNmsrHsCiXbNhlYep..." ...>

usr.authenticate("cheesys")
# => false

usr.authenticate("Ch33zy$")
# => #<User id: 4, username: "cheetochester", password_digest: "$2a$12$Io.kXzHPXoGZYzuWBawSFOawjvGNmsrHsCiXbNhlYep..." ...>

Note that when we create a new user, we do so with the password and password_confirmation keys, not password_digest. bcrypt handles validating password and password_confirmation and converting password into the password_digest that is saved in the database. Given a password string, the #authenticate method returns false if the password is incorrect, and the user instance if the password is correct.

Routes

Before we can confidently set up our controllers, we must have a clear vision of our routes. In this basic example application, users can create and view their accounts. They can also sign in and out of their accounts. We will manage this functionality with the following routes in rails-app/config/routes.rb:

Rails.application.routes.draw do
  resources :users, only: [:create, :show]
  get "/signup", to: "users#new"
  get "/login", to: "sessions#new"
  post "/sessions", to: "sessions#create"
  delete "/sessions", to: "sessions#destroy"
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

Creating a Sign Up Form

The code excerpt below depicts a basic User controller. We have supported actions for creating a user and seeing their page.

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.create(user_params)
    if @user.valid?
      @user.save
      redirect_to @user
    else  
      redirect :new
    end
  end

  def show
    @user = User.find(params[:id])
  end

  private

  def user_params
    params.require(:user).permit(:username, :password, :password_confirmation)
  end
end

There is no special sauce for creating a sign up form. We simply need a form that will pass username, password, and password_confirmation to our application. In rails-app/app/views/users/new.html.erb we can generate a form with Rails form helpers like so:

<%= form_for @user do |f| %>
  <%= f.label :username %>
  <%= f.text_field :username, placeholder: "Username" %>
  <%= f.label :password %>
  <%= f.password_field :password, placeholder: "Password" %>
  <%= f.label :password_confirmation %>
  <%= f.password_field :password_confirmation, placeholder: "Confirm Password" %>
  <%= f.submit "Create Account" %>
<% end %>

On submission, our UsersController#create action will attempt to create a new user instance with those parameters. If the user instance is valid, the user will be redirected to their show page. Otherwise, the user will be redirected to the new user page.

User Sign Up

Now that we have handled signing up a user, let's tackle signing them in.

Logging In

Logging in, or signing in, will be handled through Rails' session hash, which reads and manages a session cookie on a user's browser. Managing session requires enough unique actions to warrant another controller. We'll call this controller the SessionsController.

class SessionsController < ApplicationController
  def new
  end

  def create
    @user = User.find_by(username: params[:username])
    if @user && @user.authenticate(params[:password])
      session[:user_id] = @user.id
      redirect_to @user
    else  
      redirect_to login_path
    end
  end
end

In this controller we've defined two actions. SessionsController#new is simply responsible for rendering a page that enables a user to create a new session (also known as log in). The contents of rails-app/app/views/sessions/new.html.erb maybe as simple as:

<%= form_tag sessions_path do %>
    <%= label_tag "Username" %>
    <%= text_field_tag :username, nil, placeholder: "Username" %>
    <%= label_tag "Password" %>
    <%= password_field_tag :password, nil, placeholder: "Password" %>
    <%= submit_tag "Log In"%>
<% end %>

The purpose of this form is to prompt the authentication of a user and the storage of their identity in the session cookie. In SessionsController#create, we first try to find the user based on their username. If the user exists, we authenticate them using the password provided in the form and the #authenticate method granted to us by the bcrypt gem. If they are authenticated, we will store their id in the session cookie (session[:user_id] = @user.id). Otherwise, they will be redirected to the login path.

User Login

What keeps someone from simply modifying their session cookie to mimic a user? Lucky for us, Rails encrypts the cookies that it creates!

Authorization with bcrypt

Laying the Foundation for Authorization

We've implemented authentication with a couple of forms, a handful of routes and controller actions, and a few bcrypt methods. And yet a one-winged bird can't fly. We need authorization to put our authentication to work. We can lay the foundation for authorization by writing helper methods in rails-app/app/controllers/application_controller.rb.

class ApplicationController < ActionController::Base
  helper_method :logged_in?, :current_user

  def current_user
    if session[:user_id]
      @user = User.find(session[:user_id])
    end
  end

  def logged_in?
    !!current_user
  end

  def authorized
    redirect_to login_path unless logged_in?
  end
end

Here we've defined a few helper methods for use in our controllers and views. ApplicationController#current_user looks for a value in the :user_id key of the session cookie-hash. If there is one, the corresponding user instance will be returned. Otherwise, the return value is nil. #logged_in? coerces the return value of current_user into a Boolean by use of a double-bang. #authorized will trigger a redirect to the login page unless a user is logged in (that is, unless a value of session[:user_id] exists and it matches the value of an id of an existing user).

Since all of our controllers inherit from ApplicationController, we can go ahead and use these methods in them. helper_method let's us use the methods arguments passed to it in our views.

Authorization in Controllers

The before_action callback method can help us make short work of using our authorization methods. In our UsersController, we'll add:

class UsersController < ApplicationController
  before_action :authorized, only: [:show]

  ...
end

With this line, the application will check to see if a user is logged in and redirect to the login path if they aren't before the #show action is even fired. But the fun doesn't stop here. We can leverage our authorization helper methods to conditionally render our views!

Authorization in Views

In rails-app/app/views/layouts/application.html.erb, let's add a login/logout button below our view template. That way, it will appear on any pages we add to our application. Recall that <%= yield %> in application.html.erb is where the templates corresponding to our controller actions get rendered.

<!DOCTYPE html>
<html>
...

  <body>
    <%= yield %>

    <br>

    <% if logged_in? %>
      <%= button_to "Logout", sessions_path, method: :delete %>
    <% else %>
      <%= button_to "Login Page", login_path, method: :get %>
    <% end %>
  </body>
</html>

Since we defined the #logged_in? helper method and passed :logged_in? as an argument to helper_method in our ApplicationController, we are able to use it in our views. This bit of erb renders a logout button if a user is logged in, and vice versa.

Other User Page

We can get even more specific. Since we have a helper method current_user that returns the user of the current session, we can render custom content for that user. On the rails-app/app/views/users/show.html.erb page, we can make it so that a user sees special content if they're on their own show page.

<h1>Welcome to the page of <%= @user.username %></h2>

<% if current_user == @user %>
  <h3>This is my page!!!</h3>
<% end %>

User Page

Logging Out

Every good story has to come to an end. When a user logs out, they are essentially ending their session. One basic way of ending a user's session is to set the value of session[:user_id] to nil. We'll add the following action to our SessionsController to do so.

class SessionsController < ApplicationController
  ...

  def destroy
    session[:user_id] = nil
    redirect_to login_path
  end
end

If you're the observant type, you'll have noticed that the logout button we created last section makes the HTTP request that will be routed to this action. With that action, the session cookie will cease to know about the user and the user will not be authorized in the application.

Conclusion

And that's all it takes to build out basic authentication and authorization in a Rails application with bcrypt! When we authenticate a user, we check to see they are who they say they are. We facilitate authentication in a web application through sign up and sign/log in forms. To make use of authentication, we track user state in a session cookie and use that cookie to perform authorization before relevant actions in our application.

bcrypt helps us implement authentication by giving us a means of securely storing and cross-referencing authentication factors (like passwords) in our database. To authorize users, we write helper methods that use some of those means as well as the built-in Rails sessions cookie to check user state and react accordingly.

Narcissus Getting Lost in Himself

Now that you have some idea of how it works, try to implement it yourself! When you're comfortable using bcrypt, break out the big guns like devise and try again. Authentication and authorization are essential parts of secure web applications. We'll all be better off if you use them. And never store plain text passwords in a database.

Further Reading

Discussion (4)

Collapse
cristiano profile image
cristiano • Edited on

Thanks for this guide it's really helpful and learned a lot! However could you suggest a way of writing test helpers to sign in/sign out a user?

Trying to take certain actions whilst not logged in will result in tests failing as expected. I think the challenge for me is that sessions don't seem to be supported in tests?

I have tried to make a request to sign the user in but it doesn't do it sadly:

# Helper defined in test_helper.rb
def sign_user_in
  ApplicationController.allow_forgery_protection = false
  post(sessions_url(email: "user@example.com", password: "test"))
end
Enter fullscreen mode Exit fullscreen mode

I have also tried to change the helper to authenticate the user directly but I think there's still an issue with the session:

# Helper defined in test_helper.rb
def sign_user_in
  @user = User.find_by(email: "user@example.com")
  if @user && @user.authenticate("test")
    session[:user_id] = @user.id
  end
end
Enter fullscreen mode Exit fullscreen mode

Any ideas on how to sign in the user and make sure its authorized in tests?
Thanks very much

Collapse
guledali profile image
guledali • Edited on
require "minitest/reporters"
Minitest::Reporters.use!

class ActiveSupport::TestCase
  fixtures :all


  # Log in as a particular user.
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

# Below you have to monkeypatch open up the class ActionDispatch check the link below
# https://stackoverflow.com/questions/44461101/accessing-session-in-integration-test

class ActionDispatch::IntegrationTest

  # Log in as a particular user.
  def log_in_as(user, password: 'password')
    post login_path, params: { session: { email: user.email, password: password }
  end
end

Enter fullscreen mode Exit fullscreen mode

this how you would use the test-helper

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:joe)
  end

  test "unsuccessful edit" do
    log_in_as(@user)          # HERE we are using the helper
    get edit_user_path(@user) # localhost:3000/users/1/edit 
    assert_response :success

    patch user_path(@user), params: { user: { name:  "",
                                              email: "invalid@invalid",
                                              password:              "password",
                                              password_confirmation: "password" } }

    assert_redirect_to edit_user_path(@user)
  end
Enter fullscreen mode Exit fullscreen mode

Also think this could be rewritten from

 if @user && @user.authenticate(params[:password])

Enter fullscreen mode Exit fullscreen mode

into

if @user.try!(:authenticate, params[:password])
Enter fullscreen mode Exit fullscreen mode
Collapse
cristiano profile image
cristiano

Thanks for providing such a detailed example guledali! 🙏

Did this run okay for you? It doesn't really work for me sadly, the test still thinks the user is not authenticated, perhaps I'm thinking about building the test the wrong way but have found examples similar to these.

Not sure what I'm missing here, but I don't think it is wrong to think that it's worth testing that a controller action can be accessed and ran when the user is logged in right? 🤔

Thread Thread
cristiano profile image
cristiano

I got it working now, the mistake I was doing was to add the credentials to a :sessions hash when passing them to :params, which isn't required because of how the form is structured:

# test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
require_relative "../config/environment"
require "rails/test_help"

class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors)

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all

  # Add more helper methods to be used by all tests here...
  def sign_in_as(user, password)
    post sessions_url, params: { email: user.email, password: password }
  end
end
Enter fullscreen mode Exit fullscreen mode

An example of a controller test looks like:

test "should show user" do
  sign_in_as(@user, 'password')

  get user_url(@user)
  assert_response :success
end
Enter fullscreen mode Exit fullscreen mode

The way I was doing it wrong (adding :session or :sessions):

def sign_in_as(user, password)
  post sessions_url, params: { session: {email: user.email, password: password} }
end
Enter fullscreen mode Exit fullscreen mode