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:
| Method | Flow |
|---|---|
:otp | User enters email or phone → receives OTP → enters OTP to sign in |
:password | User enters email or phone → enters password to sign in |
:custom | Host 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 anOtpRequest, and delivers itverify_otp?(otp)— validates the OTP against the latest active requestverify_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
OtpSessionsconcern 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
-
lib/devise/strategies/cm_admin_authenticatable.rb— The gem now provides this atlib/devise/strategies/cm_admin_authenticatable.rb. It's auto-registered whenuse_catalyst_otp = true. -
lib/devise/models/cm_admin_authenticatable.rb— The gem provides this atlib/devise/models/cm_admin_authenticatable.rb. -
Devise module registration in
config/initializers/devise.rb— Remove the manualDevise.add_module(:cm_admin_authenticatable, ...)block. The gem's engine initializer handles this automatically whenuse_catalyst_otp = true. -
OTP methods from User model concern — If your User model had inline
create_otp_request,verify_otp?, etc. (e.g. in anAuth::Userconcern), replace them withinclude OtpAuthenticatable. -
Session controller actions — If your
Users::SessionsControllerhadvalidate_email,validate_credentials,validate_otp,resend_otp,sign_in_with_credentialsmethods, replace them withinclude CmAdmin::OtpSessions. Keep only host-app-specific actions likereset_password_email,send_reset_password_email,update_password.
What stays in the host app
Users::SessionsController— slim controller that includesCmAdmin::OtpSessionsand adds password reset actionsUsers::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 Usermodel — must includeOtpAuthenticatableanddevise :cm_admin_authenticatable
How It Works
Devise Strategy
The CmAdminAuthenticatable strategy (lib/devise/strategies/cm_admin_authenticatable.rb) intercepts the validate_email action:
- Extracts the
identifierfrom params (supports bothidentifierand legacyemailparam names) - Calls
CmAdmin.config.user_lookupto find the user - Checks
can_access_admin_panel?on the user - On success:
- For
:otp— creates an OTP request (sends the code) and redirects tosign_in_with_credentials - For
:password— redirects tosign_in_with_credentials(password entry page) - For
:custom— callssuper(user)to sign in directly
- For
- 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
activeandtimed_out) - Rate limiting: Max 5 OTP requests per 30 seconds per user or email
- Delivery:
after_createcallback callsCmAdmin.send_otporCmAdmin.send_otp_email
Delivery Providers
CmAdmin::DeliveryProviders::Email— sends OTP viaCmAdmin.send_emailusing thecm_admin/mailers/otp/otp_emailpartialCmAdmin::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 entryapp/views/users/sessions/_otp_sign_in.html.slim— OTP input partialapp/views/users/sessions/_password_sign_in.html.slim— password input partialapp/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.