"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.
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)
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
:
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 yourinitializers
folder and paste this:
# 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
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
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
class Category < ApplicationRecord
validates :title, presence: true
validates :description, allow_blank: true
has_many :challenge_categories
has_many :challenges, through: :challenge_categories
end
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
class Challenge < ApplicationRecord
validates :title, presence: true
validates :description, presence: true
has_many :challenge_categories
has_many :categories, through: :challenge_categories
end
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:
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
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:
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:
# 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
:
include TokenHelper
Now let's create a sessions_controller
in our auth
folder and add this code:
# 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.
Now if you request POST /users
and POST /login
with the correct parameters using Postman, it should work.
Add the Challenge and Category controllers:
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
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:
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.
Top comments (1)
Nice article, quite interesting!