How to implement Token-Based Authentication and Authorization in Rails
Tags: programming, tutorial, rails, softwareengineering
Published: 2023-11-30 06:16:47

"Technologies change, but the basics are the same". That's what my mentor told me years ago when I asked him how I could be a great software engineer. And it's so true especially when it comes to securing an API with a token-based authentication. No matter the framework you use (Spring, Rails, Laravel, etc.), the principle is the same. So in this blog post, we will discuss how to implement it successfully in your Rails application.

## The project:
We want to create a backend API for an app like [Hackerrank](https://www.hackerrank.com/).
Here is the Database Design and Entity-Relationship Diagram (ERD) for the API.

For the entities, we have:
     - Users
     - Challenges (Programming questions)
     - Submissions (User solutions to challenges)
     - Categories (Challenge categories)
     - Comments (User comments on challenges)
     - Ratings (User ratings for challenges)
     - Test Cases (Input and expected output for challenges)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s00w7deqg0n3pgyia5vv.png)

Here are some relationships from the diagram above:
     - A User has many Submissions.
     - A Challenge belongs to a Category.
     - A Challenge has many Test Cases.
     - Only admins can create, update, or delete challenges, submissions, categories, or test cases.

Now, let's focus on implementing the authentication and authorization for our API. I assume you already know how to install a rails application focused on an API implementation, how to generate controllers and models.
We will go with the basic MVC architecture provided by Rails.

## Install the necessary gems

- Add this to your `Gemfile`:

```ruby
gem 'bcrypt' # For hashing passwords
gem 'jwt'   # For JWT authentication
```
`bcrypt` will be used for password-hashing.
`jwt` will be used to successfully log the user in and handle authorization.

- Create a `secret_key_generator` file in your `initializers` folder and paste this:

```ruby
# config/initializers/secret_key_generator.rb

# Require the SecureRandom module
require 'securerandom'

# Generate a random secret key
secret_key = SecureRandom.hex(32) # Adjust the size as needed (e.g., 64 characters for a 256-bit key)

# Set the secret key as an environment variable
ENV['APP_SECRET_KEY'] = secret_key

```
Initializers are executed when the Rails application boots. In our case, this initializer generates a random secret key using SecureRandom and sets it as an environment variable (APP_SECRET_KEY). We will use it to decode JWT tokens.

## Create the models and migration files

- User

```ruby
class User < ApplicationRecord
  enum role: { default: 0, admin: 1 }
  validates :name, presence: true
  validates :email, presence: true
  validates_uniqueness_of :email
  validates :password, presence: true, length: { minimum: 6 }

  has_secure_password

  def admin?
    role == 'admin'
  end
end

```

```ruby
class CreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email
      t.string :password_digest
      t.integer :role

      t.timestamps
    end
  end
end

```
- Category

```ruby
class Category < ApplicationRecord
    validates :title, presence: true
    validates :description, allow_blank: true

    has_many :challenge_categories
    has_many :challenges, through: :challenge_categories
end

```

```ruby
class CreateCategories < ActiveRecord::Migration[7.1]
  def change
    create_table :categories do |t|
      t.string :title
      t.text :description, :null => true

      t.timestamps
    end
  end
end

```
- Challenge

```ruby
class Challenge < ApplicationRecord
    validates :title, presence: true
    validates :description, presence: true

    has_many :challenge_categories
    has_many :categories, through: :challenge_categories
end

```

```ruby
class CreateChallenges < ActiveRecord::Migration[7.1]
  def change
    create_table :challenges do |t|
      t.string :title
      t.text :description

      t.timestamps
    end
  end
end

```
And since there is a many-to-many relationship between Challenge and Category, let's add the pivot table:

```ruby
class CreateChallengeCategories < ActiveRecord::Migration[7.1]
  def change
    create_table :challenge_categories do |t|
      t.references :challenge, null: false, foreign_key: true
      t.references :category, null: false, foreign_key: true

      t.timestamps
    end
  end
end

```

## Add the routes

```ruby
resources :users, only: [:create]
  namespace :auth do
    post '/login', to: 'sessions#create'
    delete '/logout', to: 'sessions#destroy'
  end
  resources :challenges
  post '/admin', to: 'users#add_admin'
```
You may have guessed it, but you will need to create a `controllers/auth` folder. I'll explain why in a few minutes.

## Authentication logic

- Add this to your `users_controller` file:

```ruby
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      render json: { message: 'Registration successful. Please log in.' }, status: :created
    else
      render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity
    end
  end

  def add_admin
    email = params[:email]

    if email.blank?
      render json: { errors: 'Email parameter is required' }, status: :unprocessable_entity
    end
    @user = User.find_by(email: email)
    if @user.nil?
      render json: { errors: 'No user with that Email found' }, status: :not_found
    else
      @user.update_column(:role, 1)
      render json: { message: 'User is now an admin' }, status: :ok
    end
  end

  private

  def user_params
      params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end

end

```
That controller is simply responsible for creating new users and admins.

Create a `helpers/token_helper` file in your `app` folder and add this code:

