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
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
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
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
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.")
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
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:
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"
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
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
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
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
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
Views
# app/views/dashboard/show.html.erb
<h1>Dashboard</h1>
# app/views/home/show.html.erb
<h1>Homepage</h1>
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>
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>
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>
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>
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
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'
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
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 %>
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>
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>
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
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
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"
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
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>
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
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
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
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>
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 } %>
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"
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
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'
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
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
# 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
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)