Skip to content

go-illa/otp_engine

OTP Engine

A comprehensive Rails plugin for One-Time Password (OTP) generation, delivery, and verification with multiple SMS provider support.

Features

  • 🔐 Secure OTP Generation: Numeric and alphanumeric OTP support
  • 📱 SMS Provider Support: Built-in Cequens provider with HTTParty
  • Interactor Pattern: Clean business logic separation using interactors
  • 🛡️ Rate Limiting: Configurable hourly and daily limits
  • 📝 Custom Messages: Simple message override with {code} placeholder
  • 🧪 Test Mode: Mock SMS sending for development and testing
  • 📊 Dual Storage Options: Database or Redis storage backends
  • 🚀 Redis Support: High-performance Redis storage with auto-expiration
  • ⚙️ Flexible Configuration: DSL-based configuration system
  • 🔄 Retry Logic: Automatic retries for failed SMS deliveries
  • 🎯 Purpose-Based OTPs: Independent OTP types for different use cases
  • Performance Optimized: Singleton Redis connections for better throughput
  • 🎛️ Custom OTP Support: Send your own OTP codes with validation

Installation

Add this line to your application's Gemfile:

gem 'otp_engine', path: 'path/to/otp_engine'  # or from git/rubygems

Standard Installation (Database Storage)

$ bundle install
$ rails generate otp_engine:install
$ rails db:migrate

Redis-Only Installation (No Database Required)

$ bundle install
$ rails generate otp_engine:install --redis-only
# No migration needed - Redis handles all storage

Installation Options

  • Standard: Creates database migration and supports both storage backends
  • Redis-Only: Skips database setup, uses Redis for all OTP storage
  • Hybrid: Start with database, switch to Redis later by updating configuration

Quick Start

Database Storage Configuration

# config/initializers/otp_engine.rb
OtpEngine.configure do |config|
  config.default_provider = :cequens

  # Storage Backend
  config.store_in_database = true

  # OTP Settings
  config.otp_length = 6
  config.otp_type = :numeric
  config.expires_in = 600 # 10 minutes
  config.test_mode = false # Set to true for development

  # Provider Configuration
  config.provider :cequens do |cequens|
    cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
    cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
    cequens.set :api_base_url, 'https://apis.cequens.com'
  end
end

Redis Storage Configuration

Option 1: New Redis Connection (OTP Engine manages Redis)

# config/initializers/otp_engine.rb
OtpEngine.configure do |config|
  config.default_provider = :cequens

  # Storage Backend
  config.store_in_database = false

  # Redis Configuration - OTP Engine creates new connection
  config.redis_config = {
    url: ENV['REDIS_URL'] || 'redis://localhost:6379/0',
    timeout: 5
  }

  # Example Redis URLs:
  # redis://localhost:6379/0                    # Local Redis
  # redis://username:password@redis.com:6379/0  # With authentication
  # rediss://redis.herokuapp.com:6379           # SSL Redis

  # OTP Settings
  config.otp_length = 6
  config.otp_type = :numeric
  config.expires_in = 600 # 10 minutes

  # Provider Configuration
  config.provider :cequens do |cequens|
    cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
    cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
    cequens.set :api_base_url, 'https://apis.cequens.com'
  end
end

Option 2: Use Existing Redis Client (Recommended for existing Redis setups)

# config/initializers/redis.rb
$redis = Redis.new(url: ENV['REDIS_URL'])

# config/application.rb (or config/initializers/otp_engine.rb)
Rails.application.config.after_initialize do
  OtpEngine.configure do |config|
    config.default_provider = :cequens

    # Storage Backend
    config.store_in_database = false

    # Use your existing Redis client
    config.redis_client = $redis
    # or config.redis_client = Rails.application.config.redis
    # or config.redis_client = Redis.current

    # OTP Settings
    config.otp_length = 6
    config.otp_type = :numeric
    config.expires_in = 600 # 10 minutes

    # Provider Configuration
    config.provider :cequens do |cequens|
      cequens.set :api_token, ENV['CEQUENS_API_TOKEN']
      cequens.set :sender_name, ENV['CEQUENS_SENDER_NAME']
      cequens.set :api_base_url, 'https://apis.cequens.com'
    end
  end