```ruby
# app/helpers/token_helper.rb
module TokenHelper
  def decode_token(token)
    JWT.decode(token, ENV['APP_SECRET_KEY'], true, algorithm: 'HS256')[0]
  rescue JWT::DecodeError
    nil
  end
end

```
And include it in your `application_controller`:

```ruby
include TokenHelper
```
Now let's create a `sessions_controller` in our `auth` folder and add this code:

```ruby
# app/controllers/sessions_controller.rb

class Auth::SessionsController < ApplicationController
  def create
    user = User.find_by(email: params[:email])

    if user && user.authenticate(params[:password])
        # Set the expiration time (e.g., 1 hour from now)
        expiration_time = Time.now.to_i + (3600 * 3) # 3600 seconds = 1 hour

        # Create the payload
        payload = { 
            user_id: user.id,
            exp: expiration_time # This sets the expiration time
        }
        token = JWT.encode(payload, ENV['APP_SECRET_KEY'], 'HS256')
        render json: { message: 'Logged in successfully', token: token }
    else
        render json: { error: 'Invalid credentials' }, status: :unauthorized
    end
  end

  def destroy
    # Invalidate the JWT token by marking it as expired or revoking it.
    token = extract_token_from_request
    if token
      begin
        # Try to decode the token to check its validity
        payload, _ = JWT.decode(token, ENV['APP_SECRET_KEY'], true, algorithm: 'HS256')

        # At this point, the token is considered valid
        # Add expiration to the payload to mark the token as invalid
        payload['exp'] = Time.now.to_i
        new_token = JWT.encode(payload, ENV['APP_SECRET_KEY'], 'HS256')

        render json: { message: 'Logged out successfully' }
      rescue JWT::DecodeError
        # JWT::DecodeError is raised when the token is not valid
        render json: { message: 'You need to sign in or sign up before continuing' }, status: :unauthorized
      end
    else
      render json: { message: 'No token found' }, status: :unprocessable_entity
    end
  end

  private

  def extract_token_from_request
    request.headers['Authorization']&.split&.last
  end
end

```
What we did there basically is generate a new token when a user logs in and invalidate a token when a user logs out. Remember, the token is used to check if a user session is valid or not. If you don't know how JWT tokens work, check the documentation [here](https://en.wikipedia.org/wiki/JSON_Web_Token).

Now if you request `POST /users` and `POST /login` with the correct parameters using Postman, it should work.

## Add the Challenge and Category controllers:

```ruby
class ChallengesController < ApplicationController
    before_action :set_challenge, only: [:show, :update, :destroy]
    before_action :authorize_user, except: [:index, :show]
    before_action :authorize_admin, except: [:index, :show]

    def index
        @challenges = Challenge.all
        render json: @challenges
    end

    def show
        render json: @challenge
    end

    def create
        @challenge = Challenge.new(challenge_params)
        if @challenge.save
            render json: @challenge, status: :created
        else
            render json: @challenge.errors, status: :unprocessable_entity
        end
    end

    def update
        if @challenge.update(challenge_params)
            render json: @challenge
        else
            render json: @challenge.errors, status: :unprocessable_entity
        end
    end

    def destroy
        @challenge.destroy
        render json: { message: 'Challenge was successfully deleted' }
    end

    private

    def set_challenge
        @challenge = Challenge.find(params[:id])
    end

    def challenge_params
        params.require(:challenge).permit(:title, :description, category_ids: [])
    end
end

```

```ruby
class CategoriesController < ApplicationController
  before_action :set_category, only: [:show, :update, :destroy]
  before_action :authorize_user, except: [:index, :show]
  before_action :authorize_admin, except: [:index, :show]

  def index
    @categories = Category.all
    render json: @categories
  end

  def show
    render json: @category
  end

  def create
    @category = Category.new(category_params)
    if @category.save
      render json: @category, status: :created
    else
      render json: @category.errors, status: :unprocessable_entity
    end
  end

  def update
    if @category.update(category_params)
      render json: @category
    else
      render json: @category.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @category.destroy
    head :no_content
  end

  private

  def set_category
    @category = Category.find(params[:id])
  end

  def category_params
    params.require(:category).permit(:title, :description, challenge_ids: [])
  end
end

```
As you can see, both controllers check if the user session is valid before executing some controller actions (create, update, delete). They are doing so with authorize_user and authorize_admin.
Add those methods in your application_controller:

```ruby
def authorize_user
        token = request.headers['Authorization']&.split(' ')&.last
        payload = decode_token(token)
        if payload.nil?
            render json: { error: 'Unauthorized' }, status: :unauthorized
        else
            @current_user = User.find(payload['user_id'])
        end
    end

    def authorize_admin
        if !@current_user || !@current_user.admin?
            render json: { error: 'Unauthorized' }, status: :unauthorized
        end
    end
```
Now, try to create a challenge without logging in and you should get an `Unauthorized` message. Log in, set the current user as admin, try again and it should work.

Et voila, let me know in the comments section if you found this article helpful.