DEV Community

Cover image for Authentication with Sorcery, RSpec, and Rails 7: Building a simple Rails CMS - Part 1
Adrian Valenzuela
Adrian Valenzuela

Posted on

Authentication with Sorcery, RSpec, and Rails 7: Building a simple Rails CMS - Part 1

Cover photo by Gabriel Kraus on Unsplash

We are going to build a little CMS with features including authentication with Sorcery, authorization with Pundit, Users that can create posts and upload images, access a dashboard and manage the navigation menu links and image galleries. We will call it, WizardCMS

Hagrid You're a wizard harry meme

We are using Sorcery because its low-level nature allows us to create a simple authentication flow with out many of the headaches that other popular libraries are giving other developers.

We have a long road ahead of us, and our passenger coming along for the ride will be our buddy RSpec who knows the way and will make sure we are heading the right direction.

The format of the articles will be that I write a test, layout the code to make the test pass and make the feature/functionality happen, and do my best to explain what is happening in the code. Often I come across article that doesn't explain enough of what is happening and it leaves me wondering why something was done.

To view progress on this project:

Create new app and install RSpec

I might be adding my own CSS through the project but I won’t clutter the tutorial with it. You can style and lay out the HTML how you like really. I hope at least to give you a decent reference as to what code you might want to use to create the app with authentication and the other features listed above. I will also be using version control, but I won’t be adding it to the tutorial as well, I’ll leave that up to you. Typically it is a good idea to just create a new branch when working on a new feature or chunk of code. :)

Create new app rails new app_name and install RSpec

Install Sorcery

We'll be installing Sorcery based off this tutorial in their wiki. I'm modifying a little bit since we are creating something different, but also because their tutorial is a bit outdated since it is based off an older version of Rails.

bundle add sorcery
bundle install
rails g sorcery:install
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

When we run the migration it will set up a users table for us with some attributes to store in the database.

Let's start or restart the server so Rails loads up Sorcery.

rails s 
# or ./bin/dev if using js-bundling/css-bundling
Enter fullscreen mode Exit fullscreen mode

Let's go on and add our first feature.

User cannot access dashboard when logged out

Spec

# spec/system/user_session_flow_spec.rb

require 'rails_helper'

describe "User session flow", type: :system do
  scenario "redirected from dashboard when logged out" do
    visit "/dashboard"
    expect(page).to have_text("You must log in")
    page_title = find('h1#page-title')
    expect(page_title).to have_content("Login")
    expect(page).to have_no_content('Dashboard')
  end

  scenario "user clicks register account" do
    visit dashboard_path
    expect(page).to have_content("Don't have an account?")
    click_link "register-user"
    page_title = find('h1#page-title')
    expect(page_title).to have_content("Register")
  end
end

Enter fullscreen mode Exit fullscreen mode

In our first spec file that we will write, I split it into two scenarios because it was getting pretty hefty. You don't want too many things happening in a test because then it becomes difficult to manage. Usually a simple test run where you have a user visit a page, interact with an element, have it make a response and display (or not display) something on a page is enough to find out whether a feature works or not. Here's a simple example:

visit "/about"
expect(page).to have_content("About Our Company")
fill_in "Email"
fill_in "Your message"
click_on "Send message"
expect(page).to have_text("You have sent your message successfully.")
Enter fullscreen mode Exit fullscreen mode

This example shows me that the user was able to go to the about page and see the title, interact with the form, and get a response that their message was sent. If this passes it means the app has the elements and functionality in place and it is behaving the way we expect it to.

With this in mind, I hope our spec file is self-explanatory just by reading the code.

Models

class User < ApplicationRecord
  authenticates_with_sorcery!

  validates :password, length: { minimum: 3 }, if: -> { new_record? || changes[:crypted_password] }
  validates :password, confirmation: true, if: -> { new_record? || changes[:crypted_password] }
  validates :password_confirmation, presence: true, if: -> { new_record? || changes[:crypted_password] }

  validates :email, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode

