Andrés
•
18 August 2024
•
9 mins
Adding authentication and authorization to your APIs is a crucial part of developing your applications. These two strategies play a significant role in ensuring security and proper access to the data and services you will offer.
To begin, I think it’s important to understand the difference between authentication and authorization in a REST API. The former refers to the process of verifying the identity of a user or service, while the latter corresponds to determining whether that authenticated user or service has the permissions to access the resource or perform the action they are attempting.
Where are we?
When it comes to implementing authentication, there are common strategies, and there’s no need to reinvent the wheel. To classify them, we can refer to the options defined by OpenAPI Initiative:
Authorization
header):
Authorization
header.Authorization
header.Similarly, we won’t reinvent the wheel with authorization either, so here are some of the most common strategies:
Now that we better understand the concepts, we can move on to practice. In our case, we will use devise-api for authentication (Bearer Token) and Pundit for authorization (PBAC). We have already added these gems to our project in the previous chapters. But if you haven’t done so, add the following to your Gemfile:
gem 'devise'
gem 'devise-api'
gem 'pundit'
Since authentication relies on Devise, the first step is to follow the installation steps for this gem:
rails generate devise:install
rails generate devise User
Then run the devise_api’s generator:
rails generate devise_api:install
Run the migrations:
rails db:migrate
And finally, we add the api
module to the model that includes Devise, which is the User
class. For this case, we can remove all other modules and keep only the following:
class User < ApplicationRecord
devise :database_authenticatable, :api
has_many :roles
has_many :projects, through: :roles
end
To expose the endpoints of devise-api
, we need to use the original devise_for
method from Devise in the routes.rb
file:
Rails.application.routes.draw do
devise_for :users
# ...
end
revoke_user_tokens POST /users/tokens/revoke(.:format) devise/api/tokens#revoke
refresh_user_tokens POST /users/tokens/refresh(.:format) devise/api/tokens#refresh
sign_up_user_tokens POST /users/tokens/sign_up(.:format) devise/api/tokens#sign_up
sign_in_user_tokens POST /users/tokens/sign_in(.:format) devise/api/tokens#sign_in
info_user_tokens GET /users/tokens/info(.:format) devise/api/tokens#info
Now you can use the following helpers in the controllers to protect the endpoints with authentication. In our example, we want to cover the entire API, so we can add them to the ApplicationController
so that other controllers inherit them. Additionally, I will add a method that might be useful:
class ApplicationController < ActionController::API
skip_before_action :verify_authenticity_token, raise: false
before_action :authenticate_devise_api_token!
def current_user
current_devise_api_user
end
end
At this point, almost all tests should be failing. We will need to add authentication to them. To make this task easier, I suggest adding the following factory:
# test/factories/devise_api_token.rb
FactoryBot.define do
factory :devise_api_token, class: "Devise::Api::Token" do
association :resource_owner, factory: :user
access_token { SecureRandom.hex(32) }
refresh_token { SecureRandom.hex(32) }
expires_in { 1.hour.to_i }
trait :access_token_expired do
created_at { 2.hours.ago }
end
trait :refresh_token_expired do
created_at { 2.months.ago }
end
trait :revoked do
revoked_at { 5.minutes.ago }
end
end
end
Then you should create tokens and send it into the HTTP headers:
class ProjectsControllerTest < ActionDispatch::IntegrationTest
setup do
# ...
@token = FactoryBot.create(:devise_api_token).access_token
end
test 'should get index' do
get projects_url, headers: { Authorization: "Bearer #{@token}" }, as: :json
assert_response :success
end
# ...
end
The problem mentioned in Chapter 1 had several conditions, all valid. However, to simplify the task and for demonstration purposes, we will only cover the authorization for Tasks. Only Users
with the admin role in the project will be able to create them (create); others will not.
Before proceeding, we need to ensure that we have followed the installation steps for Pundit. These are:
ApplicationController
: class ApplicationController < ActionController::Base
include Pundit::Authorization
#... También deberías manejar los errores de Unauthorized:
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
#...
private
def user_not_authorized(exception)
policy_name = exception.policy.class.to_s.underscore
render json: { message: "#{policy_name}.#{exception.query}" }, status: :forbidden
end
end
Create an ApplicationPolicy, it will the base for others policies:
rails g pundit:install
And we are ready. Let’s make a failing test:
#test/controllers/task_controllers_test.rb
# ...
test 'should create task' do
token = FactoryBot.create(:devise_api_token)
FactoryBot.create(:role, user: token.resource_owner, role: :manager, project: @project)
assert_difference('Task.count') do
post project_tasks_url(@project), headers: { Authorization: "Bearer #{token.access_token}" },
params: { task: { description: @task.description, project_id: @task.project_id, status: @task.status, title: @task.title } }, as: :json
end
assert_response :created
end
test 'should not create task' do
token = FactoryBot.create(:devise_api_token)
FactoryBot.create(:role, user: token.resource_owner, role: :contributor, project: @project)
assert_no_difference('Task.count') do
post project_tasks_url(@project), headers: { Authorization: "Bearer #{token.access_token}" },
params: { task: { description: @task.description, project_id: @task.project_id, status: @task.status, title: @task.title } }, as: :json
end
assert_response :forbidden
end
# ...
Now let’s make it work. Pundit operates with Policy files; we need to create one following the gem’s instructions, inheriting from our ApplicationPolicy
, and implementing our validation logic (you can also use the generator):
# app/policies/post_policy.rb
class TaskPolicy < ApplicationPolicy
def create?
user.roles.exists?(project: record.project, role: :manager)
end
end
Add the authorization method to the controller’s method:
# app/controllers/tasks_controller.rb
# ...
# POST /tasks
def create
# ...
authorize @task
# ...
end
# ...
We should achieve a nice green color in all our tests.
As we can see, applying authorization and authentication to our API is easy with Devise and Pundit. I recommend checking out the official documentation to understand more about what you can accomplish with these two gems and a bit of ingenuity.
We have seen a brief introduction to authentication using a Bearer token and authorization based on policies and roles when developing APIs. Ruby on Rails is a spectacular framework, and I hope you’ve gained something valuable from this post.
Happy coding!
The repo with all the code: https://github.com/a-chacon/api-project-management-example
Like it? Share it!