Neste artigo vou mostrar como implementar um autenticação jwt seguindo uma arquitetura um pouco fora da curva do que as docs do rails ensina.
Eu sou uma pessoa em processo de adaptação ao rails, então procurando por tutoriais na internet quase sempre me deparo apenas com exemplos simples, onde tudo é resolvido nos controllers, algo que me incomoda muito pois eu sei que conforme o projeto cresce as regras de negócio passam a ser muito mais complexas que um simples MVP de um blog, logo logo esses controllers vão ficar enormes, resolvi fazer uso então de UseCases para guardar as regras de negócio como veremos mais para frente.
O setup inicial do projeto segue o desse artigo a parte:
https://dev.to/jackson_primo/inicializando-um-projeto-ruby-on-rails-usando-postgresql-docker-compose-1gh5
Let's Bora
Começaremos adicionando nosso model de User na nossa aplicação, afinal ele será o foco da autenticação:
$ rails g model user name:string username:string email:string password_digest:string
Este comando irá criar uma nova migration dentro da pasta db/migrations, nele irá ter os comandos de criação da tabela User.
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email
      t.string :password_digest
      t.timestamps
    end
  end
end
Para rodar essa migration e fazer o banco receber as atualizações dos models da aplicação usamos o comando abaixo.
$ rails db:migrate
Após atualizar o banco será criado ou atualizado também um arquivo db/schema.rb que irá conter a estrutura das tabelas.
ActiveRecord::Schema[7.0].define(version: 2024_08_21_003305) do
  enable_extension "plpgsql"
  create_table "users", force: :cascade do |t|
    t.string "name"
    t.string "email"
    t.string "password_digest"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end
end
Agora vamos adicionar algumas modificações no model:
class User < ApplicationRecord
  has_secure_password
  validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, presence: true, uniqueness: true
  validates :name, presence: true, length: { maximum: 50 }
  validates :password, presence: true, length: { minimum: 6 }
  before_save :downcase_email
  private
  def downcase_email
    self.email = email.downcase
  end
end
Na segunda linha temos o has_secure_password, que adiciona recursos de autenticação nativos do rails no model, como a criação do campo password_digest que abriga o password encriptado e o método authenticate no model que verifica se uma string corresponde ao password encriptado.
Com nosso model pronto vamos configurar os controllers, primeiramente adicionando alguns métodos no application_controller, que vai ser herdado por todos os outros controllers:
class ApplicationController < ActionController::API
  def render_result result
    if result.is_a?(Hash) && result[:error]
      render json: { error: result[:error] }, status: result[:code]
    else
      render json: result
    end
  end
  def params 
    request.params
  end
end
Vamos gerar o controller que ficará responsável pela autenticação.
$ rails g controller auth signin signup
Nele colocaremos 2 métodos, um de registro e outro de login.
class AuthController < ApplicationController
  def signin
    result = ::UseCases::Auth::Signin.new(params).call
    render_result result
  end
  def signup
    result = ::UseCases::Auth::Signup.new(params).call
    render_result result
  end
end
Note que em cada função decidi deixar as regras de negócio para um arquivo a parte que seriam os UseCases. 
Ps: Antes de prosseguirmos uma breve explicação sobre os UseCases, o uso deles a meu ver representa bem o uso do principio Single Responsability do SOLID, pois cada arquivo representa apenas uma ação que deve ser executada, possuindo um nome que reflete esta ação e apenas um método público "call". Elas serão adicionadas dentro de app -> use_cases e cada pasta dentro dela representa um módulo que trata de um conjunto de regras de negócio, podendo ser de uma funcionalidade ou apenas de um model no banco.
Vamos criar o modulo de useCase chamado Auth, começando pela classe base que é responsável por abrigar funções e variáveis que podem ser reaproveitadas por outros arquivos dentro do modulo.
# app/use_cases/base.rb
module UseCases
  class CustomException < Exception
    attr_reader :code
    def initialize(message, error_code=500)
      super(message)
      @code = error_code
    end
  end
  class Base
    def initialize(params)
      @params = params
    end
  end
end
Inicialmente ela só vai pegar os parâmetros da request e jogar em uma variável de instancia, também adicionei uma classe chamada CustomException para tratar exceções aceitando a mensagem e o código de erro.
Agora vamos criar nosso signin:
module UseCases
  module Auth
    class Signin < Base
      include AuthHelper
      def call
        find_user
        authenticate
      rescue ::UseCases::CustomException => e
        { error: e.message, code: e.code }
      rescue Exception => e
        { error: e.message, code: 500 }
      end
      def find_user
        @user = ::User.find_by_email(@params[:email])
      end
      def authenticate 
        if @user&.authenticate(@params[:password])
          encode_token(@user)
        else
          raise ::UseCases::CustomException.new("password or email incorrect", 403)
        end
      end
    end
  end
