preloader
Loading...
New webinar: "The Remote Job Search: My Microverse Journey" with graduate Paul Rail
Watch Now

We have launched an English school for software developers. Practice speaking and lose your fear.

Topics

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.

Setting up our Gem Dependencies

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. 

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: 

  • bcrypt - A sophisticated and secure hash algorithm designed by The OpenBSD project for hashing passwords. The bcrypt Ruby gem provides a simple wrapper for safely handling passwords.
  • jwt - A pure Ruby implementation of the RFC 7519 OAuth JSON Web Token standard.
  • rack-cors - Provides support for Cross-Origin Resource Sharing (CORS) for Rack compatible web applications.

Install the gems by running bundle install.

Models and Migration Setup

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

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 %}

Authentication Token Service

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.

Conclusion

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!

We have launched an English school for software developers. Practice speaking and lose your fear.

Subscribe to our Newsletter

Get Our Insights in Your Inbox

Career advice, the latest coding trends and languages, and insights on how to land a remote job in tech, straight to your inbox.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
We use own and third party cookies to; provide essential functionality, analyze website usages, personalize content, improve website security, support third-party integrations and/or for marketing and advertising purposes.

By using our website, you consent to the use of these cookies as described above. You can get more information, or learn how to change the settings, in our Cookies Policy. However, please note that disabling certain cookies may impact the functionality and user experience of our website.

You can accept all cookies by clicking the "Accept" button or configure them or refuse their use by clicking HERE.