Now that we have a functional user system, it’s time to allow them to log in and do stuff! Since we are not making a stateless API, we could use cookies to store the session information. However, not all clients might have a way to deal with cookies, so let’s use access tokens instead.
Access tokens will be used to identify and authenticate users that went through the login process.
The AccessToken
model needs the following fields.
id
token_digest
user_id
api_key_id
accessed_at
created_at
updated_at
The token_digest
column will contain the hash of the access token that will be exchanged between the server and the client to authenticate the user. Just like a password, we hash it before storing it in the database for security purposes. Each access token will be linked to a user and an API key. A user can therefore have multiple access tokens: one for the iOS application and one for the SPA application.
The accessed_at
timestamp is just a nice-to-have feature to keep track of the last time the token was used. We could use this to expire tokens when they haven’t been used for a week, for example. In this case, we will actually expire all access tokens 15 days after their creation.
Let’s generate this model.
rails g model AccessToken token_digest:string user:references \
api_key:references accessed_at:timestamp
Output
Running via Spring preloader in process 30890
invoke active_record
create db/migrate/TIMESTAMP_create_access_tokens.rb
create app/models/access_token.rb
invoke rspec
create spec/models/access_token_spec.rb
invoke factory_bot
create spec/factories/access_tokens.rb
Before we run the migrations, we need to add indexes on the token
column in order to retrieve the access tokens faster when authenticating. We also want a composite index on the relationship columns (user_id
and api_key_id
).
# db/migrate/TIMESTAMP_create_access_tokens.rb
class CreateAccessTokens < ActiveRecord::Migration[5.2]
def change
create_table :access_tokens do |t|
t.string :token_digest
t.references :user, foreign_key: true
t.references :api_key, foreign_key: true
t.timestamp :accessed_at
t.timestamps
end
add_index :access_tokens, [:user_id, :api_key_id], unique: true
end
end
Run the migrations.
rails db:migrate && RAILS_ENV=test rails db:migrate
First, update the generated factory.
# spec/factories/access_tokens.rb
FactoryBot.define do
factory :access_token do
token_digest { nil }
accessed_at { '2016-06-09 18:14:41' }
user
api_key
end
end
Then, let’s write a few tests ensuring that a token is correctly generated and that the user
and api_key
are mandatory.
# spec/models/access_token_spec.rb
require 'rails_helper'
RSpec.describe AccessToken, :type => :model do
let(:access_token) { create(:access_token) }
it 'has a valid factory' do
expect(build(:access_token)).to be_valid
end
it { should validate_presence_of(:user) }
it { should validate_presence_of(:api_key) }
describe '#authenticate' do
context 'when valid' do
it 'authenticates' do
token = access_token.generate_token
expect(access_token.authenticate(token)).to be true
end
end
context 'when invalid' do
it 'fails to authenticate' do
access_token.generate_token
expect(access_token.authenticate('fake')).to be false
end
end
end
describe '#expired?' do
context 'when expired' do
it 'returns true' do
access_token.update_column(:created_at, 15.days.ago)
expect(access_token.expired?).to be true
end
end
context 'when not expired' do
it 'returns false' do
access_token.update_column(:created_at, 10.days.ago)
expect(access_token.expired?).to be false
end
end
end
describe '#generate_token' do
it 'generates an access token digest' do
access_token.generate_token
expect(access_token.token_digest).to_not be nil
end
it 'returns an access token' do
token = access_token.generate_token
expect(token).to_not be nil
end
end
end
Here is the AccessToken
model with the relationships and validation rules defined. We also implemented a method to generate the token before validation.
# app/models/access_token.rb
class AccessToken < ApplicationRecord
belongs_to :user
belongs_to :api_key
validates :user, presence: true
validates :api_key, presence: true
def authenticate(unencrypted_token)
BCrypt::Password.new(token_digest).is_password?(unencrypted_token)
end
def expired?
created_at + 14.days < Time.now
end
# We generate the token before hashing it and saving it.
# We also return it so we can send it to the client.
def generate_token
token = SecureRandom.hex
digest = BCrypt::Password.create(token, cost: BCrypt::Engine.cost)
update_column(:token_digest, digest)
token
end
end
Run the tests.
rspec spec/models/access_token_spec.rb
Success (GREEN)
...
AccessToken
has a valid factory
should validate that :user cannot be empty/falsy
should validate that :api_key cannot be empty/falsy
#authenticate
when valid
authenticates
when invalid
fails to authenticate
#expired?
when expired
returns true
when not expired
returns false
#generate_token
generates an access token digest
returns an access token
Finished in 1.28 seconds (files took 4.11 seconds to load)
9 examples, 0 failures
Let’s add a presenter for access tokens as well.
touch app/presenters/access_token_presenter.rb
# app/presenters/access_token_presenter.rb
class AccessTokenPresenter < BasePresenter
build_with :id, :token, :user_id, :api_key_id,
:accessed_at, :created_at, :updated_at
related_to :user
sort_by :id, :user_id, :api_key_id, :accessed_at,
:created_at, :updated_at
filter_by :id, :user_id, :api_key_id, :accessed_at,
:created_at, :updated_at
def token
@options[:token]
end
end
We will hash the token as soon as we save the access token record, so we need a way to pass the token in clear to the AccessTokenPresenter
instance. That’s why we are using @options
in the token
method above. The Serializer
class and the presenters can already receive an options
hash, now we just need to make the link between them.
Update the build_data
method of the Serializer
class by passing the @options
variable to the current presenter.
# app/serializers/alexandria/serializer.rb
module Alexandria
class Serializer
def initialize # Hidden Code
def to_json # Hidden Code
private
def build_data
if @data.respond_to?(:count)
@data.map do |entity|
# Here
presenter(entity).new(entity, @params, @options).build(@actions)
end
else
# And here
presenter(@data).new(@data, @params, @options).build(@actions)
end
end
def presenter # Hidden Code
end
end
Access tokens belong to users and API keys. Let’s add the relationships in those models and their presenters.
User
model# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :access_tokens
# ...
end
UserPresenter
# app/presenters/user_presenter.rb
class UserPresenter < BasePresenter
FIELDS = [:id, :email, :given_name, :family_name, :role, :last_logged_in_at,
:confirmed_at, :confirmation_sent_at, :reset_password_sent_at,
:created_at, :updated_at]
related_to :access_tokens
sort_by *FIELDS
filter_by *FIELDS
build_with *[FIELDS.push([:confirmation_token, :reset_password_token,
:confirmation_redirect_url,
:reset_password_redirect_url])].flatten
end
ApiKey
modelWe don’t have a presenter for the ApiKey
model, so we only need to add the has_many
relationship in the model.
# app/models/api_key.rb
class ApiKey < ApplicationRecord
has_many :access_tokens
before_validation :generate_key, on: :create
validates :key, presence: true
validates :active, presence: true
scope :activated, -> { where(active: true) }
def disable
update_column :active, false
end
private
def generate_key
self.key = SecureRandom.hex
end
end
Run all the tests to ensure that we didn’t break anything.
rspec
Success (GREEN)
...
Finished in 5.21 seconds (files took 1.19 seconds to load)
257 examples, 0 failures
It’s time to complete the lockdown!
We started it in Chapter 21, and now it’s time to complete it. Currently, we only authenticate clients. Let’s also identify and authenticate users!
Thanks to our preparation, it’s actually pretty easy. We only need to add three methods to the Authentication
module.
The first method is authenticate_user
and is needed, well, to authenticate the user. This method will return 401 Unauthorized
unless the access token return a valid value.
def authenticate_user
unauthorized!('User Realm') unless access_token
end
The access_token
method will use the token sent in the Authorization
header in order to try to find a matching access token in the database. We will use the user_id
(sent with the access token) and the api_key
to find the access token record before using the authenticate
method to check the validity of the token. The current_user
will tell us who the user making the request is. We will need this information to ensure they have access to the resource once we add authorization to the API.
def access_token
@access_token ||= compute_access_token
end
def compute_access_token
return nil if credentials['access_token'].blank?
id, token = credentials['access_token'].split(':')
user = id && token && User.find_by(id: id)
access_token = user && api_key && AccessToken.find_by(user: user,
api_key: api_key)
return nil unless access_token
if access_token.expired?
access_token.destroy
return nil
end
return access_token if access_token.authenticate(token)
end
def current_user
@current_user ||= access_token.try(:user)
end
Here is the updated Authentication
module.
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
include ActiveSupport::SecurityUtils
AUTH_SCHEME = 'Alexandria-Token'
included do
before_action :validate_auth_scheme
before_action :authenticate_client
end
private
def validate_auth_scheme
unless authorization_request.match(/^#{AUTH_SCHEME} /)
unauthorized!('Client Realm')
end
end
def authenticate_client
unauthorized!('Client Realm') unless api_key
end
def authenticate_user
unauthorized!('User Realm') unless access_token
end
def unauthorized!(realm)
headers['WWW-Authenticate'] = %(#{AUTH_SCHEME} realm="#{realm}")
render(status: 401)
end
def authorization_request
@authorization_request ||= request.authorization.to_s
end
def credentials
@credentials ||= Hash[authorization_request.scan(/(\w+)[:=] ?"?([\w|:]+)"?/)]
end
def api_key
@api_key ||= compute_api_key
end
def compute_api_key
return nil if credentials['api_key'].blank?
id, key = credentials['api_key'].split(':')
api_key = id && key && ApiKey.activated.find_by(id: id)
return api_key if api_key && secure_compare_with_hashing(api_key.key, key)
end
def access_token
@access_token ||= compute_access_token
end
def compute_access_token
return nil if credentials['access_token'].blank?
id, token = credentials['access_token'].split(':')
user = id && token && User.find_by(id: id)
access_token = user && api_key && AccessToken.find_by(user: user,
api_key: api_key)
return nil unless access_token
if access_token.expired?
access_token.destroy
return nil
end
return access_token if access_token.authenticate(token)
end
def current_user
@current_user ||= access_token.try(:user)
end
def secure_compare_with_hashing(a, b)
secure_compare(Digest::SHA1.hexdigest(a), Digest::SHA1.hexdigest(b))
end
end
Did you notice that we didn’t add a before_action
calling authenticate_user
? That’s because we don’t want it to be called for every resource. Listing books or publishers, for example, doesn’t require a logged-in user. Calling the authenticate_user
method will be done in each controller depending on their needs.
Before we do that, how about a bit of refactoring? I find the Authentication
module too big. To extract some of its logic, let’s create a new class: an Authenticator
.
mkdir app/services && touch app/services/authenticator.rb
Here is the code for this new class. We just extracted the compute_api_key
and compute_access_token
methods.
# app/services/authenticator.rb
class Authenticator
include ActiveSupport::SecurityUtils
def initialize(authorization)
@authorization = authorization
end
def api_key
return nil if credentials['api_key'].blank?
id, key = credentials['api_key'].split(':')
api_key = id && key && ApiKey.activated.find_by(id: id)
return api_key if api_key && secure_compare_with_hashing(api_key.key, key)
end
def access_token
return nil if credentials['access_token'].blank?
id, token = credentials['access_token'].split(':')
user = id && token && User.find_by(id: id)
access_token = user && api_key && AccessToken.find_by(user: user,
api_key: api_key)
return nil unless access_token
if access_token.expired?
access_token.destroy
return nil
end
return access_token if access_token.authenticate(token)
end
private
def credentials
@credentials ||= Hash[@authorization.scan(/(\w+)[:=] ?"?([\w|:]+)"?/)]
end
def secure_compare_with_hashing(a, b)
secure_compare(Digest::SHA1.hexdigest(a), Digest::SHA1.hexdigest(b))
end
end
Finally, we can update the Authentication
module with our new class.
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
AUTH_SCHEME = 'Alexandria-Token'
included do
before_action :validate_auth_scheme
before_action :authenticate_client
end
private
def validate_auth_scheme
unless authorization_request.match(/^#{AUTH_SCHEME} /)
unauthorized!('Client Realm')
end
end
def authenticate_client
unauthorized!('Client Realm') unless api_key
end
def authenticate_user
unauthorized!('User Realm') unless access_token
end
def unauthorized!(realm)
headers['WWW-Authenticate'] = %(#{AUTH_SCHEME} realm="#{realm}")
render(status: 401)
end
def authorization_request
@authorization_request ||= request.authorization.to_s
end
def authenticator
@authenticator ||= Authenticator.new(authorization_request)
end
def api_key
@api_key ||= authenticator.api_key
end
def access_token
@access_token ||= authenticator.access_token
end
def current_user
@current_user ||= access_token.try(:user)
end
end
Tadaa! Much cleaner.
AccessTokensController
The access_tokens
controller will be responsible for logging users in and out. Creating a new access token will effectively log in a user while destroying it will end the user’s “session.”
Let’s generate files for the controller and its tests.
touch app/controllers/access_tokens_controller.rb \
spec/requests/access_tokens_spec.rb
create
ActionWhen a client calls POST /api/access_tokens
, it needs to send a user’s email and password. It then expects to receive an access token that can be used to access more resources.
Read through the tests below to understand the different contexts we will be testing.
# spec/requests/access_tokens_spec.rb
require 'rails_helper'
RSpec.describe 'Access Tokens', type: :request do
let(:john) { create(:user) }
describe 'POST /api/access_tokens' do
context 'with valid API key' do
let(:key) { ApiKey.create }
let(:headers) do
{ 'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{key.id}:#{key.key}" }
end
before { post '/api/access_tokens', params: params, headers: headers }
context 'with existing user' do
context 'with valid password' do
let(:params) { { data: { email: john.email, password: 'password' } } }
it 'gets HTTP status 201 Created' do
expect(response.status).to eq 201
end
it 'receives an access token' do
expect(json_body['data']['token']).to_not be nil
end
it 'receives the user embedded' do
expect(json_body['data']['user']['id']).to eq john.id
end
end
context 'with invalid password' do
let(:params) { { data: { email: john.email, password: 'fake' } } }
it 'returns 422 Unprocessable Entity' do
expect(response.status).to eq 422
end
end
end
context 'with nonexistent user' do
let(:params) { { data: { email: 'unknown', password: 'fake' } } }
it 'gets HTTP status 404 Not Found' do
expect(response.status).to eq 404
end
end
end
context 'with invalid API key' do
it 'returns HTTP status 401 Forbidden' do
post '/api/access_tokens', params: {}
expect(response.status).to eq 401
end
end
end
end
Now, let’s add more routes for the access_tokens
controller. To avoid duplication of information, we want to change the default route for the destroy
action from…
DELETE /api/access_tokens/:id
to…
DELETE /api/access_tokens
Indeed, in order to access the destroy
action, a valid access token will be needed; this means we already have it and there’s no need to include it in the URL.
# config/routes.rb
Rails.application.routes.draw do
scope :api do
resources :books, except: :put
resources :authors, except: :put
resources :publishers, except: :put
resources :users, except: :put
resources :user_confirmations, only: :show, param: :confirmation_token
resources :password_resets, only: [:show, :create, :update], param: :reset_token
resources :access_tokens, only: :create do
delete '/', action: :destroy, on: :collection
end
get '/search/:text', to: 'search#index'
end
root to: 'books#index'
end
After creating a new access token, we will need to be able to pass it to the serializer that will give it to the appropriate presenter. To do this, we need to update the serialize
method in the ApplicationController
class.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# Hidden Code
protected
def builder_error # Hidden Code
def unprocessable_entity! # Hidden Code
def orchestrate_query # Hidden Code
def serialize(data, options = {})
{
json: Alexandria::Serializer.new(data: data,
params: params,
actions: [:fields, :embeds],
options: options).to_json
}
end
def resource_not_found # Hidden Code
end
Here’s the AccessTokensController
class with the create
action. The first thing we have to do here is try to get a user based on the email sent by the client. We use find_by!
to raise a RecordNotFound
error that will get caught by the rescue_from
at the top of the file.
After that, it’s a pretty generic authentication, using the authenticate
method that came with has_secure_password
. Once the user has been authenticated, we check if a token already exists and delete it if that’s the case. We then create a new token, embed the user in it with the EmbedPicker
, and finally return it as JSON
.
If the given credentials are invalid, we return 422 Unprocessable Entity
with a generic error message. This message is to avoid giving hints to people who are trying to hack into the system.
# app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
def create
user = User.find_by!(email: login_params[:email])
if user.authenticate(login_params[:password])
AccessToken.find_by(user: user, api_key: api_key).try(:destroy)
access_token = AccessToken.create(user: user, api_key: api_key)
token = access_token.generate_token
params[:embed] = if params[:embed].present?
params[:embed].prepend('user,')
else
'user'
end
render serialize(access_token, { token: token }).merge(status: :created)
else
render status: :unprocessable_entity,
json: { error: { message: 'Invalid credentials.' } }
end
end
private
def login_params
params.require(:data).permit(:email, :password)
end
end
Let’s run the tests.
rspec spec/requests/access_tokens_spec.rb
Success (GREEN)
...
Access Tokens
POST /api/access_tokens
with valid API key
with existing user
with valid password
gets HTTP status 201 Created
receives an access token
receives the user embedded
with invalid password
returns 422 Unprocessable Entity
with nonexistent user
gets HTTP status 404 Not Found
with invalid API key
returns HTTP status 401 Forbidden
Finished in 0.33674 seconds (files took 2.31 seconds to load)
6 examples, 0 failures
Awesome, they are passing. Let’s talk about the destroy
action now.
destroy
ActionThis action will be called when a user wants to log out of the application. All this action will do is delete the access token, meaning it cannot be used anymore.
Read through the tests below to understand all our expectations.
# spec/requests/access_tokens_spec.rb
require 'rails_helper'
RSpec.describe 'Users', type: :request do
let(:john) { create(:user) }
describe 'POST /api/access_tokens' # Hidden Code
describe 'DELETE /api/access_tokens' do
context 'with valid API key' do
let(:api_key) { ApiKey.create }
let(:api_key_str) { "#{api_key.id}:#{api_key.key}" }
before { delete '/api/access_tokens', headers: headers }
context 'with valid access token' do
let(:access_token) { create(:access_token, api_key: api_key, user: john) }
let(:token) { access_token.generate_token }
let(:token_str) { "#{john.id}:#{token}" }
let(:headers) do
token =
{ 'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key_str}, access_token=#{token_str}" }
end
it 'returns 204 No Content' do
expect(response.status).to eq 204
end
it 'destroys the access token' do
expect(john.reload.access_tokens.size).to eq 0
end
end
context 'with invalid access token' do
let(:headers) do
{ 'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key_str}, access_token=1:fake" }
end
it 'returns 401' do
expect(response.status).to eq 401
end
end
end
context 'with invalid API key' do
it 'returns HTTP status 401' do
delete '/api/access_tokens', params: {}
expect(response.status).to eq 401
end
end
end # describe 'DELETE /api/access_tokens' end
end
Here is the implementation of the destroy
action. It’s super simple, right? We are just calling access_token.destroy
and returning 204 No Content
. If you’re wondering about the access_token
method, we implemented it in the Authentication
module earlier and it’s just going to extract the token from the Authorization
header.
# app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
before_action :authenticate_user, only: :destroy
def create
user = User.find_by!(email: login_params[:email])
if user.authenticate(login_params[:password])
AccessToken.find_by(user: user, api_key: api_key).try(:destroy)
access_token = AccessToken.create(user: user, api_key: api_key)
token = access_token.generate_token
params[:embed] = if params[:embed].present?
params[:embed].prepend('user,')
else
'user'
end
render serialize(access_token, { token: token }).merge(status: :created)
else
render status: :unprocessable_entity,
json: { error: { message: 'Invalid credentials.' } }
end
end
def destroy
access_token.destroy
render status: :no_content
end
private
def login_params
params.require(:data).permit(:email, :password)
end
end
Run the tests.
rspec spec/requests/access_tokens_spec.rb
Success (GREEN)
...
Finished in 0.86329 seconds (files took 3.55 seconds to load)
10 examples, 0 failures
How about we try running all the tests? With our new implementation, some of them should be failing, right?
rspec
Success (GREEN)
...
Finished in 9.2 seconds (files took 4.83 seconds to load)
267 examples, 0 failures
Oops! They are still working. We need to use the before_action
filter authenticate_user
in each controller first!
Let’s update all the controllers and specify which actions require the user to be authenticated.
AuthorsController
Accessing the lists of authors or a specific author is available for non-logged in users. The rest of the actions requires a user, and more specifically an admin, as we will see in the next section.
# app/controllers/authors_controller.rb
class AuthorsController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
# Hidden Code
BooksController
This is the same as authors.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
# Hidden Code
PublishersController
This is the same as authors, too.
# app/controllers/publishers_controller.rb
class PublishersController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
# Hidden Code
UsersController
For users, only the create
action can be accessed without being a logged in user. Everything else requires a user.
# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :authenticate_user, only: [:index, :show, :update, :destroy]
# Hidden Code
Now, we should have broken some stuff!
rspec
Failure (RED)
...
Finished in 5.28 seconds (files took 1.08 seconds to load)
267 examples, 55 failures
...
Awesome, looks like everything’s broken. Let’s fix it!
Instead of copy/pasting some stubbing block again, we are going to create a shared context.
Create a new folder in spec
named support
. We are also going to need a file in this folder named skip_auth.rb
.
touch spec/support/skip_auth.rb
This shared context will contain a block that will stub all our authentication methods. As we’ve discussed before, the proper way of doing this would be to re-write the tests to include more contexts. We’ll take the stubbing route to save some time.
# spec/support/skip_auth.rb
RSpec.shared_context 'Skip Auth' do
before do
allow_any_instance_of(ApplicationController).to(
receive(:validate_auth_scheme).and_return(true))
allow_any_instance_of(ApplicationController).to(
receive(:authenticate_client).and_return(true))
allow_any_instance_of(ApplicationController).to(
receive(:authenticate_user).and_return(true))
end
end
We then have to include this context in all the spec files that need it. You can remove the before
block shown below from all the test files.
before do
allow_any_instance_of(BooksController).to(
receive(:validate_auth_scheme).and_return(true))
allow_any_instance_of(BooksController).to(
receive(:authenticate_client).and_return(true))
end
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
include_context 'Skip Auth'
# Hidden Code
# spec/requests/authors_spec.rb
require 'rails_helper'
RSpec.describe 'Authors', type: :request do
include_context 'Skip Auth'
# Hidden Code
# spec/requests/publishers_spec.rb
require 'rails_helper'
RSpec.describe 'Publishers', type: :request do
include_context 'Skip Auth'
# Hidden Code
# spec/requests/users_spec.rb
require 'rails_helper'
RSpec.describe 'Users', type: :request do
include_context 'Skip Auth'
# Hidden Code
If we run the tests again, everything is working fine. Awesome!
rspec
Success (GREEN)
...
Finished in 5.41 seconds (files took 0.98325 seconds to load)
267 examples, 0 failures
We can now identify and authenticate users. The last step to protect our resources is to implement authorization. Authorization answers the question, “Can this particular user really perform this action?”
We are not going to do it from scratch, however. There is a gem out there that is simple, clean, and only uses PORO (Plain-Old Ruby Objects) to define rules: Pundit.
Let’s add the gem to our Gemfile
.
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'pg'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
gem 'pg_search'
gem 'kaminari'
gem 'bcrypt', '~> 3.1.7'
gem 'pundit'
# Hidden Code
Get it installed with bundle
.
bundle install
And use the pundit
generator to create the policies folder and the ApplicationPolicy
.
rails g pundit:install
Output
Running via Spring preloader in process 26249
create app/policies/application_policy.rb
Let’s talk a bit about the way pundit
works. For each model, we are going to create a policy that will define the requirements for the CRUD actions (index, show, create, update, destroy).
Below is the beginning of the ApplicationPolicy
generated by pundit
. The initialize
method receives two arguments: the user and the record to check permissions against.
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
# Hidden Code
end
Pundit will also provide a method to use in controllers known as authorize
. This method only takes one argument - the record
to check permissions against. To get the user, this method expects a current_user
to be defined somewhere in your application.
The authorize
method will then instantiate the appropriate policy class, inferring the class name from the model passed. It will then call the method matching the name of the action where authorize
was called.
For example, if I were to check if a user can update a book, I would use:
def update
@book = Book.find(params[:id])
authorize @book
# Hidden Code
end
Which will end up calling this:
BookPolicy.new(current_user, book).update?
Depending on the code we put in the update?
method, the user might, or might not, be able to update the given book. Let’s define the policies for our models now.
To get that out of the way, generate all the policy files we will need.
touch app/policies/access_token_policy.rb \
app/policies/author_policy.rb \
app/policies/book_policy.rb \
app/policies/publisher_policy.rb \
app/policies/author_policy.rb \
app/policies/user_policy.rb
Here is the policy for the access tokens. As you can see, creating access tokens is always allowed, even without a user. However, deleting an access token is only allowed for the user to whom it belongs.
# app/policies/access_token_policy.rb
class AccessTokenPolicy < ApplicationPolicy
def create?
true
end
def destroy?
@user == @record.user
end
end
For authors, publishers and books, anyone can access index
and show
. However, users need to be admin in order to create
, update
or delete
books. We defined the role
attribute on users in the previous chapter, so we can simply use this and call user.admin?
to check if a user is an admin or not.
# app/policies/author_policy.rb
class AuthorPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
end
# app/policies/book_policy.rb
class BookPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
end
# app/policies/publisher_policy.rb
class PublisherPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
end
Finally, the User
policy is the most complex one. Most of the actions can either be performed by admin users, or if the record being accessed is the current user. Creating is the exception since anyone can do it.
# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def index?
user.admin?
end
def show?
user.admin? || user == record
end
def create?
true
end
def update?
user.admin? || user == record
end
def destroy?
user.admin? || user == record
end
end
With our policies ready, it’s time to update the ApplicationController
class to use them and enforce the rules. To avoid polluting the controller too much, let’s create a new module and call it Authorization
.
touch app/controllers/concerns/authorization.rb
First, we need to make sure that the Pundit
module is included. Then, we need to ensure that we rescue from Pundit::NotAuthorizedError
by returning 403
to the client. Finally, we use after_action :verify_authorized
, which is a filter method provided by Pundit. This will raise an exception if an action has not checked the permissions.
# app/controllers/concerns/authorization.rb
module Authorization
extend ActiveSupport::Concern
include Pundit
included do
rescue_from Pundit::NotAuthorizedError, with: :forbidden
after_action :verify_authorized
end
def authorize_actions
if action_name == 'index'
return authorize(controller_name.classify.constantize)
end
authorize resource
end
def forbidden
render(status: 403)
end
end
Don’t forget to include this new module in the ApplicationController
file.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Authentication
include Authorization
# Hidden Code
Now, we need to start doing some authorization in our controllers. Indeed, if you run the tests now, you will end up with many of them failing because Pundit
is raising the Pundit::AuthorizationNotPerformedError
exception.
rspec
...
Finished in 5.73 seconds (files took 1.14 seconds to load)
267 examples, 128 failures
First, let’s take care of the controllers that don’t need authorization: the password_resets
controller and the search
controller.
PasswordResetsController
# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
skip_before_action :validate_auth_scheme, only: :show
skip_before_action :authenticate_client, only: :show
before_action :skip_authorization
# Hidden Code
SearchController
# app/controllers/search_controller.rb
class SearchController < ApplicationController
before_action :skip_authorization
# Hidden Code
Next up, the access_tokens
controller. For this one, we are going to add authorize(access_token)
in the create
and destroy
actions. If we cannot authenticate the user, we also need to call skip_authorization
to prevent Pundit from raising its exception.
AccessTokensController
# app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
before_action :authenticate_user, only: :destroy
def create
skip_authorization # We add this
user = User.find_by!(email: login_params[:email])
if user.authenticate(login_params[:password])
AccessToken.find_by(user: user, api_key: api_key).try(:destroy)
access_token = AccessToken.create(user: user, api_key: api_key)
token = access_token.generate_token
params[:embed] = if params[:embed].present?
params[:embed].prepend('user,')
else
'user'
end
render serialize(access_token, { token: token }).merge(status: :created)
else
render status: :unprocessable_entity,
json: { error: { message: 'Invalid credentials.' } }
end
end
def destroy
authorize(access_token) # And this
access_token.destroy
render status: :no_content
end
private
def login_params
params.require(:data).permit(:email, :password)
end
end
For the rest of our controllers (authors, publishers, books and users), we can just use the authorize_actions
method we created in the Authorization
module.
AuthorsController
# app/controllers/authors_controller.rb
class AuthorsController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
before_action :authorize_actions
# Hidden Code
PublishersController
# app/controllers/publishers_controller.rb
class PublishersController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
before_action :authorize_actions
# Hidden Code
BooksController
# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :authenticate_user, only: [:create, :update, :destroy]
before_action :authorize_actions
# Hidden Code
UsersController
# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :authenticate_user, only: [:index, :show, :update, :destroy]
before_action :authorize_actions
# Hidden Code
Try running the tests to see how things are now.
rspec
While some tests have been fixed by adding that, a lot of them are still failing.
Failure (RED)
...
Finished in 5.1 seconds (files took 0.97459 seconds to load)
267 examples, 57 failures
...
We are now going to fix all the failing tests in a few steps. First, we’re going to stub the user authentication and make it return an admin user. That means we won’t be blocked by anything related to authentication or authorization.
Begin by adding a new factory to build admins.
# spec/factories/users.rb
FactoryBot.define do
factory :user do
# Hidden Code
end
factory :admin, class: User do
email { 'admin@example.com' }
password { 'password' }
given_name { 'Super' }
family_name { 'Admin' }
confirmed_at { Time.now }
role { :admin }
end
end
The second step involves updating the skip_auth.rb
file that we used to skip client authentication. Let’s update it to skip user authentication and authorization as well.
# spec/support/skip_auth.rb
RSpec.shared_context 'Skip Auth' do
let(:admin_user) { create(:admin) }
let(:api_key) { ApiKey.first || create(:api_key) }
let(:access_token) { create(:access_token, user: admin_user, api_key: api_key) }
before do
allow_any_instance_of(ApplicationController).to(
receive(:validate_auth_scheme).and_return(true))
allow_any_instance_of(ApplicationController).to(
receive(:authenticate_client).and_return(true))
allow_any_instance_of(ApplicationController).to(
receive(:authenticate_user).and_return(true))
allow_any_instance_of(ApplicationController).to(
receive(:access_token).and_return(access_token))
allow_any_instance_of(ApplicationController).to(
receive(:current_user).and_return(admin_user))
end
end
With that in place, our tests should be passing without any issue.
rspec
...
Finished in 6.83 seconds (files took 1.12 seconds to load)
267 examples, 0 failures
Some of the tests that you wrote in the exercises of the previous chapters might be failing now. Try to fix them ;).
We are currently not testing the authentication/authorization we implemented; instead, we skip it with some stubbed methods. We did write some tests checking the authentication in the tests for the access_tokens
controller, but not for authorization. That’s because we will rely on testing the policies to ensure permissions are followed.
Create a new folder and the files for all our policies tests with the command below.
mkdir spec/policies && \
touch spec/policies/book_policy_spec.rb \
spec/policies/publisher_policy_spec.rb \
spec/policies/author_policy_spec.rb \
spec/policies/user_policy_spec.rb \
spec/policies/access_token_policy_spec.rb
We need to require pundit/rspec
to make it easier to write our tests. Add it to the spec/rails_helper.rb
file.
# spec/rails_helper.rb
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
abort("The Rails environment is running in production mode!") if Rails.env.production?
# Require RSpec for Rails, webmock and database_cleaner
require 'rspec/rails'
require 'webmock/rspec'
require 'database_cleaner'
require "pundit/rspec"
# Hidden Code
Here are the tests for the book policy. Thanks to the helpers provided by Pundit
, writing those tests was pretty straightforward.
# spec/policies/book_policy_spec.rb
require 'rails_helper'
describe BookPolicy do
subject { described_class }
permissions :index?, :show? do
it 'grants access' do
expect(subject).to permit(nil, Book.new)
end
end
permissions :create?, :update?, :destroy? do
it 'denies access if user is not admin' do
expect(subject).not_to permit(build(:user), Book.new)
end
it 'grants access if user is admin' do
expect(subject).to permit(build(:admin), Book.new)
end
end
end
rspec spec/policies/book_policy_spec.rb
...
BookPolicy
index? and show?
grants access
create?, update?, and destroy?
denies access if user is not admin
grants access if user is admin
Finished in 0.13231 seconds (files took 2.95 seconds to load)
3 examples, 0 failures
Good, it seems our policy follows our expectations.
Want to hear the good news? Writing the tests for the remaining policies will be one of the exercises for this chapter. Good luck!
To complete everything we’ve done during this chapter, let’s write an integration test that will go through everything, from logging users in to logging them out after having requested some data.
Create a new file in the features folder.
touch spec/features/user_auth_flow_spec.rb
Here are the tests. The flow should be pretty clear with the comments.
# spec/features/user_auth_flow_spec.rb
require 'rails_helper'
RSpec.describe 'User Auth Flow', type: :request do
def headers(user_id = nil, token = nil)
api_key_str = "#{api_key.id}:#{api_key.key}"
if user_id && token
token_str = "#{user_id}:#{token}"
{ 'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key_str}, access_token=#{token_str}" }
else
{ 'HTTP_AUTHORIZATION' =>
"Alexandria-Token api_key=#{api_key.id}:#{api_key.key}" }
end
end
let(:api_key) { ApiKey.create }
let(:email) { 'john@gmail.com' }
let(:password) { 'password' }
let(:params) { { email: email, password: password, given_name: 'Johnny' } }
it 'authenticate a new user' do
# Step 1 - Create a user
post '/api/users', params: { data: params }, headers: headers
expect(response.status).to eq 201
id = json_body['data']['id']
# Step 2 - Try to update given_name
patch "/api/users/#{id}",
params: { data: { given_name: 'John' } },
headers: headers
expect(response.status).to eq 401
# Step 3 - Login
post '/api/access_tokens',
params: { data: { email: email, password: 'password' } },
headers: headers
expect(response.status).to eq 201
expect(json_body['data']['token']).to_not be nil
expect(json_body['data']['user']['email']).to eq email
token = json_body['data']['token']
user_id = json_body['data']['user']['id']
# Step 4 - Update given_name
patch "/api/users/#{id}",
params: { data: { given_name: 'John' } },
headers: headers(user_id, token)
expect(response.status).to eq 200
expect(json_body['data']['given_name']).to eq 'John'
# Step 5 - Try to list all users
get '/api/users', headers: headers(user_id, token)
expect(response.status).to eq 403
# Step 6 - Logout
delete '/api/access_tokens', headers: headers(user_id, token)
expect(response.status).to eq 204
# Step 7 - Try to access user info with invalid token
get "/api/users/#{id}", headers: headers(user_id, token)
expect(response.status).to eq 401
end
end
rspec spec/features/user_auth_flow_spec.rb
...
User Auth Flow
authenticate a new user
Finished in 0.58597 seconds (files took 2.46 seconds to load)
1 example, 0 failures
Great, it seems everything is working well together!
Here are a few additional exercises from this chapter for you to complete on your own.
If any of your tests is still failing, it’s time to fix it.
To ensure that we set the correct permissions, write tests for all the policy classes.
AccessTokenPolicy
AuthorPolicy
PublisherPolicy
UserPolicy
Run all the tests to ensure that everything is working.
rspec
Success (GREEN)
...
Finished in 7.5 seconds (files took 1.03 seconds to load)
286 examples, 0 failures
Let’s push the changes.
git status
Output
On branch user-auth
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: Gemfile
modified: Gemfile.lock
modified: app/controllers/application_controller.rb
modified: app/controllers/authors_controller.rb
modified: app/controllers/books_controller.rb
modified: app/controllers/concerns/authentication.rb
modified: app/controllers/password_resets_controller.rb
modified: app/controllers/publishers_controller.rb
modified: app/controllers/search_controller.rb
modified: app/controllers/users_controller.rb
modified: app/models/api_key.rb
modified: app/models/user.rb
modified: app/presenters/user_presenter.rb
modified: app/serializers/alexandria/serializer.rb
modified: config/routes.rb
modified: db/schema.rb
modified: spec/factories/users.rb
modified: spec/rails_helper.rb
modified: spec/requests/authors_spec.rb
modified: spec/requests/books_spec.rb
modified: spec/requests/publishers_spec.rb
modified: spec/requests/users_spec.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/controllers/access_tokens_controller.rb
app/controllers/concerns/authorization.rb
app/models/access_token.rb
app/policies/
app/presenters/access_token_presenter.rb
app/services/
db/migrate/20160709070329_create_access_tokens.rb
spec/factories/access_tokens.rb
spec/features/user_auth_flow_spec.rb
spec/models/access_token_spec.rb
spec/policies/
spec/requests/access_tokens_spec.rb
spec/support/skip_auth.rb
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Implement user authentication and authorization"
Push to GitHub.
git push origin master
In this chapter, we implemented authentication and authorization. Those are two complex subjects which explains the length of this chapter. However, now that it’s done, we are getting very close to the end of this module. In the next chapter, we will implement payments to make our e-commerce application complete.