Introdução

Olá, devs! 👋

Bem-vindos à terceira parte da nossa série sobre Grape e Rails! Nos posts anteriores, exploramos a configuração básica da API e implementamos operações CRUD para nossos eventos. Hoje, vamos dar um passo adiante e implementar autenticação para nossa API.

A segurança é um aspecto crucial de qualquer API, e implementar um sistema de autenticação robusto é essencial para proteger seus recursos. Neste post, vamos explorar como implementar autenticação com JWT (JSON Web Tokens) e como proteger nossas rotas.

Image description

Configurando Autenticação com JWT

Para nosso exemplo, vamos utilizar JWT para autenticação. Se você não está familiarizado com JWT, é um padrão para a criação de tokens de acesso que podem ser validados sem a necessidade de consultar um banco de dados. Os tokens JWT contêm informações sobre o usuário e expiram após um determinado período.

Primeiro, vamos adicionar as gems necessárias ao nosso projeto:

gem 'devise'  # Para autenticação de usuários
gem 'jwt'     # Para trabalhar com tokens JWT

Execute bundle install para instalar as dependências.

1. Criando o Serviço de JWT

Primeiro, precisamos criar um serviço para lidar com a codificação e decodificação de tokens JWT. Em app/services/jwt/json_web_token.rb:

# frozen_string_literal: true

module Jwt
  class JsonWebToken
    # SECRET = Rails.application.credentials.secret_key_base
    SECRET = "secret-key" # usar algum código no secrets ou algo mais seguro
    ENCRYPTION = "HS256"

    def self.encode(payload, exp = 120.hours.from_now)
      payload[:exp] = exp.to_i
      JWT.encode(payload, SECRET)
    rescue JWT::EncodeError => e
      Rails.logger.error("JWT Encode Error: #{e.message}")
    end

    def self.decode(token)
      body = JWT.decode(token, SECRET)[0]
      ActiveSupport::HashWithIndifferentAccess.new(body)
    rescue JWT::ExpiredSignature, JWT::DecodeError => e
      Rails.logger.error("JWT Encode Error: #{e.message}")
      nil
    end
  end
end

Observação: No ambiente de produção, você deve armazenar a chave secreta em um local seguro, como Rails.application.credentials.secret_key_base ou uma variável de ambiente.

2. Criando Helpers para Autenticação

Em seguida, vamos criar helpers para lidar com a autenticação em nossos endpoints. Em app/api/helpers/auth_helpers.rb:

# frozen_string_literal: true

module Helpers
  module AuthHelpers
    extend Grape::API::Helpers

    # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
    def authenticate_request!
      # Este exemplo é simplificado; no projeto real, usamos apenas headers["Authorization"]
      token = headers["Authorization"]&.split&.last || headers["authorization"]&.split&.last
      error!({ error: "Unauthorized", success: false }, 401) unless token

      # Decodifica o token JWT
      @jwt_result = Jwt::JsonWebToken.decode(token)
      error!({ error: "Invalid Token", success: false }, 401) unless @jwt_result

      # Busca o usuário atual (ou gera erro se não for encontrado)
      current_user!
    rescue StandardError => e
      error!({ error: "Authentication Error: #{e.message}" }, 401)
    end
    # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity

    # Este método busca o usuário atual e gera erro se não for encontrado
    def current_user!
      @current_user ||= User.find_by(id: @jwt_result["id"])
      error!({ error: "Invalid Token", success: false }, 401) unless @current_user
      @current_user
    end

    # Este método permite usar o current_user em qualquer lugar
    def current_user
      @current_user
    end
  end
end

3. Implementando o Endpoint de Login

Agora, vamos criar um endpoint para autenticar usuários e gerar tokens JWT. Em app/api/v1/login.rb:

# frozen_string_literal: true

