DEV Community

jacksonPrimo
jacksonPrimo

Posted on • Edited on

Autenticação JWT em uma Api Rest Ruby On Rails

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Para rodar essa migration e fazer o banco receber as atualizações dos models da aplicação usamos o comando abaixo.

$ rails db:migrate 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Vamos gerar o controller que ficará responsável pela autenticação.

$ rails g controller auth signin signup 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
# 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 
Enter fullscreen mode Exit fullscreen mode

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' 
Enter fullscreen mode Exit fullscreen mode

E execute:

$ bundler install 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

Vamos usar este middleware em um segundo controller de teste.

$ rails g controller user 
Enter fullscreen mode Exit fullscreen mode
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 
Enter fullscreen mode Exit fullscreen mode

Se quiser que o middleware seja apenas para essa rota, pode usar o only.

before_action :authenticate_user, only: %i[get_info] 
Enter fullscreen mode Exit fullscreen mode

E por fim caso queira pular esse middleware use o skip_before_action.

skip_before_action :authenticate_user, only: %i[get_info] 
Enter fullscreen mode Exit fullscreen mode

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") 
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
daibushi profile image
Jaderson Nascimento

Olá, achei um typo simples no texto

E execute:

$ bunlder install

deveria ser bundle ou bundler

Collapse
 
jackson_primo profile image
jacksonPrimo

corrigido! obrigado pelo aviso