DEV Community

Cover image for Ruby on Rails API with JWT Auth Tutorial
Alex Merced
Alex Merced

Posted on

Ruby on Rails API with JWT Auth Tutorial

Certain parts of this tutorial were adapted from this tutorial, https://medium.com/better-programming/build-a-rails-api-with-jwt-61fb8a52d833

Our Mission

Our goal is to make an API where users can login and creates notes under their account. I'm assuming some basic knowledge of Ruby on Rails and that you have it installed. If this is not the case just do this basic hello world tutorial to get a basic introduction before this tutorial.

https://iridakos.com/programming/2013/11/24/saying-hello-world-with-ruby-on-rails

Starting Up Our Project

rails new notesapi --database=postgresql --api
Enter fullscreen mode Exit fullscreen mode

So this will create a new project in a notesapi folder. The --database=postgresql flag will make our database.yml and gemfile setup for postgres and the --api flag will build a minimal build for building api so we don't have all the bulk neccessary for rendering views.

Configuring your dependencies

Make sure to add the following to your Gemfile

gem 'bcrypt', '~> 3.1.7'
gem 'rack-cors'
gem 'jwt'
Enter fullscreen mode Exit fullscreen mode

Then after making these adjustments in terminal run the command bundle install which will read the Gemfile and install any as of yet uninstalled dependencies. Now what do these libraries do?

bcrypt: Encrypts strings, will be used to encrypt new user passwords and verify where passwords at login match the encrypted password in our database.

rack-cors: This will allow us to manage Cross Origin Resource Sharing (CORS) and determine which URLs are allowed to make requests to our server

JWT: Json Web Tokens (JWT) are often issued when a user successfully logs in containing encrypted information such as username. Our API will look for JWT tokens in request headers to know the request is authorized to access certain data. (So I login get a token, I send the token when I request my notes and this tells the API which user I am and that I'm properly logged in)

CORS

Head into config/initializers/cors.rb and enter the following. The "*" basically allows all sources to make requests to our server which we can change after we deploy and know what our front-end applications URL will be.


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

Setting up Auth Routes

Head over to config/routes.rb and include the following

Rails.application.routes.draw do
  resource :users, only: [:create]
  post "/login", to: "users#login"
  get "/auto_login", to: "users#auto_login"
end
Enter fullscreen mode Exit fullscreen mode

So we only want the create the resources route for users which will be used to create new users. The '/login' route will be used for logging in and 'auto_login' will allow you to verify you are logged in using your JWT.

Creating the User Model

rails g model User username:string password_digest:string age:integer
Enter fullscreen mode Exit fullscreen mode

Notice the password field is called 'password_digest', this is a convention that the Bcrypt library is looking for to know that it will be responsible for digesting the password and encrypting it.

Securing our User Model

Head into app/models/user.rb and add the following macro which will notify bCrypt to do its thing.

class User < ApplicationRecord
    has_secure_password
end
Enter fullscreen mode Exit fullscreen mode

The User Controller

Run the following command to create a User controller...

rails g controller Users
Enter fullscreen mode Exit fullscreen mode

This will create a new controller file at app/controllers/, let's open it up and begin building some controller functions.

Migrating the model to your database

Before this next step take a moment to go to your config/database.yml and set your postgres settings

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

development:
  <<: *default
  database: database_name
  username: test
  password: test
  host: localhost
  port: 5432

test:
  <<: *default
  database: database_name

production:
  <<: *default
  url: <%= ENV['DBURI'] %>
Enter fullscreen mode Exit fullscreen mode

So locally it will work off your localhost database and in production it'll pull a connection string from your Heroku config vars (it'll look for a variable called DBURI in this case).

After setting up the connection run the following command to generate the migration files (files that contain commands to be sent to database) and then migrate (send those commands to your database and set a marker to know which migrations it has already ran).

rails db:create && rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Seed some data

We will want to put some initial data in our database to test and work with so go to your db/seeds.rb file and add the following line.

user = User.create(username: "alexmerced", password: "pineapple", age: 35)
Enter fullscreen mode Exit fullscreen mode

After saving the new user in the seed file run the following command to seed the database.

rails db:seed
Enter fullscreen mode Exit fullscreen mode

The Application Controller

Head over and open app/controllers/application_controller.rb

Add the following code...

class ApplicationController < ActionController::API
    before_action :authorized

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

  def auth_header
    # { Authorization: 'Bearer <token>' }
    request.headers['Authorization']
  end

  def decoded_token
    if auth_header
      token = auth_header.split(' ')[1]
      # header: { 'Authorization': 'Bearer <token>' }
      begin
        JWT.decode(token, 'yourSecret', true, algorithm: 'HS256')
      rescue JWT::DecodeError
        nil
      end
    end
  end

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

  def logged_in?
    !!logged_in_user
  end

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

So let's break down these methods:

encode_token this method takes in a payload (a hash of key/values you want to save in the token) and signs a token using a secret key. (in production this should an ENV variable.)

auth_header When determining is someone is logged in the frontend usually sends the token in the "Authorization" header preceded by the word bearer, this function checks to see if the header was sent.

example of a request including the appropiate header...

fetch("/", {
  method: "post",
  headers: {
    "Content-Type": "application/json",
    Authorization: `bearer ${JWT_TOKEN}`,
  },
  body: JSON.stringify(requestBody),
})
Enter fullscreen mode Exit fullscreen mode