end

Option 3: Using Redis Connection Pool

# config/initializers/otp_engine.rb
require 'connection_pool'

OtpEngine.configure do |config|
  config.default_provider = :cequens
  config.store_in_database = false

  # Use connection pool for better concurrency
  config.redis_client = ConnectionPool.new(size: 10, timeout: 5) do
    Redis.new(url: ENV['REDIS_URL'] || 'redis://localhost:6379/0')
  end

  # ... other configuration
end

🚨 Initialization Order Important!

Problem: Rails initializers load alphabetically, so otp_engine.rb might load before redis.rb.

Simple Solution: Use Rails.application.config.after_initialize (as shown in Option 2 above)

This ensures OTP Engine configuration runs after all initializers have loaded, so your Redis client will be available.

Alternative: Rename your initializers with numbers:

# config/initializers/01_redis.rb
$redis = Redis.new(url: ENV['REDIS_URL'])

# config/initializers/02_otp_engine.rb
OtpEngine.configure do |config|
  config.redis_client = $redis
end

Basic Usage

# Send OTP with required purpose
result = OtpEngine.send_otp('+201234567890', { purpose: :verification })

if result.success?
  puts "OTP sent! Message ID: #{result.message_id}"
  puts "OTP Code: #{result.otp_code}" # For testing only!
else
  puts "Error: #{result.error}"
end

# Send OTP with custom message and purpose
result = OtpEngine.send_otp('+201234567890', {
  message: "Your login code is: {code}",
  purpose: :login
})

# Verify OTP (purpose must match what was used when sending)
result = OtpEngine.verify_otp('+201234567890', '123456', { purpose: :login })

if result.success?
  puts "OTP verified successfully!"
else
  puts "Verification failed: #{result.error}"
end

Custom OTP Support

You can send your own OTP codes instead of letting the engine generate them. Custom OTPs are validated against your configured otp_length and otp_type settings.

# Send a custom OTP code
result = OtpEngine.send_otp('+201234567890', {
  custom_otp: 'ABC123',
  purpose: :verification
})

# Custom OTP validation respects your configuration:
# - Length must match config.otp_length (default: 6)
# - Characters must match config.otp_type (:numeric or :alphanumeric)

# Example with numeric-only configuration
OtpEngine.config.otp_type = :numeric
result = OtpEngine.send_otp('+201234567890', {
  custom_otp: '123456',  # ✅ Valid: 6 digits
  purpose: :verification
})

# This will fail validation
result = OtpEngine.send_otp('+201234567890', {
  custom_otp: 'ABC123',  # ❌ Invalid: contains letters when numeric only
  purpose: :verification
})
# => Error: "Custom OTP code can only contain numbers"

# Example with alphanumeric configuration
OtpEngine.config.otp_type = :alphanumeric
result = OtpEngine.send_otp('+201234567890', {
  custom_otp: 'ABC123',  # ✅ Valid: 6 alphanumeric characters
  purpose: :verification
})

Custom OTP Configuration:

  • Enable/disable: config.allow_custom_otp = true (default: true)
  • Length validation: Uses config.otp_length (default: 6)
  • Character validation: Uses config.otp_type (:numeric or :alphanumeric)
  • Error messages: Clear validation feedback for invalid custom OTPs

In Controllers

class AuthController < ApplicationController
  def send_otp
    result = OtpEngine.send_otp(
      params[:phone_number],
      message: 'Your login code: {code}',
      purpose: :login
    )

    if result.success?
      render json: {
        success: true,
        message: 'OTP sent successfully'
      }
    else
      render json: {
        success: false,
        error: result.error
      }, status: :unprocessable_entity
    end
  end

  def verify_otp
    result = OtpEngine.verify_otp(
      params[:phone_number],
      params[:otp_code],
      purpose: :login
    )

    if result.success?
      # Handle successful verification
      user = User.find_by(phone_number: params[:phone_number])
      sign_in(user) if user

      render json: {
        success: true,
        message: 'Login successful'
      }
    else
      render json: {
        success: false,
        error: result.error
      }, status: :unprocessable_entity
    end
  end
end

Purpose-Based OTPs

OTP Engine supports different purposes for OTPs, allowing you to separate login codes from registration codes, password resets, etc. Each purpose has independent rate limiting and validation.

