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: "[email protected]"
},
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: "[email protected]") 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) { "[email protected]" }
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)