DEV Community

Shraddha
Shraddha

Posted on

SpendWise - Budget management app (Ruby on Rails + React) - Part 4

Welcome back! I appreciate you sticking with me on this project. Today, we’re going to jump straight into user authentication and explore why I went with Devise and JWT for this feature.

In previous projects, I’ve used Devise for traditional Rails apps and found it to be simple and reliable for handling user registration, login, and password management. But when it comes to API-only apps—where you need stateless authentication—JWT (JSON Web Token) is a much better solution than session-based approaches.

By combining Devise with JWT, I get the best of both worlds: Devise makes user management (registration, login, etc.) a breeze, while JWT provides secure, stateless authentication that fits perfectly with an API setup.

Let’s dive into how it all works!

1. Add required gems

First, we need to include the necessary gems for Devise and JWT. Add these to your Gemfile:

gem 'devise'
gem 'devise-jwt'
Enter fullscreen mode Exit fullscreen mode

Run bundle install to install the gems.

2. Install Devise

Next, install Devise and generate the User model:

rails generate devise:install
rails generate devise User
Enter fullscreen mode Exit fullscreen mode

In your User model (app/models/user.rb), include the following Devise modules:

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,

end
Enter fullscreen mode Exit fullscreen mode

Run migrations to update your database schema:

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

3. Enabling CORS

Since the API will interact with a frontend (likely running on a different domain), we must enable CORS to allow cross-origin requests. This step is critical for securely allowing your frontend (e.g., a React app) to communicate with your backend API.

First, add the rack-cors gem:

gem 'rack-cors'
Enter fullscreen mode Exit fullscreen mode

Run bundle install and uncomment the contents of the file config/initializers/cors.rb and add the following code:

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

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

Also, ensure that Devise isn’t using navigational formats by updating config/initializers/devise.rb. uncomment and edit the following line:

config.navigational_formats = []
Enter fullscreen mode Exit fullscreen mode

This will prevent devise from using flash messages which are a default feature and are not present in Rails api mode.

And lastly, add this toconfig/environments/development.rb

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
Enter fullscreen mode Exit fullscreen mode

This helps with Devise's password recovery and other mailer features.

4. Set up devise-jwt

Ensure that your config/master.key file exists so that Rails can decrypt credentials containing the secret key base. If it’s missing, you can generate it by running:

EDITOR="code --wait" bin/rails credentials:edit
Enter fullscreen mode Exit fullscreen mode

Rails will generate the config/master.key when you save and close the file.

5. JWT Configuration
In config/initializers/devise.rb, configure JWT for the Rails app:

config.jwt do |jwt|
  jwt.secret = Rails.application.credentials.fetch(:secret_key_base)
  jwt.dispatch_requests = [['POST', %r{^/users/sign_in$}]]
  jwt.revocation_requests = [['DELETE', %r{^/users/sign_out$}]]
  jwt.expiration_time = 120.minutes.to_i
end
Enter fullscreen mode Exit fullscreen mode

This config ensures that JWT is used for login (sign-in) and logout (sign-out), with tokens expiring after two hours.

6. Creating custom controllers

Next, we generate the devise controllers (sessions and registrations) to handle sign-ins and sign-ups

rails g devise:controllers users -c sessions registrations
Enter fullscreen mode Exit fullscreen mode

Then we must override the default routes provided by devise and add route aliases.

Rails.application.routes.draw do
  devise_for :users, controllers: {
    sessions: 'users/sessions',
    registerations: 'users/registerations'
  }
end
Enter fullscreen mode Exit fullscreen mode

7. Revocation Strategy

To manage token revocation and prevent the reuse of tokens after a user logs out, I used the JTIMatcher strategy. This strategy works by adding a unique identifier to each token (called a JTI) and storing it in the database. When a user logs out, the JTI is invalidated, making any token associated with it unusable.

In your User model, the jti field is used to track valid tokens:

rails g migration addJtiToUsers jti:string:index:unique
Enter fullscreen mode Exit fullscreen mode

Then, update the migration file:

class AddJtiToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :jti, :string, null: false
    add_index :users, :jti, unique: true
  end
end
Enter fullscreen mode Exit fullscreen mode

Run the migration:

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Finally, update your User model:

class User < ApplicationRecord
  include Devise::JWT::RevocationStrategies::JTIMatcher
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :jwt_authenticatable, jwt_revocation_strategy: self

  def jwt_payload
    super
  end
end
Enter fullscreen mode Exit fullscreen mode

8. Custom Devise Controllers

To handle JSON responses, we create custom controllers for user registration and session management:

We’ll leverage some Devise helper methods to define how the application behaves in specific scenarios:

respond_with: manages the response for POST requests, such as after registration or login.

respond_to_on_destroy: handles the response for DELETErequests, like when logging out.

in app/controllers/users/registrations_controller.rb:

class Users::RegistrationsController < Devise::RegistrationsController
  respond_to :json
  private

  def respond_with(current_user, _opts = {})
    if resource.persisted?
      render json: {
        status: {code: 200, message: 'Signed up successfully.'},
        data: UserSerializer.new(current_user).serializable_hash[:data][:attributes]
      }
    else
      render json: {
        status: {message: "User couldn't be created successfully. #{current_user.errors.full_messages.to_sentence}"}
      }, status: :unprocessable_entity
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

in app/controllers/users/sessions_controller.rb:

class Users::SessionsController < Devise::SessionsController
  respond_to :json

  private

  def respond_with(resource, _options = {})
    render json: { status: { code: 200, message: 'User signed in successfully' }, data: current_user }, status: :ok
  end

  def respond_to_on_destroy
    token = request.headers['Authorization']&.split(' ')&.last
    if token.nil?
      render json: { status: 401, message: 'Token missing or malformed' }, status: :unauthorized
    else
      begin
        jwt_payload = JWT.decode(token, Rails.application.credentials.fetch(:secret_key_base), true, algorithm: 'HS256').first
        current_user = User.find(jwt_payload['sub'])
        if current_user
          render json: { status: 200, message: 'Signed out successfully' }, status: :ok
        else
          render json: { status: 401, message: 'User has no active session' }, status: :unauthorized
        end
      rescue JWT::DecodeError
        render json: { status: 401, message: 'Invalid token' }, status: :unauthorized
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This configuration allows us to handle sign-in and sign-out responses in JSON format and validates JWT tokens on sign-out.

9. Updating Routes

We now define routes for Devise, pointing them to the custom controllers:

Rails.application.routes.draw do
  devise_for :users, controllers: {
    sessions: 'users/sessions',
    registrations: 'users/registrations'
  }
end
Enter fullscreen mode Exit fullscreen mode

This connects the user authentication flow to the appropriate controllers.

Phew! That was quite a bit of coding. Now, the final step is to test whether all our hard work has paid off and if everything is functioning as expected

10. Testing with Postman

In application terminal, run rails s to start the server.

To test the API, use Postman to send HTTP requests to the app.

Creating a user:

Creating username and password

Logging in:

Sign-in

After we log in, we can check that the authorization token was received:

Logging out:

When we log out the authorization token needs to be passed, for testing, we have to manually add it to Postman:

Sign-out

What’s Next?
That’s all for now! In the next post, I’ll dive into creating models and controllers, and implementing one-to-many relationships with validations. Stay tuned!

Design and Implement Database Schema

Top comments (0)