Skip to main content

OTP & Authentication

Cm-admin provides built-in OTP and password authentication flows that can be opted into via configuration. The gem ships with a Devise strategy, session controller concern, OTP model, delivery providers, and view templates — so host apps no longer need to maintain these files themselves.


Overview

The auth system supports three methods configured via CmAdmin.config.auth_method:

MethodFlow
:otpUser enters email or phone → receives OTP → enters OTP to sign in
:passwordUser enters email or phone → enters password to sign in
:customHost app provides its own flow — the Devise strategy signs the user in directly

OTP delivery can go through email, phone (WhatsApp via Gupshup), or both. See CmAdminConfiguration for all config options.


Setup

1. Enable Catalyst OTP in the initializer

# config/initializers/zcm_admin.rb
CmAdmin.configure do |config|
config.auth_method = :otp # or :password, :custom
config.use_catalyst_otp = true # registers the gem's Devise strategy + model
# ...other config
end

use_catalyst_otp = true registers the gem's CmAdminAuthenticatable Devise strategy and model module via ActiveSupport::Reloader.to_prepare. This runs after app initializers (where the flag is set) but before eager load (where the User model needs the module). Host apps that don't opt in are unaffected.

2. Generate the OTP requests migration

rails g cm_admin:add_otp_requests
rails db:migrate

This creates the otp_requests table with columns: otp, email (citext), expired_at, status, verified_at, user_id (optional foreign key), and timestamps. An index is added on email.

3. Update the User model

Add the OtpAuthenticatable concern and has_many :otp_requests to your User model:

class User < ApplicationRecord
include OtpAuthenticatable

has_many :otp_requests, dependent: :destroy

devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :cm_admin_authenticatable

def can_access_admin_panel?
true # or your own authorization logic
end
end

The OtpAuthenticatable concern provides:

  • create_otp_request — generates a 6-digit OTP, creates an OtpRequest, and delivers it
  • verify_otp?(otp) — validates the OTP against the latest active request
  • verify_otp_with_email?(email, otp) — validates OTP for email-based (no user) flows
  • .create_otp_request_for_email(email) — class method for creating OTPs without a user record

4. Configure the sessions controller

The gem provides an OtpSessions concern that handles validate_email, validate_credentials, validate_otp, validate_password, resend_otp, and sign_in_with_credentials. Include it in your app's sessions controller:

# app/controllers/users/sessions_controller.rb
module Users
class SessionsController < Devise::SessionsController
layout 'cm_session'
helper CmAdmin::ViewHelpers

include CmAdmin::OtpSessions

# Add any custom actions or overrides here
# e.g. reset_password_email, send_reset_password_email, update_password
end
end

The concern provides after_successful_sign_in and after_sign_in_redirect_path as extension points for host apps to customize post-login behavior.

5. Configure routes

# config/routes.rb
Rails.application.routes.draw do
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords'
}

devise_scope :user do
post 'validate_email', to: 'users/sessions#validate_email'
get 'sign_in_with_credentials', to: 'users/sessions#sign_in_with_credentials'
post 'validate_credentials', to: 'users/sessions#validate_credentials'
get 'resend_otp', to: 'users/sessions#resend_otp'
get 'reset_password_email', to: 'users/sessions#reset_password_email'
post 'send_reset_password_email', to: 'users/sessions#send_reset_password_email'
post 'update_password', to: 'users/sessions#update_password'
end

mount CmAdmin::Engine => '/cm_admin'
end

Phone-Based OTP Delivery

To deliver OTPs via phone (e.g. WhatsApp through Gupshup):

1. Configure delivery channel and provider

CmAdmin.configure do |config|
config.auth_method = :otp
config.use_catalyst_otp = true
config.otp_delivery_channel = :phone_or_email # or :phone
config.otp_delivery_provider = :gupshup
end

When otp_delivery_channel is :phone_or_email, the login screen shows a toggle that lets the user switch between phone-based and email-based login:

  • The form shows a mobile number field (with the country-code selector used across cm-admin) by default.
  • A "Use email instead" link (styled like the "Forgot Password?" link) toggles to an email field; the link then becomes "Use phone instead" to switch back. The user can toggle between the two at any time before submitting.
  • OTP delivery follows the chosen identifier — a phone number sends the OTP via the configured phone provider only, and an email sends it via email only. This is handled automatically by the Devise strategy and OtpSessions concern based on whether the identifier contains @.

2. Add Gupshup credentials

# config/credentials.yml.enc or rails credentials:edit
gupshup:
api_key: "your_api_key"
source_number: "your_sender_number"
templates:
otp: "your_gupshup_template_id"

