In my last article, Test-Driven Development of a RESTful JSON API With Rails, we built a simple Rails RESTful JSON API. If you haven't yet, I encourage you to read it first before diving into this article, which is part two.
Today, almost all user-driven web applications require a user authentication feature. This is for logging in and out, registration, and more. The Rails ecosystem for building authentication gets more crowded each day, with many choices.
The Ruby toolbox site is a handy site that provides statistics on Web Authentication. They are based on the popularity, release, and activity of Web Authentication used to authenticate users in web applications written in Rails.
First, we will cover simple authentication using bcrypt gem and a token-based authentication - JSON Web Token authentication (JWT). Token-based authentication does not store anything on the server. Rather, it creates a unique encoded token that gets checked every time a request is made. It is also stateless.
The table below shows the list of our API Endpoints.
In the previous article, we completed the book and categories endpoints. In this article, we'll focus on the login, register endpoints, and securing our book's endpoint using JWT.
We will require the following gems in our Gemfile, which you can add at the bottom:
Install the gems by running bundle install.
Let's start by generating our user model and updating our book and category model to include user ID as a foreign key. This means for every book created, we will need to identify which user added it.
{% code-block language="js" %}
rails g model User username password_digest
rails g migration update_books_table
{% code-block-end %}
The BCrypt gem requires that we have a XXX_digest attribute, in our case password_digest. Adding has_secure_password method in our user model will set and authenticate against a BCrypt password. Your user model should now look like this:
{% code-block language="js" %}
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
end
{% code-block-end %}
The generator invokes both active record and rspec to generate the migration model and spec respectively. The migration file for your user should like the code below.
{% code-block language="js" %}
# db/migrate/[timestamp]_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.1]
def change
create_table :users do |t|
t.string :username
t.string :password_digest
t.timestamps
end
end
end
{% code-block-end %}
Update the book migration file generated -[timestamp]_update_books.rb to look like this:
{% code-block language="js" %}
# db/migrate/[timestamp]_update_books.rb
class UpdateBooksTable < ActiveRecord::Migration[6.1]
def change
add_reference :books, :user, foreign_key: true
end
end
{% code-block-end %}
Let's run the migrations:
{% code-block language="js" %}
rails db:migrate
{% code-block-end %}
Since we are following the test-driven approach, let's write the model specs for the user model:
{% code-block language="js" %}
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
it { should validate_presence_of(:username) }
it { should validate_uniqueness_of(:username) }
it {
should validate_length_of(:username)
.is_at_least(3)
}
it { should validate_presence_of(:password) }
it {
should_not validate_length_of(:password)
.is_at_least(5)
}
describe 'Associations' do
it { should have_many(:books) }
end
end
{% code-block-end %}
Now, try executing the specs by running:
{% code-block language="js" %}
rspec spec/models/user_spec.rb
{% code-block-end %}
Just as expected, we have failed tests. Only one has passed because of the has_secure_password we added.
Let’s go ahead and fix the failures:
{% code-block language="js" %}
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :books
validates :username, presence: true, uniqueness: true, length: { minimum: 3 }
validates :password, presence: true, length: { minimum: 6 }
end
{% code-block-end %}
Now, we can run the tests again. You should see all tests are passed.
Remember that we updated our books table column. So, let's update our book_specs file to reflect the user association by updating the association tests.
{% code-block language="js" %}
RSpec.describe Book, type: :model do
it { should belong_to(:category) }
it { should belong_to(:user) }
it { should validate_presence_of(:title) }
it { should validate_presence_of(:author) }
it {
should validate_length_of(:title)
.is_at_least(3)
}
end
{% code-block-end %}
Above, it's obvious that running the tests rspec spec/models/book_spec.rb will result in one failed test. We can easily fix this by adding the belongs_to association to the book model.
{% code-block language="js" %}
class Book < ApplicationRecord
belongs_to :category
belongs_to :user
validates :title, :author, presence: true, length: { minimum: 3 }
end
{% code-block-end %}
Great job thus far! Next, we'll move on to controllers.
Controllers play a vital role in the MVC framework pattern. They act as the middleman between the views and model. Every time a user makes a request, an object (instance of the controller) is created, and when the request is completed, the object is destroyed.
Since we won't be creating any more models for this project, let's go ahead and generate the controllers. Both a user controller and authentication controller will be used to handle authentication. You can think of it as your usual sessions controller.
{% code-block language="js" %}
rails g controller Users
rails g controller Authentication
{% code-block-end %}
Following our API versioning approach, let's move the generated controller files to:
{% code-block language="js" %}
app/controllers/api/v1
mv app/controllers/{users_controller.rb,authentication_controller.rb} app/controllers/api/v1/
{% code-block-end %}
As expected, when we generated the users and authentication controller, their respective specs files were generated as well.
Before we write our first tests in this part, let's add factories for users. We also need to update that book.
You might have noticed; your books_request_spec is failing if you run the below:
{% code-block language="js" %}
rspec spec/requests/books_request_spec.rb.
touch spec/factories/user.rb
# spec/factories/user.rb
FactoryBot.define do
factory :user do
username { Faker::Internet.username(specifier: 5..10) }
password { 'password' }
end
end
{% code-block-end %}
Now that the user factory is ready, we can update the book factory by adding user { create(:user) } similar to what we did with the category. See below:
{% code-block language="js" %}
# spec/factories/book.rb
FactoryBot.define do
factory :book do
title { Faker::Book.title }
author { Faker::Book.author }
category { create(:category) }
user { create(:user) }
end
end
{% code-block-end %}
Run rspec spec/requests/books_request_spec.rb again and all the tests should now be passing.
Now, let’s write the specs for /register and /login API:
{% code-block language="js" %}
# spec/requests/users_request_spec.rb
RSpec.describe 'Users', type: :request do
describe 'POST /register' do
it 'authenticates the user' do
post '/api/v1/register', params: { user: { username: 'user1', password: 'password' } }
expect(response).to have_http_status(:created)
expect(json).to eq({
'id' => User.last.id,
'username' => 'user1',
'token' => AuthenticationTokenService.call(User.last.id)
})
end
end
end
# spec/requests/authentication_request_spec.rb
RSpec.describe 'Authentications', type: :request do
describe 'POST /login' do
let(:user) { FactoryBot.create(:user, username: 'user1', password: 'password') }
it 'authenticates the user' do
post '/api/v1/login', params: { username: user.username, password: 'password' }
expect(response).to have_http_status(:created)
expect(json).to eq({
'id' => user.id,
'username' => 'user1',
'token' => AuthenticationTokenService.call(user.id)
})
end
it 'returns error when username does not exist' do
post '/api/v1/login', params: { username: 'ac', password: 'password' }
expect(response).to have_http_status(:unauthorized)
expect(json).to eq({
'error' => 'No such user'
})
end
it 'returns error when password is incorrect' do
post '/api/v1/login', params: { username: user.username, password: 'incorrect' }
expect(response).to have_http_status(:unauthorized)
expect(json).to eq({
'error' => 'Incorrect password '
})
end
end
end
{% code-block-end %}
You might be wondering where the AuthenticationTokenService is coming from, as we don't have it anywhere in our app. We will get to that soon! As the name implies, you can guess what it will be doing - generating a Token for each user.
Moving on, running our specs rspec spec/requests/users_request_spec.rb && rspec spec/requests/authentication_request_spec.rb will throw a ActionController::RoutingError. So, let’s quickly fix that.
Here we will need just two routes /login and /register. Our /config/routes should now look like this:
{% code-block language="js" %}
get 'users/Authentication'
namespace :api do
namespace :v1 do
resources :categories, only: %i[index create destroy]
resources :books, only: %i[index create show update destroy]
post 'login', to: 'authentication#create'
post 'register', to: 'users#create'
end
end
end
{% code-block-end %}
Now run rspec spec/requests/users_request_spec.rb && rspec spec/requests/authentication_request_spec.rb again. The next error should be …..define constant Api::V1::UsersController, but didn't.
Let’s wrap the UsersController class with module API and V1, then add the #create method to our users_controller. You can do this like so:
{% code-block language="js" %}
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def create
user = User.create(user_params)
if user.save
render json: UserRepresenter.new(user).as_json, status: :created
else
render json: { error: user.errors.full_messages.first }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:username, :password)
end
end
end
end
{% code-block-end %}
Let's also wrap the AuthenticationController class with module API and V1, then add the #create method for authentication.
{% code-block language="js" %}
Rails.application.routes.draw do
root' home#index'
namespace :api do
namespace :v1 do
resources :users, only: :index
resources :favourites, only: %i[index create destroy]
resources :cars, only: %i[index create show destroy]
post 'login', to: 'authentication#create'
post 'register', to: 'users#create'
end
end
end
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class AuthenticationController < ApplicationController
class AuthenticateError < StandardError; end
rescue_from ActionController::ParameterMissing, with: :parameter_missing
rescue_from AuthenticateError, with: :handle_unauthenticated
def create
if user
raise AuthenticateError unless user.authenticate(params.require(:password))
render json: UserRepresenter.new(user).as_json, status: :created
else
render json: { error: 'No such user' }, status: :unauthorized
end
end
private
def user
@user ||= User.find_by(username: params.require(:username))
end
def parameter_missing(error)
render json: { error: error.message }, status: :unprocessable_entity
end
def handle_unauthenticated
render json: { error: 'Incorrect password ' }, status: :unauthorized
end
end
end
end
{% code-block-end %}
You might remember our own custom helper from the previous article. We used it to render the JSON response just the way we wanted it. Let's go ahead and create one for users here:
{% code-block language="js" %}
touch app/representers/user_representer.rb
# app/representers/user_representer.rb
class UserRepresenter
def initialize(user)
@user = user
end
def as_json
{
id: user.id,
username: user.username,
token: AuthenticationTokenService.call(user.id)
}
end
private
attr_reader :user
end
{% code-block-end %}
It's a good practice to keep our Rails controllers clean and DRY. Putting a single service object into the services folder allows you to get a little more granular. Tomek Pewiński's article, How Service Objects in Rails Will Help You Design Clean And Maintainable Code, sheds more light on this topic.
Our token service class will live in the services directory under the app directory and will just handle one single task generating token to our authenticated users.
This is a good time to create our Authentication Token Service. Before we do that, though, it's important to know how the JSON Web Token really works.
Let's keep it simple and focus on an explanation that will best suit our small API application.
Essentially, whenever a user tries to access a protected route or resource, say /books, the user agent (e.g., browser) should send the JWT. This is typically in the Authorization header using the Bearer schema. Authorization: Bearer <token>. That should be enough to move forward now, but you can find a more detailed explanation of this here.
We'll start by creating a services directory and authentication_token_service.rb file.
{% code-block language="js" %}
mkdir app/services && touch app/services/authentication_token_service.rb
{% code-block-end %}
Next, generate the SECRET_KEY that will be used for encoding and decoding our token.
{% code-block language="js" %}
HMAC_SECRET = Rails.application.secrets.secret_key_base
{% code-block-end %}
We will then take the user_id and expiration time as payload.
{% code-block language="js" %}
# app/services/authentication_token_service.rb
class AuthenticationTokenService
HMAC_SECRET = Rails.application.secrets.secret_key_base
ALGORITHM_TYPE = 'HS256'.freeze
def self.call(user_id)
exp = 24.hours.from_now.to_i
payload = { user_id: user_id, exp: exp }
JWT.encode payload, HMAC_SECRET, ALGORITHM_TYPE
end
def self.decode(token)
JWT.decode token, HMAC_SECRET, true, { algorithm: ALGORITHM_TYPE }
rescue JWT::ExpiredSignature, JWT::DecodeError
false
end
def self.valid_payload(payload)
!expired(payload)
end
def self.expired(payload)
Time.at(payload['exp']) < Time.now
end
def self.expired_token
render json: { error: 'Expired token! login again' }, status: :unauthorized
end
end
{% code-block-end %}
Phew! That was a long one. Now, let's run all the tests again to make sure everything is green.
{% code-block language="js" %}
bundle exec rspec
{% code-block-end %}
As you can see, I had one failed test, "Expected the response to have status code 201, but it was 422".
Line 47 of books_request_specs.rb, where we wrote specs to post a new book, is where we have the error. That is because our application has been modified to tie every new book to a user. We can quickly fix it by adding a user to the post book params, as well as to that of the books_controller.
{% code-block language="js" %}
# spec/requests/books_request_spec.rb
[....]
let!(:user1) { create(:user) }
let(:valid_attributes) do
{ title: 'Whispers of Time', author: 'Dr. Krishna Saksena',
category_id: history.id, user_id: user1.id }
end
[....]
# app/controllers/api/v1/books_controller.rb
[....]
def book_params
params.permit(:title, :author, :category_id, :user_id)
end
[....]
{% code-block-end %}
Run the tests again. Now everything should be green. If you get all green, well done! If not, check again and make sure you're not missing anything.
There you have it! That is how we authenticate a RESTful JSON API using TDD. You should be proud of yourself for coming this far! A Test-Driven Development approach with good test coverage enables us to have a full picture of the feature we would be developing, as well as its requirements in order for those tests to pass. In a real scenario, you will often need to go back and forth tweaking your tests as well as your code.
You can find the full code for everything we went over here. In the next article of this series, we cover user authorization on a secured endpoint and API documentation using Postman. Head there next!
Happy Coding!
Career advice, the latest coding trends and languages, and insights on how to land a remote job in tech, straight to your inbox.