module V1
  class Login < Grape::API
    resource :login do
      desc "Login ", {
        success: Entities::Users::LoginResponse,
        failure: [{ code: 401, message: "Unauthorized" }],
        params: ::Helpers::ContractToParams.generate_params(LoginContract)
      }
      post do
        contract = LoginContract.new
        result = contract.call(params)
        error!({ errors: result.errors.to_h }, 422) if result.failure?

        @user = User.find_by(email: params[:email])
        if @user&.valid_password?(params[:password])
          token = ::Jwt::JsonWebToken.encode({ id: @user.id })
          status 200
          present({ user: @user, token: token }, with: Entities::Users::LoginResponse)
        else
          error!({ error: "Unauthorized" }, 401)
        end
      end
    end
  end
end

Nota: Este exemplo usa valid_password? do Devise, que é um método padrão para verificar senhas.

4. Definindo Entidades para Resposta de Login

Para formatar as respostas do endpoint de login, vamos criar algumas entidades:

Em app/api/entities/users/user_response.rb:

# frozen_string_literal: true

module Entities
  module Users
    class UserResponse < Grape::Entity
      expose :id, documentation: { type: "Integer", desc: "User id" }
      expose :email, documentation: { type: "String", desc: "User email" }
    end
  end
end

Em app/api/entities/users/login_response.rb:

# frozen_string_literal: true

module Entities
  module Users
    class LoginResponse < Grape::Entity
      expose :user, using: Entities::Users::UserResponse,
                   documentation: { type: "Array", desc: "List of users" }
      expose :token, documentation: { type: "String", desc: "JWT token" }
    end
  end
end

5. Atualizando o API Grape

Agora, vamos atualizar nosso arquivo app/api/v1/api_grape.rb para incluir os helpers de autenticação e o novo endpoint de login:

# frozen_string_literal: true

class V1::ApiGrape < Grape::API
  version "v1", using: :path
  format :json
  default_format :json

  use CustomErrorMiddleware

  helpers ::Helpers::AuthHelpers

  mount V1::Events
  mount V1::Login

  add_swagger_documentation(
    api_version: "v1",
    hide_documentation_path: true,
    mount_path: "/swagger_doc",
    hide_format: true,
    info: {
      title: "Events API",
      description: "API for event management",
      contact_name: "Support Team",
      contact_email: "contact@example.com"
    },
    security_definitions: {
      Bearer: {
        type: "apiKey",
        name: "Authorization",
        in: "header",
        description: 'Enter "Bearer" followed by your token. Example: Bearer abc123'
      }
    },
    security: [{ Bearer: [] }]
  )
end

6. Adicionando Dados de Teste

Para facilitar os testes, podemos criar um usuário padrão usando seeds:

# db/seeds.rb

User.find_or_create_by!(email: "teste@examle.com") do |user|
  user.password = "123456"
  user.password_confirmation = "123456"
end

Execute rails db:seed para criar o usuário.

Protegendo Endpoints

Com nossa configuração de autenticação em vigor, agora podemos proteger nossos endpoints. Vamos atualizar nosso controlador de eventos para exigir autenticação:

module V1
  class Events < Grape::API
    before { authenticate_request! }

    # Resto do código permanece o mesmo...
  end
end

Agora, todos os endpoints no recurso events exigirão um token JWT válido para acesso.

Implementando Middleware para Tratamento de Erros

Para melhorar ainda mais nossa API, vamos implementar um middleware personalizado para tratar erros comuns. Em app/middleware/custom_error_middleware.rb:

# frozen_string_literal: true