3. Ensure User model has phone_number

The Gupshup provider calls user.phone_number — your User model must have a phone_number attribute.

Custom delivery providers

Pass any object that responds to #deliver(user:, otp:, template:):

class MySmsProvider
def deliver(user:, otp:, template:)
# your SMS sending logic
end
end

config.otp_delivery_provider = MySmsProvider.new

Custom User Model

By default cm-admin uses the User model for authentication lookups, mentions, support tickets, and the history page. Override user_class with the model name or class:

config.user_class = 'AdminUser'
# or
config.user_class = AdminUser

user_class is lazily constantized, so it can be set before the model is loaded or after a Rails code reload.

Custom User Lookup

The default user_lookup lambda resolves users against config.user_class by email first, then by phone number (trailing digits match). Override it for custom logic:

config.user_lookup = lambda { |identifier|
AdminUser.find_by(email: identifier.downcase) ||
AdminUser.find_by(phone_number: identifier.gsub(/\D/, ''))
}

The lambda receives the raw identifier string from the login form (which accepts email or phone) and should return a User instance or nil.


Migration from Host-App Files

If your host app previously had its own copies of these files, you can remove them after enabling use_catalyst_otp:

Files to remove from the host app

  1. lib/devise/strategies/cm_admin_authenticatable.rb — The gem now provides this at lib/devise/strategies/cm_admin_authenticatable.rb. It's auto-registered when use_catalyst_otp = true.

  2. lib/devise/models/cm_admin_authenticatable.rb — The gem provides this at lib/devise/models/cm_admin_authenticatable.rb.

  3. Devise module registration in config/initializers/devise.rb — Remove the manual Devise.add_module(:cm_admin_authenticatable, ...) block. The gem's engine initializer handles this automatically when use_catalyst_otp = true.

  4. OTP methods from User model concern — If your User model had inline create_otp_request, verify_otp?, etc. (e.g. in an Auth::User concern), replace them with include OtpAuthenticatable.

  5. Session controller actions — If your Users::SessionsController had validate_email, validate_credentials, validate_otp, resend_otp, sign_in_with_credentials methods, replace them with include CmAdmin::OtpSessions. Keep only host-app-specific actions like reset_password_email, send_reset_password_email, update_password.

What stays in the host app

  • Users::SessionsController — slim controller that includes CmAdmin::OtpSessions and adds password reset actions
  • Users::PasswordsController — Devise password reset controller (the gem doesn't provide this)
  • Routes — the devise scope routes must remain in the host app's config/routes.rb
  • User model — must include OtpAuthenticatable and devise :cm_admin_authenticatable

How It Works

Devise Strategy

The CmAdminAuthenticatable strategy (lib/devise/strategies/cm_admin_authenticatable.rb) intercepts the validate_email action:

  1. Extracts the identifier from params (supports both identifier and legacy email param names)
  2. Calls CmAdmin.config.user_lookup to find the user
  3. Checks can_access_admin_panel? on the user
  4. On success:
    • For :otp — creates an OTP request (sends the code) and redirects to sign_in_with_credentials
    • For :password — redirects to sign_in_with_credentials (password entry page)
    • For :custom — calls super(user) to sign in directly
  5. On failure — returns a generic error message

OTP Model

OtpRequest (app/models/otp_request.rb) manages OTP lifecycle:

  • Statuses: created, verified, resent, time_out, cancelled
  • Expiry: 15 minutes (scoped via active and timed_out)
  • Rate limiting: Max 5 OTP requests per 30 seconds per user or email
  • Delivery: after_create callback calls CmAdmin.send_otp or CmAdmin.send_otp_email

Delivery Providers

  • CmAdmin::DeliveryProviders::Email — sends OTP via CmAdmin.send_email using the cm_admin/mailers/otp/otp_email partial
  • CmAdmin::DeliveryProviders::Gupshup — sends OTP via Gupshup WhatsApp API using configured templates

The CmAdmin.send_otp method routes to the appropriate provider(s) based on otp_delivery_channel config.


Views

The gem provides these view templates:

  • app/views/users/sessions/new.html.slim — login form (identifier input)
  • app/views/users/sessions/sign_in_with_credentials.html.slim — OTP/password entry
  • app/views/users/sessions/_otp_sign_in.html.slim — OTP input partial
  • app/views/users/sessions/_password_sign_in.html.slim — password input partial
  • app/views/cm_admin/mailers/otp/_otp_email.html.slim — OTP email template

Host apps can override any of these by creating files at the same paths in the host app's app/views/ directory.