Skip to content

2. Users & Authentication

Nicolas Erlichman edited this page Aug 28, 2024 · 11 revisions

Authentication

The first thing that we'll need to add to the application are users that can sign up and log in.

In this step we'll see how to do just that with the support of the gem (external library) devise.

Adding devise

The Gemfile file allows you to specify what gem dependencies are needed for your Rails application.

We can add devise (or any other gem) to our application by adding the following to the Gemfile:

...
gem "pg"
# ===========
gem 'devise'
# ===========
...

And then running the following in your console:

foo@bar:~$ bundle install

Lastly, to finish installing devise we need to run:

foo@bar:~$ rails generate devise:install

Adding devise-token-auth

Devise by itself is not enough if we are doing an api only rails app, like we do in this workshop. In this cases, we can add the gem devise-jwt to bridge the gap.

Let's add to our Gemfile:

...
gem 'devise'
# ===========
gem 'devise-jwt'
# ===========
...

and run:

foo@bar:~$ bundle install

Edit the file config/initializers/devise.rb to include the following content:

# frozen_string_literal: true

Devise.setup do |config|
  # ...

  # Devise jwt config

  config.jwt do |jwt|
    jwt.secret = ENV.fetch('JWT_SECRET', nil)
  end
end

Edit the file .ENV to include:

JWT_SECRET=secret_signature # you can generate one with `SecureRandom.hex(32)`

Users

Generating users

We can use devise's generator to automatically create our users model and migration.

foo@bar:~$ rails generate devise user
      invoke  active_record
      create    db/migrate/YYYYMMddhhmmss_devise_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
      insert    app/models/user.rb
       route  devise_for :users

In this workshop we leave specs (tests) as future work. We strongly advise to replace the auto-generated specs and use rspec-rails gem instead.

Migration

We will need to modify the migration generated by devise to add the jti's that will be used by devise-jwt. To do that, let's add the following to our migration db/migrate/YYYYMMddhhmmss_devise_create_users.rb:

## Tokens
...
# ===========
t.string :jti, null: false
t.index :jti, unique: true
# ===========

t.timestamps null: false
...

That's it, now you can run the migration to update your database

foo@bar:~$ rails db:migrate
== 20220818142458 DeviseCreateUsers: migrating ================================
-- create_table(:users)
   -> 0.0069s
-- add_index(:users, :email, {:unique=>true})
   -> 0.0019s
-- add_index(:users, :reset_password_token, {:unique=>true})
   -> 0.0012s
== 20220818142458 DeviseCreateUsers: migrated (0.0100s) =======================

Controller and Endpoints

API Controller

First we create an API controller which all other controllers will inherit from:

app/controllers/api/api_controller.rb

# frozen_string_literal: true

module Api
  class ApiController < ActionController::API

    before_action :authenticate_user!

    def route_not_found
      render_generic_error('Route not found', status: :not_found)
    end
  end
end

User model

Modify the User model to include:

class User < ApplicationRecord
  # ...
  include Devise::JWT::RevocationStrategies::JTIMatcher
  # ...
  devise :database_authenticatable, :registerable,
         #...
         :jwt_authenticatable, jwt_revocation_strategy: self
end

Fake Rails Web Sessions

Since Rails uses web sessions but devise-jwt is sessionless (stateless) , we need to tell Devise to use a fake session.

In order to do that, first we create the FakeSession model at app/controllers/concerns/fake_session.rb:

# frozen_string_literal: true

# This module is used to avoid the following error:
# "NoMethodError: undefined method `[]' for nil:NilClass"
# when using Devise with an API-only Rails app.
# This concern must be used in the controller that inherits from
# Devise::RegistrationsController when using Devise JWT.
module FakeSession
  extend ActiveSupport::Concern

  class FakeRackSession < Hash
    def enabled?
      false
    end
  end

  included do
    before_action :set_fake_rack_session_for_devise

    private

    def set_fake_rack_session_for_devise
      request.env['rack.session'] ||= FakeRackSession.new
    end
  end
end

Then we need to modify Devise controllers to tell it to use the fake session. To do that we need to modify:

  • app/controllers/api/users/passwords_controller.rb
  • app/controllers/api/users/registrations_controller.rb
  • app/controllers/api/users/sessions_controller.rb

And include:

# frozen_string_literal: true

module Api
  module Users
    class XXXController < Devise::XXXController
      include FakeSession
      respond_to :json
    end
  end
end

Our last step is going to be overriding the routes file to tell Devise to use our controllers. In order to do that, we need to modify config/routes.rb and make sure we have:

Rails.application.routes.draw do
  scope module: 'api', defaults: { format: :json } do
    devise_for :users, controllers: {
      confirmations: 'api/users/confirmations',
      sessions: 'api/users/sessions',
      registrations: 'api/users/registrations',
      passwords: 'api/users/passwords'
    }
   # ...

Conclusion

We are done! We have just created our first model User and we are ready to sign up and login to our application.

By using devise and devise-jwt in our ApiController every controller that inherits from it will have access to the logged in user by using current_user.

Next: 3.1. Companies Model

Clone this wiki locally