class CustomErrorMiddleware < Grape::Middleware::Base
  def call(env)
    @app.call(env) # Passa a requisição adiante
  rescue Grape::Exceptions::MethodNotAllowed => e
    handle_method_not_allowed(e, env)
  rescue ActiveRecord::RecordNotFound => e
    handle_record_not_found(e)
  rescue StandardError => e
    handle_standard_error(e)
  end

  private

  def handle_record_not_found(error)
    # Poderia usar, por exemplo: Sentry.capture_exception(error, extra: { message: error.message })
    error_response(message: "Couldn't find #{error.model || 'record'} with id: #{error.id}", status: 404)
  end

  def handle_method_not_allowed(error, env)
    request_method = env["REQUEST_METHOD"]
    path = env["PATH_INFO"]

    Rails.logger.error "Method not allowed: #{request_method} for path #{path}"

    allowed_methods = if error.respond_to?(:headers) && error.headers["Allow"]
                        "Allowed methods: #{error.headers['Allow']}"
                      else
                        "Please use the appropriate HTTP methods for this endpoint"
                      end

    error_message = "The
    #{request_method} method is not allowed for this resource.
   #{allowed_methods}. please check your parameters"

    unless Rails.env.development?
      # Sentry.capture_exception(error, extra: {
      #                            request_method: request_method,
      #                            path: path,
      #                            message: error_message
      #                          })
    end

    error_response(message: error_message, status: 405)
  end

  def handle_standard_error(error)
    Rails.logger.error(error.message)
    Rails.logger.error(error.backtrace.join("\n"))
    # Sentry.capture_exception(error, extra: { message: error.message }) unless Rails.env.development?
    error_response(message: "Something went wrong", status: 500)
  end

  def error_response(message:, status:)
    throw :error, message: { error: message }, status: status
  end
end

Este middleware captura exceções comuns e as transforma em respostas de API adequadas. Com ele implementado, podemos simplificar nossos endpoints:

get ":id" do
  @event = Event.find(params[:id])
  present @event, with: ::Entities::EventResponse
end

O middleware capturará automaticamente a exceção ActiveRecord::RecordNotFound e retornará uma resposta de erro apropriada.

Testando a Autenticação

Agora que implementamos a autenticação, vamos adicionar alguns testes para garantir que tudo funcione corretamente:

# spec/api/v1/login_spec.rb

require 'rails_helper'

RSpec.describe V1::Login, type: :request do
  let(:base_url) { "/api/v1/login" }
  let(:email) { "user@example.com" }
  let(:password) { "password123" }

  before do
    @user = create(:user, email: email, password: password)
  end

  describe "POST /api/v1/login" do
    context "with valid credentials" do
      it "returns a JWT token" do
        post base_url, params: { email: email, password: password }

        expect(response).to have_http_status(:success)
        json_response = JSON.parse(response.body)

        expect(json_response["user"]["id"]).to eq(@user.id)
        expect(json_response["user"]["email"]).to eq(@user.email)
        expect(json_response["token"]).not_to be_nil
      end
    end

    context "with invalid credentials" do
      it "returns an error" do
        post base_url, params: { email: email, password: "wrong_password" }

        expect(response).to have_http_status(:unauthorized)
        json_response = JSON.parse(response.body)

        expect(json_response["error"]).to eq("Unauthorized")
      end
    end
  end
end

Documentação Swagger Atualizada

Com a adição da autenticação e do endpoint de login, nossa documentação Swagger agora mostrará:

  1. Um novo endpoint /login para autenticação
  2. Informações de segurança para todos os endpoints protegidos
  3. Um botão "Authorize" na interface Swagger para inserir o token JWT

Login

Adicionar Login token

Conclusão

Nesta terceira parte da nossa série, implementamos um sistema de autenticação completo para nossa API Grape:

  1. Configuramos JWT para gerar e verificar tokens
  2. Criamos helpers para autenticação
  3. Implementamos um endpoint de login
  4. Protegemos nossos endpoints com autenticação
  5. Adicionamos middleware para tratamento de erros
  6. Atualizamos nossa documentação Swagger

A autenticação é um componente crucial para qualquer API, e o Grape torna relativamente simples implementá-la. Ao combinar JWT com os helpers do Grape, conseguimos um sistema de autenticação robusto que protege nossos recursos e proporciona uma experiência de desenvolvedor agradável.

No próximo post, exploraremos recursos mais avançados do Grape, como paginação, ordenação e filtragem, para tornar nossa API ainda mais poderosa e flexível.


Gostou deste post? Deixe seus comentários abaixo e não se esqueça de conferir as partes anteriores desta série!

Repositório do exemplo: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample_pt3

Referências:


Me chamo Rodrigo Nogueira Barreto. Trabalho com Ruby on Rails desde 2015.

Caso queiram acompanhar essa jornada, por favor comentem e deixem seu like!