Available Purposes

The following purpose values are supported:

  • :verification (default) - General verification
  • :login - User login
  • :registration - User registration
  • :password_reset - Password reset
  • :phone_verification - Phone number verification
  • :two_factor - Two-factor authentication

Purpose Usage Examples

# Registration OTP
result = OtpEngine.send_otp('+201234567890', {
  purpose: :registration,
  message: "Welcome! Your registration code is: {code}"
})

# Login OTP (separate from registration)
result = OtpEngine.send_otp('+201234567890', {
  purpose: :login,
  message: "Your login code is: {code}"
})

# Password Reset OTP
result = OtpEngine.send_otp('+201234567890', {
  purpose: :password_reset,
  message: "Your password reset code is: {code}"
})

# Verify with matching purpose
result = OtpEngine.verify_otp('+201234567890', '123456', { purpose: :login })

Important Notes

  • Purpose must match: When verifying, you must use the same purpose that was used when sending
  • Independent rate limits: Each purpose has separate hourly/daily rate limits
  • Validation: Invalid purpose values will return an error with valid options
  • Required parameter: Purpose must be specified for both sending and verifying OTPs

Storage Backends

OTP Engine supports two storage backends that can be chosen based on your application's needs:

Database Storage

  • When to use: Traditional Rails applications with existing database infrastructure
  • Benefits: Audit trail, complex queries, familiar ActiveRecord interface
  • Setup: Requires database migration, uses OtpRecord model
  • Configuration: config.store_in_database = true

Redis Storage

  • When to use: High-throughput applications, microservices, stateless deployments
  • Benefits: Better performance, automatic expiration, no database dependencies
  • Setup: Requires Redis server, no migrations needed
  • Configuration: config.store_in_database = false + redis_config or redis_client

Redis Client Options

  1. New Connection (redis_config): OTP Engine creates its own Redis connection
  2. Existing Client (redis_client): Reuse your application's Redis connection
  3. Connection Pool: Use connection pooling for high-concurrency applications

Recommendation: Use redis_client to inject your existing Redis connection for better resource management.

Performance Considerations

Redis Advantages:

  • Faster Operations: In-memory storage for sub-millisecond access
  • 🚀 Auto-Expiration: Built-in TTL eliminates cleanup jobs
  • 📈 High Throughput: Optimized for concurrent OTP operations
  • 🔧 Connection Pooling: Singleton pattern prevents connection churn

Database Advantages:

  • 📊 Rich Queries: Complex analytics and reporting capabilities
  • 🔍 Audit Trail: Complete history of OTP operations
  • 🛠️ Familiar Tools: Standard Rails/ActiveRecord patterns

API Reference

OtpEngine.send_otp(phone_number, options = {})

Sends an OTP to the specified phone number.

Parameters:

  • phone_number (String): Target phone number
  • options (Hash): Options hash
    • :purpose - Required. Purpose of OTP (:verification, :login, :registration, :password_reset, :phone_verification, :two_factor)
    • :message - Custom message template with {code} placeholder
    • :provider - Specific provider to use
    • :otp_length - Override default OTP length
    • :otp_type - Override default OTP type
    • :expires_in - Override default expiration
    • :custom_otp - Send your own OTP code (validated against config settings)

Returns: Interactor result with:

  • success? - Boolean indicating success
  • otp_code - Generated OTP code
  • message_id - Provider message ID
  • otp_record - Database record (if stored)
  • error - Error message on failure
  • error_code - Error code symbol

OtpEngine.verify_otp(phone_number, code, options = {})

Verifies an OTP code for the given phone number.

Parameters:

  • phone_number (String): Phone number to verify
  • code (String): OTP code to verify
  • options (Hash): Options hash
    • :purpose - Required. Must match the purpose used when sending the OTP

Returns: Interactor result with:

  • success? - Boolean indicating success
  • verified - Boolean confirmation of verification
  • otp_record - Database record (if stored)
  • error - Error message on failure
  • error_code - Error code symbol

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

The gem is available as open source under the terms of the MIT License.

About

Ruby on Rails engine for secure OTP generation, SMS delivery, and verification with multiple provider support and purpose-based workflows

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages