This is a tutorial on how to set up authentication (verifying who you are) and authorization (what you are permitted to do) using Ruby 2.7, Rails 6.0.3 and two popular Ruby gems: Devise and cancancan.
The code for this tutorial is on GitHub: https://github.com/hibbard-eu/authentication-with-devise-and-cancancan
The Scenario
The app we’ll be coding is a store. In order for people to use the store, they’ll need to register an account. The store will also have sellers (otherwise it would be a rubbish store) and an admin.
This means we’ll need the following resources: Item
, User
, Role
.
Here’s a UML diagram showing how they relate to one another:
Note that users can have a maximum of one role. The permissions for each of these users will break down as follows:
- Unregistered users are redirected to the sign up page
- Registered users: can view items
- Sellers: can view items, create items, as well as update and destroy any items that belong to them
- Admin: can perform any CRUD operation on any resource
So let’s get started.
Generating the Project Files
Let’s start by creating a new project.
rails new store
In its current version, Rails uses Webpacker as its default JavaScript compiler. Webpacker expects us to have both Node.js and the Yarn package manager installed. If you don’t have Node installed already, I would recommend using a version manager which will let you switch between versions with ease and which also negates certain permission errors.
For this tutorial, I am using the current LTS version of Node (12.18.1) and the latest version of Yarn (1.22.4). Yarn should be installed globally using npm -i g yarn
.
Once the project has been created, we can change into the store
directory and remove the following line from our Gemfile
:
- gem 'jbuilder', '~> 2.7'
Jbuilder is used for generating and rendering JSON responses for API requests in Rails. We won’t be needing this functionality.
Next, let’s use the scaffold generator to create our project files:
rails g scaffold user name:string role:belongs_to
rails g scaffold role name:string description:string
rails g scaffold item name:string description:text 'price:decimal{5,2}', user:belongs_to
If you’re wondering what price:decimal{5,2}
does, it adds the following to the migration file:
.decimal :price, precision: 5, scale: 2
A decimal with a precision of 5 and a scale of 2 can range from -999.99 to 999.99.
Finally, run the migrations with the following command.
rake db:migrate
Editing the Boilerplate
We’ll need to tailor the files that Rails has generated to suit our needs.
Start by removing the following line from the top of the index
and show
view templates for the User
, Role
and Item
resources.
- <p id="notice"><%= notice %></p>
In items/index.html.erb
change “User” to “Seller” and item.user_id
to item.user.name
.
In items/show.html.erb
change “User” to “Seller” and @item.user_id
to @item.user.name
.
In items/_form.html.erb
remove the complete user_id
field (including the surrounding div tags).
- <div class="field">
- <%= form.label :user_id %>
- <%= form.text_field :user_id %>
- </div>
In users/index.html.erb
change user.role_id
to user.role.name
In users/show.html.erb
change @user.role_id
to @user.role.name
In users/_form.html.erb
change <%= form.text_field :role_id %>
to:
<%= collection_select(
:user, :role_id, Role.all, :id, :name, { prompt: true }
) %>
At this point if you start up Puma (rails s
) and visit http://localhost:3000/items, http://localhost:3000/roles, or http://localhost:3000/users, you can see that our basic scaffolding is working (albeit without any data)
Everything is now setup to implement the authentication logic.
Authentication with Devise
Devise is a full-featured authentication solution for Rails based on Warden. One of the things I like about it most (aside from the ease of use) is that it is built on a modularity concept. This makes it easy to include only those features you need in your application.
To get started with Devise, add it to our project’s Gemfile:
bundle add devise
Then run Devise’s installation generator:
rails g devise:install
This command generates a couple of files: an initializer and a locale file that contains all of the messages that Devise needs to display.
As per the post installation message, we’ll need to make a couple of alterations to our config files.
Add the following line to the bottom of config/environments/development.rb
:
config.action_mailer.default_url_options = { host: 'localhost:3000' }
And set a root route in /config/routes.rb
root to: 'items#index'
We’re going to use the User
model to handle authentication and Devise provides a generator for doing just that:
rails g devise User
rake db:migrate
Devise won’t override our current User
model – it will simply add a bunch of attributes to it.
Edit the /app/views/layouts/application.html.erb
to include the following immediately before the <%= yield %>
:
<% if user_signed_in? %>
Signed in as <%= current_user.email %>. Not you?
<%= link_to "Edit profile", edit_user_registration_path %>
<%= link_to "Sign out", destroy_user_session_path, method: :delete %>
<% else %>
<%= link_to "Sign up", new_user_registration_path %> or
<%= link_to "sign in", new_user_session_path %>
<% end %>
<% flash.each do |name, msg| %>
<%= content_tag :div, msg, id: "flash_#{name}" %>
<% end %>
And add the following line to the top of ItemsController
, RolesController
and UsersController
:
before_action :authenticate_user!
This will ensure that the user is logged in before being able to access any of these resources.
Now is the time to add some data. Alter db/seeds.rb
thus:
r1 = Role.create({ name: 'Regular', description: 'Can read items' })
r2 = Role.create({ name: 'Seller', description: 'Can read and create items. Can update and destroy own items' })
r3 = Role.create({ name: 'Admin', description: 'Can perform any CRUD operation on any resource' })
u1 = User.create({ name: 'Sally', email: 'sally@example.com', password: 'aaaaaaaa', password_confirmation: 'aaaaaaaa', role_id: r1.id })
u2 = User.create({ name: 'Sue', email: 'sue@example.com', password: 'aaaaaaaa', password_confirmation: 'aaaaaaaa', role_id: r2.id })
u3 = User.create({ name: 'Kev', email: 'kev@example.com', password: 'aaaaaaaa', password_confirmation: 'aaaaaaaa', role_id: r2.id })
u4 = User.create({ name: 'Jack', email: 'jack@example.com', password: 'aaaaaaaa', password_confirmation: 'aaaaaaaa', role_id: r3.id })
i1 = Item.create({ name: 'Rayban Sunglasses', description: 'Stylish shades', price: 99.99, user_id: u2.id })
i2 = Item.create({ name: 'Gucci watch', description: 'Expensive timepiece', price: 199.99, user_id: u2.id })
i3 = Item.create({ name: 'Henri Lloyd Pullover', description: 'Classy knitwear', price: 299.99, user_id: u3.id })
i4 = Item.create({ name: 'Porsche socks', description: 'Cosy footwear', price: 399.99, user_id: u3.id })
Then run:
rake db:seed
Finally, we can restart the Rails server and log in using the email address and password of one of the users we defined in the seeds file.
Notice that if we are logged out and try and access any of the protected resources, we are redirected to a log in page with the message “You need to sign in or sign up before continuing.”
Exciting times, huh?
A Bit More About Devise
As I mentioned briefly above, Devise is based on a modularity concept. To expand on that, take a peek at the User
model. You should see:
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
belongs_to :role
end
In its default configuration, Devise comes with five of ten modules enabled.
You can see these in action in our app – for example Rememberable
remembers the user’s authentication in a saved cookie (embodied by the checkbox on the login form that says “Remember me”).
If you didn’t require this functionality, simply comment out :rememberable
and it won’t be included.
I would encourage you to take the time to read through what all of the modules do. They are listed on the project’s readme
Now, to get our app working properly, we’ll going to need to make a few tweaks.
Adding an Admin Namespace
Let’s start by namespacing the CRUD interface. This is necessary as otherwise the user registration routes and user management routes can conflict. Alter config/routes.rb
like so:
devise_for :users
scope '/admin' do
resources :users
end
Then, in the User
model, we can add the missing association (app/models/user.rb
):
has_many :items, dependent: :destroy
We are specifying dependent: :destroy
as if we delete a seller, it also makes sense to delete the items they are selling.
And in the ItemsController
, make sure that a user is assosciated with each item before it is saved:
def create
@item = Item.new(item_params)
@item.user_id = current_user.id
...
end
Once you have restarted the server, you will be able to (kinda) manage users at http://localhost:3000/admin/users. I write kinda, as you’ll not yet be able to create new users. We’ll get to that a little later.
Customizing Devise
Now, if you click on the “sign up” or “edit profile” links, you’ll notice that the user’s name attribute is missing. It would be nice to have users be able to specify their names when registering, so let’s fix that.
Devise comes with many views built-in. If we would like to customize these pages, we must transfer the Devise view files into our project so we can modify them. Luckily, Devise has a generator to do just that:
rails g devise:views
This will copy across a bunch of templates to the app/views/devise
directory.
Change into this folder, then into the registrations
sub-folder. Locate the new.html.erb
and edit.html.erb
files and add the following just after <%= render "devise/shared/error_messages", resource: resource %>
:
<div class="field">
<%= f.label :name %><br />
<%= f.text_field :name %>
</div>
This will add the correct fields to the appropriate forms, but were you to attempt to fill them out at this point, you would notice that Devise isn’t saving your changes.
The reason for this is that the extra parameter you are passing to the controller (name
in this case) needs to be expressly permitted.
You can do this as follows in the ApplicationController
:
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
devise_parameter_sanitizer.permit(:account_update, keys: [:name])
end
end
Also, when a user signs up, they need to be assigned a role. We can make this default to “Regular”. And while we’re at it, we can also add some validation to make sure a user enters a name.
To do this, alter /app/models/user.rb
as follows:
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
belongs_to :role, optional: true
has_many :items, dependent: :destroy
validates :name, presence: true
before_save :assign_role
def assign_role
self.role = Role.find_by name: 'Regular' if role.nil?
end
end
Take a moment at this point to start up the app again and make sure that everything is working. It is? Good.
Creating New Users
The final thing we’re going to do, is give the admin the power to create new users via the admin interface, as well as to edit any of the existing users’ attributes.
First off, let’s add the user’s email address to the views.
app/views/users/index.html.erb
:
<th>Role</th>
<th>Email</th>
<th colspan="3"></th>
<td><%= user.role.name %></td>
<td><%= user.email %></td>
<td><%= link_to 'Show', user %></td>
app/views/users/show.html.erb
:
<p>
<strong>Email:</strong>
<%= @user.email %>
</p>
app/views/users/_form.html.erb
:
<div class="field">
<%= form.label :email %><br>
<%= form.text_field :email %>
</div>
In the form partial, we can also add a password field:
<div class="field">
<%= form.label :password %><br>
<%= form.password_field :password, placeholder: "Leave blank if unchanged" %>
</div>
Now things get a little complicated, so as to be able to create a new user via our admin interface (http://localhost:3000/admin/users/new), we need to permit the attributes that Devise requires:
/app/controllers/users_controller.rb
def user_params
params.require(:user).permit(
:email,
:password,
:password_confirmation,
:name,
:role_id
)
end
That’s ok, but if we try and edit an existing user (e.g. http://localhost:3000/admin/users/1/edit), then we are prompted to set a new password every time we want to update something. This is not ideal.
To get around this, we can take advantage of Devise’s update_without_password method.
Alter the update method in UsersController
as shown (making sure to include the protected method needs_password?
):
def update
if user_params[:password].blank?
user_params.delete(:password)
user_params.delete(:password_confirmation)
end
successfully_updated = if needs_password?(@user, user_params)
@user.update(user_params)
else
@user.update_without_password(user_params)
end
if successfully_updated
redirect_to @user, notice: 'User was successfully updated.'
else
render :edit
end
end
private
def needs_password?(_user, params)
params[:password].present?
end
And with that we can now create, update and delete users.
Enabling the Trackable Module
Before we finish looking at Devise and authentication, let’s enable the trackable module, to give us a little more information on our users.
In /app/models/users.rb
alter the code like so:
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
...
end
Create a new migration:
rails generate migration AddDeviseTrackableColumnsToUsers
This will create a new file in the db/migrate
folder. Alter it like so:
class AddDeviseTrackableColumnsToUsers < ActiveRecord::Migration[6.0]
def self.up
change_table :users do |t|
t.integer :sign_in_count, default: 0, null: false
t.datetime :current_sign_in_at
t.datetime :last_sign_in_at
t.string :current_sign_in_ip
t.string :last_sign_in_ip
end
end
end
Then run the migration with rake db:migrate
. As you can see from the migration file, this will add several columns to the user table.
Now, add the following to /app/views/users/show.html.erb
:
<p>
<strong>Joined on:</strong>
<%= @joined_on %>
</p>
<p>
<strong>Last logged in on:</strong>
<%= @last_login %>
</p>
<p>
<strong>No. times logged in:</strong>
<%= @user.sign_in_count %>
</p>
And the logic to the UsersController
:
def show
@joined_on = @user.created_at.to_formatted_s(:short)
if @user.current_sign_in_at
@last_login = @user.current_sign_in_at.to_formatted_s(:short)
else
@last_login = 'never'
end
end
Also, to show us which users are associated with a particular role, add the association to the Role
model.
app/models/role.rb
:
has_many :users, dependent: :restrict_with_exception
The restrict_with_exception
option will cause an ActiveRecord::DeleteRestrictionError
exception to be raised if you try to delete a Role
record, but it has associated User
records.
Next, add the following to app/views/roles/show.html.erb
:
<p>
<strong>Assosciated users:</strong>
<%= @assosciated_users %>
</p>
And the logic to the RolesController
:
def show
if @role.users.empty?
@assosciated_user = 'None'
else
@assosciated_users = @role.users.map(&:name).join(', ')
end
end
And there we go. In not very much code we have implemented a robust authentication solution for our app, as well as building a simple interface to administer users.
Take a moment to restart the app, have a play with what we’ve got so far and assure yourself that it is working.
Authorization With cancancan
Cancancan is an authorization library for Ruby on Rails which restricts what resources a given user is allowed to access. It is the continuation of the now defunct cancan project started by Ryan Bates (of Railscast fame).
To install it, run:
bundle add cancancan
CanCanCan expects a current_user
method to exist in the controller, which we have thanks to Devise.
It also expects user permissions to be defined in an Ability
class, for which it includes a generator:
rails g cancan:ability
This will generate a file at app/models/ability.rb
. Edit it like so:
class Ability
include CanCan::Ability
def initialize(user)
if user.admin?
can :manage, :all
else
can :read, :all
end
end
end
As you can see, abilities are defined with the method can
. This method takes two parameters: the first is the action that we want to perform and the second is the model class that the action applies to. In the above example we are permitting the admin to perform any CRUD action on any resource within our app and restricting all other users to just being able to read.
We don’t care about the permissions of those users who have not yet logged in (you remember they should just be directed to the sign in page), but if we did (for example, if they could view items), you would have to initialize the user
variable to something sensible. Otherwise you would end up checking for nil values all over the place.
def initialize(user)
user ||= User.new # Guest user
...
end
Next we need to define an admin?
method in our User
model.
app/models/user.rb
:
def admin?
role.name == 'Admin'
end
And in the UsersController
we need to specify which users are authorized to do what. You can do this on a per action basis using the authorize!
method, which in turn performs the can?
check and raises an exception if needed.
For example to check if a given user can edit an item:
def edit
authorize! :edit, @item
end
However, repeating this across every action in our app would soon become tiresome. Luckily there is an easier way to do this if you are using RESTful style controllers, namely with the method load_and_authorize_resource
. As the name suggests, this method loads the appropriate resource and authorizes it in a before action.
Place it at the top of your ItemsController
.
class ItemsController < ApplicationController
before_action :authenticate_user!
load_and_authorize_resource
...
end
As this method loads the necessary resource for us based on the action we are performing, we can also remove any lines of code that set the instance variable in each action.
In the case of our ItemsController
that would mean removing the following lines:
class ItemsController < ApplicationController
- before_action :set_item, only: [:show, :edit, :update, :destroy]
def new
- @item = Item.new
end
def create
- @item = Item.new(item_params)
...
end
private
- def set_item
- @item = Item.find(params[:id])
- end
end
If you now restart the server and access http://localhost:3000/items as a regular user or as a seller, you will be in read only mode. As an admin, you will still be able to create, edit and delete records.
Now we need to repeat the above steps for our other two resources:
app/controllers/users_controller.rb
class UsersController < ApplicationController
- before_action :set_user, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!
+ load_and_authorize_resource
def new
- @user = User.new
end
def create
- @user = User.new(user_params)
...
end
private
- def set_user
- @user = User.find(params[:id])
- end
end
app/controllers/roles_controller.rb
class RolesController < ApplicationController
- before_action :set_role, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!
+ load_and_authorize_resource
def new
- @role = Role.new
end
def create
- @role = Role.new(role_params)
...
end
private
- def set_role
- @role = Role.find(params[:id])
- end
end
This ensures that regular users and sellers are also in read-only mode when accessing Roles and Users.
A Nicer Error Page
Next, we can ensure that our users see a nicer error page than the one with the AccessDenied exception they currently see when attempting to access something they are not authorized to.
We can do this using a method called rescue_from that we can place in our ApplicationController
. We’ll pass it a block inside of which we’ll make the application show a flash error message and redirect to the home page.
app/controllers/application_controller.rb
rescue_from CanCan::AccessDenied do
flash[:error] = 'Access denied!'
redirect_to root_url
end
Now all that remains to do is to set the permissions of regular users and sellers to something sensible.
Let’s define two more methods to check the user’s current role:
app/models/user.rb
def seller?
role.name == 'Seller'
end
def regular?
role.name == 'Regular'
end
Then it’s a matter of updating the abilities in the Ability class.
app/models/ability.rb
if user.admin?
can :manage, :all
elsif user.seller?
can :read, Item
can :create, Item
can :update, Item do |item|
item.try(:user) == user
end
can :destroy, Item do |item|
item.try(:user) == user
end
elsif user.regular?
can :read, Item
end
The permissions for admins and regular users should be straight forward.
In the case of sellers however, things become a little more complicated when dealing with the update and destroy actions (you remember that sellers should only be able to update and delete their own items).
Here we pass can
a block which will receive the instance of the model we’re checking. The block should return true
or false
depending on whether the action should be allowed, or not. Within the block we’ll use Rails’ try method to check that the item’s user
attribute is the current user. Using try
covers the eventuality that the item is nil
and will prevent an exception being raised.
Finally, so that regular users and sellers don’t see any links to actions they are not permitted to perform, alter app/views/items/index.html.erb
like so:
<td>
<% if can? :update, item %>
<%= link_to 'Edit', edit_item_path(item) %>
<% end %>
</td>
<td>
<% if can? :destroy, item %>
<%= link_to 'Destroy', item, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
</td>
...
<% if can? :create, Item %>
<%= link_to 'New Item', new_item_path %>
<% end %>
The Finishing Touches
Before we end, let’s add some navigation to app/views/layouts/application.html.erb
so as to help admin users switch between resources.
<body>
<div class="flex-container">
<header>
<% if user_signed_in? %>
Signed in as <%= current_user.email %>.<br>
Not you?
<%= link_to "Edit profile", edit_user_registration_path %>
<%= link_to "Sign out", destroy_user_session_path, method: :delete %>
<% else %>
<%= link_to "Sign up", new_user_registration_path %> or
<%= link_to "sign in", new_user_session_path %>
<% end %>
<nav>
<% if @current_user&.admin? %>
<%= link_to "Items", items_path %> |
<%= link_to "Users", users_path %> |
<%= link_to "Roles", roles_path %>
<% end %>
</nav>
</header>
<% flash.each do |name, msg| %>
<%= content_tag :div, msg, id: "flash_#{name}" %>
<% end %>
<main>
<%= yield %>
</main>
</div>
</body>
The extra markup lets us add some basic styling to make the app more visually appealing. I’m not going to list the CSS here, you can copy it out of the scaffold.scss file on GitHub. The table styling is courtesy of W3schools.
When you’ve applied the styles, here’s what the app should look like:
Next, we can add some JavaScript to fade out, then remove our flash messages after a delay of 3.5 seconds. To this end, make a new folder named src
in the app/javascript
folder. In app/javascript/src
create a file named index.js
and add the following:
document.addEventListener('DOMContentLoaded', () => {
const flashMessage = document.querySelector('div[id^="flash_"]');
if (flashMessage) {
setTimeout(() => {
flashMessage.classList.add('hide');
flashMessage.classList.remove('show');
setTimeout(() => {
flashMessage.parentElement.removeChild(flashMessage);
}, 1500);
}, 3500);
}
});
Then require it in app/javascript/packs/application.js
to ensure it is included with our JavaScript bundle.
require('@rails/ujs').start();
require('turbolinks').start();
require('@rails/activestorage').start();
require('channels');
require('../src/index');
Finally, the very last thing I want to examine (promise!) is a slightly lesser documented feature of Devise. You remember that we initially wanted users who are not logged in to be redirected to the sign-in page? Well, wouldn’t it be nicer if we directed them to a welcome page with a link to either register or sign in?
To do this we need a WelcomeController
and a corresponding view. These can be generated with the following command:
rails g controller welcome index
Now edit the newly generated view template at app/views/welcome/index.html.erb
:
<h1>Welcome to the Store!</h1>
<h2>Selling you things you don't need since 2020</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Adipisci
eveniet, repellat quae sed hic obcaecati nam exercitationem saepe
quod totam dolore explicabo culpa iure deserunt? Dignissimos, fuga,
adipisci. Temporibus, aliquid.
</p>
Once that is in place, alter your config/routes.rb
to take advantage of Devise’s authenticated
route thus:
authenticated :user do
root to: 'items#index', as: :authenticated_root
end
root to: 'welcome#index'
Restart your server and we’re done!
In case you missed it at the beginning, the code for this tutorial is on GitHub: https://github.com/hibbard-eu/authentication-with-devise-and-cancancan
This was quite a long post — I hope it proves useful for people. If you have any questions or comments, I’d be glad to hear them below.
Comments