end
A função authenticate @user vem do has_secure_password adicionado no model.
Agora partimos para o signup:
module UseCases
  module Auth
    class Signup < Base
      include AuthHelper
      def call
        already_has_user_with_this_email?
        user = create_user
        encode_token(user)
      rescue ::UseCases::CustomException => e
        { error: e.message, code: e.code }
      rescue Exception => e
        { error: e.message, code: 500 }
      end
      def already_has_user_with_this_email?
        user = ::User.find_by_email(@params[:email])
        raise ::UseCases::CustomException.new('email already in use', 400) if user 
      end
      def create_user
        user = User.new(sanitize_params)
        return user if user.save!
        raise ::CustomException.new("cannot register user: #{user.errors}", 400)
      end
      def sanitize_params
        @params.slice(:name, :password, :email)
      end
    end
  end
end
Note que em ambos UseCases temos a função encode_token, ela vem do helper AuthHelper incluido no início da classe. Vamos implementar ele:
$ rails g helper auth
# app/helpers/auth_helper.rb
require "jwt"
module AuthHelper
  def encode_token user
    exp = 3.days.from_now
    token = JWT.encode({ user_id: @user.id, exp: exp.to_i }, ENV['JWT_SECRET'], "HS256")
    { token: token, exp: exp }    
  end
end
A função encode_token usa a lib JWT para gerar um hash baseado no payload(composto pelo id do usuário e um tempo de expiração de 3 dias) e no secret que está em uma variável de ambiente. Para instalar a lib adicione a seguinte linha no seu Gemfile:
gem 'jwt', '~> 1.5', '>= 1.5.4'
E execute:
$ bundler install
Para finalizar vamos fazer um middleware para cuidar da verificação de autenticação de rotas. Para isso vamos adicionar uma função no nosso application_controller.rb chamada authenticate_user:
class ApplicationController < ActionController::Base
  include AuthHelper
  {...}
  def authenticate_user
    token = request.headers['Authorization']&.split(' ')&.last
    decoded_token = decode_token(token)
    user_id = decoded_token['user_id']
    user = User.find_by id: user_id
    request.params.merge!(session_user: user)
  rescue JWT::ExpiredSignature
    render json: { error: "token expirado" }, status: 403
  rescue JWT::DecodeError
    render json: { error: "token inválido" }, status: 403
  end
end
Esta função recupera o token do header Authorization e usa a função decode do AuthHelper para validar e decodificar ele. Por fim recupera o usuário no banco e mergeia nos parametros da request.
Para implementar o decode no AuthHelper é bem simples:
  def decode_token token
    JWT.decode(token, ENV['JWT_SECRET'])[0]
  end
Vamos usar este middleware em um segundo controller de teste.
$ rails g controller user
class UserController < ApplicationController
  before_action :authenticate_user
  def get_info
    data = params[:session_user].slice(:name, :email, :created_at)
    render json: { user: data }, status: 200
  end
end
Se quiser que o middleware seja apenas para essa rota, pode usar o only.
before_action :authenticate_user, only: %i[get_info]
E por fim caso queira pular esse middleware use o skip_before_action.
skip_before_action :authenticate_user, only: %i[get_info]
Caso você venha a ter problemas do tipo "NameError: uninitialized constant AuthController::UseCases" é provável que o auto import das configurações esteja seguindo as novas regras de nomenclatura de pastas(uma frescura ae do rails que não sei o motivo de existir), para evitar esse problema adicione a config:
# config/application.rb
config.eager_load_paths.delete("#{Rails.root}/app/use_cases")
config.eager_load_paths.unshift("#{Rails.root}/app")
Nas referências tem um artigo que explica melhor sobre isso.
Com isso temos uma estrutura de pastas e separação de regras de negócio bem interessante. Qualquer dica, sugestão ou duvida deixa nos comentários.
link de referências do artigo:
https://www.toptal.com/ruby-on-rails/rails-service-objects-tutorial
https://blog.appsignal.com/2020/06/17/using-service-objects-in-ruby-on-rails.html
https://www.fastruby.io/blog/rails/upgrade/zeitwerk/upgrading-to-zeitwerk.html
https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19
https://www.thoughtco.com/nameerror-uninitialized-2907928
https://medium.com/binar-academy/rails-api-jwt-authentication-a04503ea3248
    
Top comments (2)
Olá, achei um typo simples no texto
E execute:
$ bunlder install
deveria ser bundle ou bundler
corrigido! obrigado pelo aviso