Introduction
Hello, devs! 👋
Welcome to the third part of our series on Grape and Rails! In the previous posts, we explored the basic API configuration and implemented CRUD operations for our events. Today, we'll take a step further and implement authentication for our API.
Security is a crucial aspect of any API, and implementing a robust authentication system is essential to protect your resources. In this post, we'll explore how to implement authentication with JWT (JSON Web Tokens) and how to protect our routes.
Setting Up Authentication with JWT
For our example, we'll use JWT for authentication. If you're not familiar with JWT, it's a standard for creating access tokens that can be validated without the need to query a database. JWT tokens contain information about the user and expire after a certain period.
First, let's add the necessary gems to our project:
gem 'devise' # For user authentication gem 'jwt' # For working with JWT tokens
Run bundle install
to install the dependencies.
1. Creating the JWT Service
First, we need to create a service to handle the encoding and decoding of JWT tokens. In 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" # use some code in secrets or something more secure 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
Note: In a production environment, you should store the secret key in a secure location, such as
Rails.application.credentials.secret_key_base
or an environment variable.
2. Creating Authentication Helpers
Next, let's create helpers to handle authentication in our endpoints. In 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! # This example is simplified; in the real project, we only use headers["Authorization"] token = headers["Authorization"]&.split&.last || headers["authorization"]&.split&.last error!({ error: "Unauthorized", success: false }, 401) unless token # Decode the JWT token @jwt_result = Jwt::JsonWebToken.decode(token) error!({ error: "Invalid Token", success: false }, 401) unless @jwt_result # Fetch the current user (or generate an error if not found) current_user! rescue StandardError => e error!({ error: "Authentication Error: #{e.message}" }, 401) end # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity # This method fetches the current user and generates an error if not found 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 # This method allows using current_user anywhere def current_user @current_user end end end
3. Implementing the Login Endpoint
Now, let's create an endpoint to authenticate users and generate JWT tokens. In 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
Note: This example uses Devise's
valid_password?
, which is a standard method for verifying passwords.
4. Defining Entities for Login Response
To format the login endpoint responses, let's create some entities:
In 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
In 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. Updating the Grape API
Now, let's update our app/api/v1/api_grape.rb
file to include the authentication helpers and the new login endpoint:
# 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. Adding Test Data
To facilitate testing, we can create a default user using seeds:
# db/seeds.rb User.find_or_create_by!(email: "teste@examle.com") do |user| user.password = "123456" user.password_confirmation = "123456" end
Run rails db:seed
to create the user.
Protecting Endpoints
With our authentication setup in place, we can now protect our endpoints. Let's update our events controller to require authentication:
module V1 class Events < Grape::API before { authenticate_request! } # The rest of the code remains the same... end end
Now, all endpoints in the events
resource will require a valid JWT token for access.
Implementing Middleware for Error Handling
To further improve our API, let's implement a custom middleware to handle common errors. In app/middleware/custom_error_middleware.rb
:
# frozen_string_literal: true class CustomErrorMiddleware < Grape::Middleware::Base def call(env) @app.call(env) # Pass the request forward 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) # Could use, for example: 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
This middleware captures common exceptions and transforms them into appropriate API responses. With it implemented, we can simplify our endpoints:
get ":id" do @event = Event.find(params[:id]) present @event, with: ::Entities::EventResponse end
The middleware will automatically capture the ActiveRecord::RecordNotFound
exception and return an appropriate error response.
Testing Authentication
Now that we've implemented authentication, let's add some tests to ensure everything works correctly:
# 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
Updated Swagger Documentation
With the addition of authentication and the login endpoint, our Swagger documentation will now show:
- A new
/login
endpoint for authentication - Security information for all protected endpoints
- An "Authorize" button in the Swagger interface to enter the JWT token
Conclusion
In this third part of our series, we implemented a complete authentication system for our Grape API:
- Set up JWT to generate and verify tokens
- Created helpers for authentication
- Implemented a login endpoint
- Protected our endpoints with authentication
- Added middleware for error handling
- Updated our Swagger documentation
Authentication is a crucial component for any API, and Grape makes it relatively simple to implement. By combining JWT with Grape's helpers, we achieved a robust authentication system that protects our resources and provides a pleasant developer experience.
In the next post, we'll explore more advanced Grape features such as pagination, sorting, and filtering to make our API even more powerful and flexible.
Did you like this post? Leave your comments below and don't forget to check out the previous parts of this series!
Example repository: https://github.com/rodrigonbarreto/event_reservation_system/tree/mvp_sample_pt3
References:
My name is Rodrigo Nogueira Barreto. I've been working with Ruby on Rails since 2015.
If you want to follow this journey, please comment and leave your like!
Top comments (0)