before_action :authorized : this macro tells rails to run the authorized funcion before completing any request to the API. (why, to make sure they are logged in before they use the API)

decoded_token: If the auth header is present this function parses the string for the token and verified it and pulls out the payload (the hash we stored in the token when we created it). If verification fails it will return nil.

logged_in_user if decoding the token is successful it grabs the username from the payload and searches our user in the database.

logged_in returns true if logged in

authorized: returns a message asking user to log in if unless logged_in return true

The Users Controller

Now to add some methods to the user controller, head over to app/controllers/user_controller.rb and add the following methods.

class UsersController < ApplicationController
  before_action :authorized, only: [:auto_login]

  # REGISTER
  def create
    @user = User.create(user_params)
    if @user.valid?
      token = encode_token({user_id: @user.id})
      render json: {user: @user, token: token}
    else
      render json: {error: "Invalid username or password"}
    end
  end

  # LOGGING IN
  def login
    @user = User.find_by(username: params[:username])

    if @user && @user.authenticate(params[:password])
      token = encode_token({user_id: @user.id})
      render json: {user: @user, token: token}
    else
      render json: {error: "Invalid username or password"}
    end
  end


  def auto_login
    render json: @user
  end

  private

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

end
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

before_action :authorized, only: [:auto_login]: This required the authorization method from the Application_Controller to be run before only the auto_login method of this controller.

create This creates a new user using the data returned from the user_params method. If the new user is validated (matches the user schema) then a JWT token is signed and returned in a json object to the client.

login Looks up the username and compares the password passed in, if all checks out, generate an JWT token and sends it over in a JSON object.

auto_login renturns the user object as JSON assuming the user has previous logged in (in which case the authenticate function will run successfully)

user_params private function that parses the needed data from the request to create a new user

Time to test our API Auth

Run your server

rails s -p 4000
Enter fullscreen mode Exit fullscreen mode

make post request to /login with your username and password, copy token from response
make a get request to /auto_login with your token in your headers

Making the Notes item

let's create our notes model/controller/routes with the following command

rails g scaffold note message:string user:references
Enter fullscreen mode Exit fullscreen mode

The Result

model...

class Note < ApplicationRecord
  belongs_to :user
end
Enter fullscreen mode Exit fullscreen mode

It has belongs to since we specified the user field references our user model. So a notes cannot be created without associating it with a user.

In the controller file...

class NotesController < ApplicationController
  before_action :set_note, only: [:show, :update, :destroy]

  # GET /notes
  def index
    @notes = Note.all

    render json: @notes
  end

  # GET /notes/1
  def show
    render json: @note
  end

  # POST /notes
  def create
    @note = Note.new(note_params)

    if @note.save
      render json: @note, status: :created, location: @note
    else
      render json: @note.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /notes/1
  def update
    if @note.update(note_params)
      render json: @note
    else
      render json: @note.errors, status: :unprocessable_entity
    end
  end

  # DELETE /notes/1
  def destroy
    @note.destroy
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_note
      @note = Note.find(params[:id])
    end

    # Only allow a trusted parameter "white list" through.
    def note_params
      params.require(:note).permit(:message, :user_id)
    end
end
Enter fullscreen mode Exit fullscreen mode

Nice to see all our crud routes initially built out for us!

Then our migration file!

class CreateNotes < ActiveRecord::Migration[6.0]
  def change
    create_table :notes do |t|
      t.string :message
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

and last our routes

Rails.application.routes.draw do
  resources :notes
  resource :users, only: [:create]
  post "/login", to: "users#login"
  get "/auto_login", to: "users#auto_login"
end
Enter fullscreen mode Exit fullscreen mode

our notes already has all the main resources routes all setup, nice!

Adding auth to notes

in the notes controllers add authorization as a before action...

before_action :authorized
Enter fullscreen mode Exit fullscreen mode

modify the controllers like so...


  # GET /notes
  def index
    @notes = Note.where(user_id: @user.id)

    render json: @notes
  end

  # GET /notes/1
  def show
    render json: @note
  end

  # POST /notes
  def create
    @note = Note.new(note_params)
    @note.user_id = @user.id

    if @note.save
      render json: @note, status: :created, location: @note
    else
      render json: @note.errors, status: :unprocessable_entity
    end
  end

Enter fullscreen mode Exit fullscreen mode

Notice the changes are mainly to the create and index function. The change to the index function makes sure that we are only grabbing notes of the particular user and the changes in create makes sure every user is assigned a user_id at creation.

The test the API and you'll find you'll need to login before you can create and view notes, congrats!

Top comments (4)

Collapse
 
carlorossi11 profile image
Carlo • Edited

Very helpful guide! I followed it exactly but had to make a few additional adjustments to the "notes"controller (used "requesters" in my project) to get it working in postman

dev-to-uploads.s3.amazonaws.com/i/...

Collapse
 
theitsloth profile image
theitsloth

Awesome tutorial, but ApplicationController::logged_in? should not trigger a database query, as this defeats the purpose of using JWTs. It should instead use Application::decoded_token.

Collapse
 
vuducvi1999 profile image
Vuducvi1999

This is great one for newbie!

Collapse
 
prpe profile image
Muhammadreza Haghiri

Just a few questions. How can you login, sign up or logout using CURL for instance?