In our user model file we are declaring that we authenticate a User with the Sorcery gem. Then we lay our a couple validations; these validations are for "virtual attributes" that we will use on our forms. If you noticed when we ran the initial migration there were no password or password_confirmation attributes in the users table, but instead a salt and crypted_password attributes. The idea is that you don't store passwords in plain text in the database, so this code will take the virtual attributes in the form and encrypt them before storing them in the database. We also validate the uniqueness of email, which means two different user accounts cannot use the same email to sign up, there can only be one:

Highlander gif

Routes

# config/routes.rb
get 'register', to: 'user_registrations#new'
get 'login' => 'user_sessions#new', :as => :login
get 'dashboard', to: 'dashboard#show'
root "home#show"
Enter fullscreen mode Exit fullscreen mode

Currently we have 4 routes in place, all of which are get requests where we request some templates with data. Here we are requesting a register page, login page, dashboard page, and homepage. If you are not familiar with MVC frameworks, these routes actually point to a controller action, which in turn returns the template. The actions can return other things like JSON, but for now we'll focus on templates. Rails returns a template file if it has the same name as the action.

Controllers

# app/controller/dashboard_controller.rb

class DashboardController < ApplicationController
    def show
    end
end
Enter fullscreen mode Exit fullscreen mode

Our dashboard route points to the dashboard controller and show action. We don't need to write anything in the show action because by default it'll point to the show.html.erb view.

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  before_action :require_login

  private
  def not_authenticated
    redirect_to login_path, alert: "You must log in"
  end
end
Enter fullscreen mode Exit fullscreen mode

The code in our application controller will also be called in the controllers we write. The controllers we write extend from the application controller. In other words, for example, when we write class DashboardController < ApplicationController; end we are saying "run the code in the application controller, and if dashboard controller overrides anything, run that".

So by adding before_action :require_login to our application controller, it will also be called in every other controller that extends from it unless we say otherwise. That's how access is restricted to the dashboard page and you need to log in to access it.

Sorcery also has a method not_authenticated that also runs when a user attempts to access a restricted page, so we specify where we want the user to be redirected and also specify an alert.

# app/controllers/home_controller.rb

class HomeController < ApplicationController
    skip_before_action :require_login, only: :show
  def show
  end
end
Enter fullscreen mode Exit fullscreen mode

In the home controller, we add another method called skip_before_action which basically is "don't run this method before any action" and we specify which method by passing in the :require_login method, and specify the show action by passing it as an option. Essentially this code says the user doesn't need to be logged in to access it.

# app/controllers/user_sessions_controller.rb

class UserSessionsController < ApplicationController
  skip_before_action :require_login, only: %i[ new ]

  def new
  end
end

Enter fullscreen mode Exit fullscreen mode

Earlier in our application controller we called a method, the login_path route. The route points to the user_sessions controller and it's new action. We add the skip_before_action :require_login as well so we can give non-signed users the ability to log in. The new action will render the new.html.erb template.

# app/controllers/user_registrations_controller.rb

class UserRegistrationsController < ApplicationController
  skip_before_action :require_login, only: :new

  def new
  end
end
Enter fullscreen mode Exit fullscreen mode

Views

# app/views/dashboard/show.html.erb
<h1>Dashboard</h1>
Enter fullscreen mode Exit fullscreen mode
# app/views/home/show.html.erb
<h1>Homepage</h1>
Enter fullscreen mode Exit fullscreen mode

We are starting slow, and only adding the h1 tags with what is the title of the template.

# app/views/shared/_navbar.html.erb
<nav>
  <section>
    <%= link_to "Home", root_path %>
    <%= link_to "Dashboard", dashboard_path if current_user %>
  </section>
</nav>

<aside>
  <% if notice %><%= notice %><% end %>
  <% if alert %><%= alert %><% end %>
</aside>

Enter fullscreen mode Exit fullscreen mode

We're going to use a conditional statement to display the dashboard link when the user is signed in, and we'll have some methods to show us alerts or notices.

# app/views/layouts/application.html.erb

  <body>
    <%= render 'shared/navbar' %>
    <%= yield %>
  </body>
Enter fullscreen mode Exit fullscreen mode

Render the _navbar.html.erb partial in the application.html.erb template.

# app/views/user_sessions/new.html.erb

<h1 id="page-title">Login</h1>

<div>
  <p>Don't have an account? <%= link_to "Register", register_path, id: "register-user" %></p>
</div>

Enter fullscreen mode Exit fullscreen mode

I like the idea of using Capybara to click on the id of an element versus the content of the HTML because I might want to replace "Login" with an SVG icon and add the attribute title="login" (Note the title attribute is good practice for accessibility so it's something that is highly recommended to be added).

# app/views/user_registrations/new.html.erb
<h1 id="page-title">Register</h1>

Enter fullscreen mode Exit fullscreen mode

And lastly the user_registrations/new.html.erb template.

After having all these files in place, the test should pass. Awesome, but we are far from done. Let's look at our next feature.

User registers account

Spec

require 'rails_helper'

describe "User registers account", type: :system do
  scenario "user registers and sees dashboard" do
    visit login_path
    click_link 'register-user'

    email = Faker::Internet.email
    password = Faker::Internet.password
    fill_in "Email", with: email
    fill_in "Password", with: password
    fill_in "Password confirmation", with: password
    click_on "submit-register-user"

    expect(page).to have_text("You have registered successfully")

    page_title = find('h1#page-title')
    expect(page_title).to have_content("Dashboard")
    expect(page).to have_content(email)
  end
end


Enter fullscreen mode Exit fullscreen mode

This test is pretty fun. Here we use the Faker gem to create a fake user on the fly. From what I understand it is different from using the FactoryBot gem method (with user assignment) user = create :user because the latter will imply that there already exists a user in the database, the former helps us simulate the creation of a new user when filling out the new user registration form.

Routes

  get 'register', to: 'user_registrations#new'
  post 'register/user', to: 'user_registrations#create'
Enter fullscreen mode Exit fullscreen mode

We only need to set up two routes for this test, one to get the form, the other to post the form action. When the user clicks submit it will run the code shown below on the create action to create the user.

Controllers

# app/controllers/user_registrations_controller.rb

class UserRegistrationsController < ApplicationController
  skip_before_action :require_login, only: %i[ new create ]

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      auto_login(@user)
      redirect_to dashboard_path, notice: "You successfully registered"
    else
      flash.now[:alert] = "Registration failed"
      render :new
    end
  end

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

Enter fullscreen mode Exit fullscreen mode

Fun fact: The %i is for creating an output of non-interpolated array of symbols. Here's a cool blog post with some references.

When someone is filling out the registration form to create a new user account, the @user instance variable will load the new method on the User class. (@user = User.new). Then in the form we will fill in the attributes :email, :password, :password_confirmation so when we click submit it will take the attributes and use that to create a new user. The attributes are permitted (whitelisted) and required to be present during the create action and we pass them via the user_params private method. Private methods can only be accessed by other methods if they are within the same class.

What I really like about this particular create action that we wrote is that we got to use Sorcery's auto_login() method. We pass in the user that was just created and automatically log them in after registering so we can direct them to the dashboard. Clean and simple. From there they can log out and log in as they please, after we create that functionality of course.

Views

# app/views/user_registrations/new.html.erb

<h1 id="page-title">Register</h1>

<%= form_with(model: @user, url: register_user_path, method: :post) do |form| %>

  <div>
    <%= form.label :email %>
    <%= form.text_field :email %>
  </div>

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

  <div>
    <%= form.label :password_confirmation %>
    <%= form.password_field :password_confirmation %>
  </div>

  <div>
    <%= form.submit "Create Account", id: "submit-register-user" %>
  </div>
<% end %>

Enter fullscreen mode Exit fullscreen mode

This form specifies and loads up the model, the url for the form action which points to our user_registrations#create action, and we get explicit about our form submitting a POST method.

# app/views/dashboard/show.html.erb
<h1 id="page-title">Dashboard</h1>
Enter fullscreen mode Exit fullscreen mode

Now we add the id to the h1 tag for the Dashboard because our new test now calls for it. Considering the possibility that Capybara might get "confused" and see the "Dashboard" link in our nav and "Dashboard" title in our page, we add the id to make sure it finds the one we want it to find.

# app/views/shared/_navbar.html.erb
<nav>
  <section>
    <%= link_to "Home", root_path %>
    <%= link_to "Dashboard", dashboard_path if current_user %>
  </section>
  <section>
    <% if current_user %>
      You are logged in as: <%= current_user.email %>
    <% end %>
  </section>
</nav>

<aside>
  <% if notice %><%= notice %><% end %>
  <% if alert %><%= alert %><% end %>
</aside>

Enter fullscreen mode Exit fullscreen mode

Let's up the navbar partial to show the email of the user signed in. Here's how our current navbar looks like, we'll tidy it up later on.

That should be good for that feature.

User signs in to dashboard

Spec

# spec/system/user_sign_in_spec.rb

require 'rails_helper'

describe "User login flow", type: :system do
  scenario "user goes to log in page" do
    visit root_path
    click_on "sign-in-link"

    page_title = find("h1#page-title")
    expect(page_title).to have_content("Login")
  end

  scenario "user fills out form and signs in" do
    user = create :user

    visit login_path

    fill_in "Email", with: user.email
    fill_in "Password", with: "secret"
    click_on "sign-in-button"

    expect(page).to have_content(user.email)
    expect(page).to have_text('You have logged in successfully')
  end
end

Enter fullscreen mode Exit fullscreen mode

Now we are going to make sure an existing user can log in and we'll use FactoryBot to test that. While running the test it'll create a new user with predefined attributes which you can refer below in the section titled "Factory".

The first test will make sure the user can click to the login page. Super simple, all we have to do is create the link, but we'll refactor the navbar partial a bit as you'll see in the "Views" section below.

Our second test will have an existing user go to the login page, fill out the form and sign in and see their email displayed on the page and a successful login message.

Factory

# spec/factories/user.rb

FactoryBot.define do
  factory :user do
    sequence(:email) { |i| "user#{i}@example.com" }
    password { "secret" }
    password_confirmation { "secret" }

    salt { salt = "asdasdastr4325234324sdfds" }
    crypted_password { Sorcery::CryptoProviders::BCrypt.encrypt("secret", salt) }
  end
end

Enter fullscreen mode Exit fullscreen mode

I'm hoping when we created the User model with Sorcery that it created this factory for you, also refer to my RSpec setup in Rails 7 if you haven't seen it yet.

Let's make our user factory look like the code above.

Routes

# config/routes.rb

post 'login' => "user_sessions#create"
Enter fullscreen mode Exit fullscreen mode

This route will point to the create action in the user_sessions_controller to create a session for the user. The code to make it happen will be in the following controller section.

Controllers

# app/controllers/user_sessions_controller.rb

class UserSessionsController < ApplicationController
  skip_before_action :require_login, only: %i[ new create ]

  def new
  end

  def create
    @user = login(params[:email], params[:password])

    if @user
      redirect_back_or_to(dashboard_path, notice: "You have logged in successfully")
    else
      flash.now[:alert] = "Log in failed"
      render :new
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

Sorcery gives us a login method where we can pass in our params from the form and it logs us in, if the params exist, or in other words if @user = true then it will redirect you to the dashboard or the page you tried to access before being prompted for your login credentials. If the login failed, it will render the login form again and let you know it failed.

Views

# app/views/shared/_navbar.html.erb
<nav>
  <section>
    <%= link_to "Home", root_path %>
  </section>
  <section>
    <% if current_user %>
      <%= link_to "Dashboard", dashboard_path %>
      You are logged in as: <%= current_user.email %>
    <% else %>
      <%= link_to "Login", login_path, id: 'sign-in-link' %>
    <% end %>
  </section>
</nav>

<aside>
  <% if notice %><%= notice %><% end %>
  <% if alert %><%= alert %><% end %>
</aside>

Enter fullscreen mode Exit fullscreen mode

We moved the login and log out links into the same conditional statement as the dashboard link because they all pertain to each other.

Run the test and it will pass! On to the next feature!

User signs out

Spec

# spec/system/user_sign_out_spec.rb

require 'rails_helper'

describe "User signs out", type: :system do
  before do
    user = create :user

    visit login_path

    fill_in "Email", with: user.email
    fill_in "Password", with: "secret"
    click_on "sign-in-button"

    expect(page).to have_content(user.email)
    expect(page).to have_text('You have logged in successfully')
  end

  scenario "user signs out from dashboard and get taken to homepage" do
    visit dashboard_path
    expect(page).to have_current_path(dashboard_path)
    click_on 'sign-out-link'
    expect(page).to have_text("You have logged out")
    expect(page).to have_current_path(root_path)
  end
end

Enter fullscreen mode Exit fullscreen mode

Now we are going to write a test where we have the user sign out from the dashboard and gets redirected back to the home page. It is implied that the user is already signed in, so we need to reflect that in the test. The before do statement will allow us to set up a user before running the tests that we want to pass.

Routes

# config/routes.rb
delete 'logout', to: 'user_sessions#destroy', as: :logout
Enter fullscreen mode Exit fullscreen mode

Add the route we'll use to perform the destroy action on a user's session so they can log out.

Controllers

# app/controllers/user_sessions_controller.rb

class UserSessionsController < ApplicationController
  skip_before_action :require_login, only: %i[ new create ]

  def new
  end

  def create
    @user = login(params[:email], params[:password])

    if @user
      redirect_back_or_to(dashboard_path, notice: "You have logged in successfully")
    else
      flash.now[:alert] = "Log in failed"
      render :new
    end
  end

  def destroy
    logout
    redirect_to root_path, status: :see_other, notice: "You have logged out"
  end
end

Enter fullscreen mode Exit fullscreen mode

Views

# app/views/shared/_navbar.html.erb

<nav>
  <section>
    <%= link_to "Home", root_path %>
  </section>
  <section>
    <% if current_user %>
      <%= link_to "Dashboard", dashboard_path %>
      You are logged in as: <%= current_user.email %>
      <%= link_to "Logout", :logout, id: 'sign-out-link', data: { "turbo-method": :delete } %>
    <% else %>
      <%= link_to "Login", login_path, id: 'sign-in-link' %>
    <% end %>
  </section>
</nav>

<aside>
  <% if notice %><%= notice %><% end %>
  <% if alert %><%= alert %><% end %>
</aside>

Enter fullscreen mode Exit fullscreen mode

Fun facts

So I want to point out a couple things going on here that may look different in comparison to older versions of Rails.

I'll focus on these lines of code:

# in the route
delete 'logout', to: 'user_sessions#destroy', as: :logout

# in the controller
redirect_to root_path, status: :see_other, notice: "You have logged out"

# in the view
<%= link_to "Logout", :logout, id: 'sign-out-link', data: { "turbo-method": :delete } %>
Enter fullscreen mode Exit fullscreen mode

What you see in the route is how Rails has always laid out their resources. Rails convention is that you map an HTTP verb to a controller action which in turn also maps to a CRUD operation in the database.

Prior to Rails 7, when you would want to log out or destroy a resource (a post or image), you would write <%= link_to "Sign out", destroy_user_session_path, method: :delete %>. The method: :delete part relied on an old library, rails-ujs and it helped make NON-Get requests from hyperlinks. Now with Turbo, the solution is to add data: { "turbo-method": :delete } so you don't get an error saying the link_to helper was looking for a GET resource. However, Turbo is not handling the redirect as we expect it, or at all, we need to explicitly say we want a 303 redirect so we add status: :see_other as an option in our destroy action.

Being able to control the behavior of my authentication flow with a few controllers and helpers due to the low-level nature of Sorcery really makes the app feel light-weight and I don't feel like I am overriding a ton of code that often comes with another authentication solution.

Run the test and it will pass. Next we are going to refactor some duplicate code and abstract it into a helper.

Refactor and create a test helper method in spec

In our last feature we added a test where we log in the user in order for them to perform the function to log out. This was the second implementation that we made where we had to have a user signed in to perform some interactions with the web.

I can already foresee that we are going to need to log in the user multiple throughout the development of the app, and rewriting the same code repeatedly is not a good use of our time. If it was a couple use cases then maybe that's ok, but if you find repetition through out your app, you might want to abstract that code into a helper method that you can call through out the app.

We are going to modify two spec files: user_sign_in_spec.rb and user_sign_out_spec.rb.

This is the part of the code we will abstract into a test helper method.

    visit login_path
    fill_in "Email", with: user.email
    fill_in "Password", with: "secret"
    click_on "sign-in-button"
Enter fullscreen mode Exit fullscreen mode

Note: While there are many ways you can log in a user, currently this is how our app works. We only have one login portal. If we need to update the helper method or create a new one to test a new method of logging in (for example through a social media account) then we would write the code to make it happen.

We'll be creating a new file and modifying the spec/rails_helper.rb file.

Create spec/support/sorcery_test_helper.rb and add the following code.

# spec/support/sorcery_test_helpers_rails.rb

module Sorcery
  module TestHelpers
    module Rails
      def sign_in(user)
        visit login_path
        fill_in "Email", with: user.email
        fill_in "Password", with: "secret"
        click_on "sign-in-button"
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Require this file in the rails_helper.rb

# spec/rails_helper.rb

# Add additional requires below this line. Rails is not loaded until this point!
require_relative 'support/factory_bot'
require_relative 'support/chrome'
require_relative 'support/sorcery_test_helpers_rails'
Enter fullscreen mode Exit fullscreen mode

And include the modules in RSpec's configuration in the same file.

# spec/rails_helper.rb
RSpec.configure do |config|
  config.include Sorcery::TestHelpers::Rails
end
Enter fullscreen mode Exit fullscreen mode

Now change the spec files to look like this with the new test helper method

# spec/system/user_sign_in_spec.rb
require 'rails_helper'

describe "User login flow", type: :system do
    ... 

  scenario "user fills out form and signs in" do
    user = create :user
    sign_in(user)

    expect(page).to have_content(user.email)
    expect(page).to have_text('You have logged in successfully')
  end
end

Enter fullscreen mode Exit fullscreen mode
# spec/system/user_sign_out_spec.rb

require 'rails_helper'

describe "User signs out", type: :system do
  before do
    user = create :user
    sign_in(user)
  end

  scenario "user signs out from dashboard and get taken to homepage" do
    visit dashboard_path
    expect(page).to have_current_path(dashboard_path)
    click_on 'sign-out-link'
    expect(page).to have_text("You have logged out")
    expect(page).to have_current_path(root_path)
  end
end

Enter fullscreen mode Exit fullscreen mode

We will omit the expectation of seeing the email and success message in our user_sign_out_spec since we already know it works from our user_sign_in_spec.

Run the test and it should pass!

This article covered some basic authentication features with Sorcery and I think we are at a good point to get familiar with this functionality. Next article we will be creating a reset/forgot password feature, so until if you try out this code don't forget your credentials!

While other authentication gems do a lot out of the box for you, it tends to do it a very specific way, and often you override things to make it work with your app. With Sorcery, we only add what we need and it is low-level enough to customized how users sign in and out with your app, but high-level enough where you don't have to stress too much about security.

I emailed the current maintainer and he plans to add some new features for Sorcery: "JWT support, WebAuthn integration, and updating the OAuth integration to use Omniauth are the major features slated for v1".

So that's pretty exciting. Please consider sponsoring the project on Github at only $5 to show that there is a need for another great authentication solution for the future of Rails.

Top comments (0)