DEV Community

Cover image for A Complete Guide to Rails Authentication Using JWT
Mohammad Hossain
Mohammad Hossain

Posted on

A Complete Guide to Rails Authentication Using JWT

Rails JWT authentication

A JSON web token(JWT) is a JSON Object that is used to securely transfer information between two parties. JWT is widely used for securely authenticate and authorize user from the client in a REST API. In this post, I will go over step by step how to implement authentication using JWT in a rails API.

The gems we need:

gem 'bcrypt', '~> 3.1', '>= 3.1.12’

gem 'jwt', '~> 2.5

gem 'rack-cors'

gem 'active_model_serializers', '~> 0.10.12’
Enter fullscreen mode Exit fullscreen mode

After adding the gemfile run bundle install

Create the routes

  post "/users", to: "users#create"
  get "/me", to: "users#me"
  post "/auth/login", to: "auth#login"
Enter fullscreen mode Exit fullscreen mode

We will sign up new users making a POST request to /users. An existing user can log in by making a post request to “/auth/login” and a user can access user data by making a GET request to “/me”. We need 3 routes to the least, more routes can be added later as we go.

Add CORS

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
Enter fullscreen mode Exit fullscreen mode

Cross-Origin Resource Sharing (CORS) is a middleware that will accept requests to the API from only one client URL. The client URL that we want to allow to make a request will go into the origins. We set * origins, for now, that will allow anyone to make requests to our API for now.

Create the user model:

rails g model user username password_digest bio --no-test-framework
Enter fullscreen mode Exit fullscreen mode

Add the has_secure_password macro in the user model and validation for the username:

class User < ApplicationRecord
    has_secure_password
    validates :username, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode

has_secure_password is a bcrypt method that encrypts the password for each user. For this method to work we add password_digest field to our database table. However, when we make a post request to our server we send password. Bcrypt handles the rest for us.

Example request:

fetch('URL/auth/login',{
method: POST,
headers: {
    'Content-type': 'application/json'
},
body: {
    username: 'randomUserName',
    password: 'ask^dsk34'
})

Enter fullscreen mode Exit fullscreen mode

Adding JWT to our API

JSON Web Tokens are an open, industry-standard RFC 7519 method for representing claims securely between two parties. A JWT token looks like this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Image description

Source - JWT.io

It has three parts. The first part is the header that contain the algorithm and token type. The second part is the payload, the data we want to store in the token. The third part is the signature, which contains the secret key. We are going to generate JWT tokens from the application_controller.rb

class ApplicationController < ActionController::API

    def encode_token(payload)
        JWT.encode(payload, 'hellomars1211') 
    end

    def decoded_token
        header = request.headers['Authorization']
        if header
            token = header.split(" ")[1]
            begin
                JWT.decode(token, 'hellomars1211')
            rescue JWT::DecodeError
                nil
            end
        end
    end

end
Enter fullscreen mode Exit fullscreen mode

The encode_token method takes the payload as an argument. We will pass the user id as a payload. Then we call the JWT.encode(payload, 'hellomars1211') method to encode our token. Our payload will be the user id which then we can use to find the correct user. Notice that we pass a string: 'hellomars1211', along with the payload as an argument, which will be our secret key, that we’ll also use to decode our token. A secret key can be any combination of chars, symbols, numbers, etc. We will call the decoded_token method to decode a JWT token.

Whenever we make a request to a protected route or resource we pass the JWT token along with our data in the request, in the Authorization header using the Bearer schema. An example request:

fetch("URL/me", {
  method: "GET",
  headers: {
    Authorization: `Bearer <token>`,
  },
});
Enter fullscreen mode Exit fullscreen mode

Image description

We access the token from the header and decode the token using JWT.decode(token, 'hellomars1211', true, algorithm: 'HS256') method, we also need to pass the secret key in order to decode the token.

We can now access the user.id from the decoded token. We will create a method current_user that takes the user id from the decoded token and find the user using the same user id. This will give us the user that is currently logged in. We will create another method authorized to check if we have a current_user that is logged in.

def current_user 
    if decoded_token
        user_id = decoded_token[0]['user_id']
        @user = User.find_by(id: user_id)
    end
end

def authorized
    unless !!current_user
    render json: { message: 'Please log in' }, status: :unauthorized
    end
end
Enter fullscreen mode Exit fullscreen mode

Finally we will add a before_action rule to the controller that will call the authorized method before doing anything and check if the user is logged in. For any unauthorized request we will render a message: Please log in. This is what our application_controller will look like:

*app/controllers/application_controller.rb*

class ApplicationController < ActionController::API
    before_action :authorized

    def encode_token(payload)
        JWT.encode(payload, 'hellomars1211') 
    end

    def decoded_token
        header = request.headers['Authorization']
        if header
            token = header.split(" ")[1]
            begin
                JWT.decode(token, 'hellomars1211', true, algorithm: 'HS256')
            rescue JWT::DecodeError
                nil
            end
        end
    end

    def current_user 
        if decoded_token
            user_id = decoded_token[0]['user_id']
            @user = User.find_by(id: user_id)
        end
    end

    def authorized
        unless !!current_user
        render json: { message: 'Please log in' }, status: :unauthorized
        end
    end

end
Enter fullscreen mode Exit fullscreen mode

Create the users_controller

rails g controller users
Enter fullscreen mode Exit fullscreen mode

In the user controller, we will create an action for creating or signing up a new user. We will also create a token if a user signs up with valid data and send the token along with the response, this will make the user to be logged in right away when they sign up for our app. We will handle the sign up function in the create method inside the users_controller.rb.

class UsersController < ApplicationController
    rescue_from ActiveRecord::RecordInvalid, with: :handle_invalid_record

    def create 
        user = User.create!(user_params)
        @token = encode_token(user_id: user.id)
        render json: {
            user: UserSerializer.new(user), 
            token: @token
        }, status: :created
    end

    private

    def user_params 
        params.permit(:username, :password, :bio)
    end

    def handle_invalid_record(e)
            render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
    end
end
Enter fullscreen mode Exit fullscreen mode

We serialized our data to only return the user id, username, and bio. It wouldn’t make sense for us to return the password to the client and also the password is encrypted in our database.

class UserSerializer < ActiveModel::Serializer
  attributes :id, :username, :bio
end
Enter fullscreen mode Exit fullscreen mode

Now, remember when we added the before_action rule to the application_controller? That will prevent us creating a new user if we are logged in. But that doesn’t make any sense. How can we log in when we didn’t even sign up yet or how can we log in when we can’t create a new user at all? Well, in order for us to bypass authorization we will add a skip_before_action to the user controller and make exception for only the create method. This will allow us to skip the authorization if we want to sign up a new user.

Also, we will create method me to get the profile of the user that will return the current_user that we set in the application_controller.

*app/controllers/users_controller.rb*

class UsersController < ApplicationController
    skip_before_action :authorized, only: [:create]
    rescue_from ActiveRecord::RecordInvalid, with: :handle_invalid_record

    def create 
        user = User.create!(user_params)
        @token = encode_token(user_id: user.id)
        render json: {
            user: UserSerializer.new(user), 
            token: @token
        }, status: :created
    end

    def me 
        render json: current_user, status: :ok
    end

    private

    def user_params 
        params.permit(:username, :password, :bio)
    end

    def handle_invalid_record(e)
            render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
    end
end
Enter fullscreen mode Exit fullscreen mode

Our sign-up is ready. Let’s try making some request it in the postman:

Image description

Let’s gooooooooooo!! Oh, wait! What else are we missing? The most important part of the auth: the LOGIN!

To implement login we will create a new controller and we will call it auth_controller.

rails g controller auth
Enter fullscreen mode Exit fullscreen mode
*app/controllers/auth_controller.rb*

class AuthController < ApplicationController

    skip_before_action :authorized, only: [:login]
    rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found

    def login 
        @user = User.find_by!(username: login_params[:username])
        if @user.authenticate(login_params[:password])
            @token = encode_token(user_id: @user.id)
            render json: {
                user: UserSerializer.new(@user),
                token: @token
            }, status: :accepted
        else
            render json: {message: 'Incorrect password'}, status: :unauthorized
        end

    end

    private 

    def login_params 
        params.permit(:username, :password)
    end

    def handle_record_not_found(e)
        render json: { message: "User doesn't exist" }, status: :unauthorized
    end
end
Enter fullscreen mode Exit fullscreen mode

In the login method we are first finding the user with the username, if the user is not found we return an error message: "User doesn't exist". After we find the user we authenticate the user with the password using bcrypt’s authenticate method. Once the authentication is complete we create a token for the user and return the user along with token. In case the authentication is failed we return an error message: 'Incorrect password'. We also added the skip_before_action :authorized, only: [:login] here just like for the create method in the users controller.

Let’s make some calls in postman:

Image description

Image description

Image description

Our auth is complete.

User authentication is 3 fold. At first we validate the data, then we authenticate the user with correct username, and password, and finally we authorize the user.

Validation—————>Authentication————>Authorization

We cannot build a logout function if we authenticate using JWT. The JWT library doesn’t come with a destroy method for the token. So, how can we log out? That has to be handled in the client. If we have a react client, we can store the token when we login in the localStorage and remove it from the localStorage if we want to log out.

fetch("http://localhost:3000/auth/login/", {
      method: "POST",
      headers: {
        "Content-type": "application/json",
      },
      body: JSON.stringify({
        username: username,
        password: password,
      }),
    })
      .then((res) => res.json())
      .then((data) => {
        localStorage.setItem("jwt", data.jwt);
      })
Enter fullscreen mode Exit fullscreen mode

And we simply remove the jwt token from the localStorage to logout:

localStorage.removeItem("jwt")
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
code_bergley profile image
Berg

Such a detailed and informative blog, good job and thanks for your work.