## Linear:
- https://github.com/chatwoot/chatwoot/issues/486

## Description
This PR implements Multi-Factor Authentication (MFA) support for user
accounts, enhancing security by requiring a second form of verification
during login. The feature adds TOTP (Time-based One-Time Password)
authentication with QR code generation and backup codes for account
recovery.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Added comprehensive RSpec tests for MFA controller functionality
- Tested MFA setup flow with QR code generation
- Verified OTP validation and backup code generation
- Tested login flow with MFA enabled/disabled

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2025-09-18 16:49:24 +02:00
committed by GitHub
parent f03a52bd77
commit 239c4dcb91
33 changed files with 1345 additions and 37 deletions

View File

@@ -0,0 +1,68 @@
class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
def create
mfa_service.enable_two_factor!
end
def verify
@backup_codes = mfa_service.verify_and_activate!
end
def destroy
mfa_service.disable_two_factor!
end
def backup_codes
@backup_codes = mfa_service.generate_backup_codes!
end
private
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: current_user)
end
def check_mfa_enabled
render_could_not_create_error(I18n.t('errors.mfa.not_enabled')) unless current_user.mfa_enabled?
end
def check_mfa_feature_available
return if Chatwoot.mfa_enabled?
render json: {
error: I18n.t('errors.mfa.feature_unavailable')
}, status: :forbidden
end
def check_mfa_disabled
render_could_not_create_error(I18n.t('errors.mfa.already_enabled')) if current_user.mfa_enabled?
end
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
render_could_not_create_error(I18n.t('errors.mfa.invalid_code'))
end
def validate_password
return if current_user.valid_password?(mfa_params[:password])
render_could_not_create_error(I18n.t('errors.mfa.invalid_credentials'))
end
def mfa_params
params.permit(:otp_code, :password)
end
end

View File

@@ -9,13 +9,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def create
# Authenticate user via the temporary sso auth token
if params[:sso_auth_token].present? && @resource.present?
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
else
super
return handle_mfa_verification if mfa_verification_request?
return handle_sso_authentication if sso_authentication_request?
super do |resource|
return handle_mfa_required(resource) if resource&.mfa_enabled?
end
end
@@ -25,6 +23,20 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def mfa_verification_request?
params[:mfa_token].present?
end
def sso_authentication_request?
params[:sso_auth_token].present? && @resource.present?
end
def handle_sso_authentication
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
end
def login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
@@ -46,6 +58,41 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = User.from_email(params[:email])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(resource)
render json: {
mfa_required: true,
mfa_token: Mfa::TokenService.new(user: resource).generate_token
}, status: :partial_content
end
def handle_mfa_verification
user = Mfa::TokenService.new(token: params[:mfa_token]).verify_token
return render_mfa_error('errors.mfa.invalid_token', :unauthorized) unless user
authenticated = Mfa::AuthenticationService.new(
user: user,
otp_code: params[:otp_code],
backup_code: params[:backup_code]
).authenticate
return render_mfa_error('errors.mfa.invalid_code') unless authenticated
sign_in_mfa_user(user)
end
def sign_in_mfa_user(user)
@resource = user
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
render_create_success
end
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')

View File

@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class SuperAdmin < User
end

View File

@@ -7,6 +7,7 @@
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
@@ -17,6 +18,9 @@
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
@@ -33,10 +37,12 @@
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class User < ApplicationRecord
@@ -58,6 +64,7 @@ class User < ApplicationRecord
:validatable,
:confirmable,
:password_has_required_content,
:two_factor_authenticatable,
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
# TODO: remove in a future version once online status is moved to account users
@@ -70,6 +77,12 @@ class User < ApplicationRecord
validates :email, presence: true
serialize :otp_backup_codes, type: Array
# Encrypt sensitive MFA fields
encrypts :otp_secret, deterministic: true
encrypts :otp_backup_codes
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
@@ -156,6 +169,27 @@ class User < ApplicationRecord
find_by(email: email&.downcase)
end
# 2FA/MFA Methods
# Delegated to Mfa::ManagementService for better separation of concerns
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: self)
end
delegate :two_factor_provisioning_uri, to: :mfa_service
delegate :backup_codes_generated?, to: :mfa_service
delegate :enable_two_factor!, to: :mfa_service
delegate :disable_two_factor!, to: :mfa_service
delegate :generate_backup_codes!, to: :mfa_service
delegate :validate_backup_code!, to: :mfa_service
def mfa_enabled?
otp_required_for_login?
end
def mfa_feature_available?
Chatwoot.mfa_enabled?
end
private
def remove_macros

View File

@@ -0,0 +1,27 @@
class BaseTokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode(token_payload, secret_key, algorithm)
end
def decode_token
JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
rescue JWT::ExpiredSignature, JWT::DecodeError
{}
end
private
def token_payload
payload || {}
end
def secret_key
Rails.application.secret_key_base
end
def algorithm
'HS256'
end
end

View File

@@ -0,0 +1,23 @@
class Mfa::AuthenticationService
pattr_initialize [:user!, :otp_code, :backup_code]
def authenticate
return false unless user
return authenticate_with_otp if otp_code.present?
return authenticate_with_backup_code if backup_code.present?
false
end
private
def authenticate_with_otp
user.validate_and_consume_otp!(otp_code)
end
def authenticate_with_backup_code
mfa_service = Mfa::ManagementService.new(user: user)
mfa_service.validate_backup_code!(backup_code)
end
end

View File

@@ -0,0 +1,88 @@
class Mfa::ManagementService
pattr_initialize [:user!]
def enable_two_factor!
user.otp_secret = User.generate_otp_secret
user.save!
end
def disable_two_factor!
user.otp_secret = nil
user.otp_required_for_login = false
user.otp_backup_codes = nil
user.save!
end
def verify_and_activate!
ActiveRecord::Base.transaction do
user.update!(otp_required_for_login: true)
backup_codes_generated? ? nil : generate_backup_codes!
end
end
def two_factor_provisioning_uri
return nil if user.otp_secret.blank?
issuer = 'Chatwoot'
label = user.email
user.otp_provisioning_uri(label, issuer: issuer)
end
def generate_backup_codes!
codes = Array.new(10) { SecureRandom.hex(4).upcase }
user.otp_backup_codes = codes
user.save!
codes
end
def validate_backup_code!(code)
return false unless valid_backup_code_input?(code)
codes = user.otp_backup_codes
found_index = find_matching_code_index(codes, code)
return false if found_index.nil?
mark_code_as_used(codes, found_index)
end
private
def valid_backup_code_input?(code)
user.otp_backup_codes.present? && code.present?
end
def find_matching_code_index(codes, code)
found_index = nil
# Constant-time comparison to prevent timing attacks
codes.each_with_index do |stored_code, idx|
is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
is_unused = stored_code != 'XXXXXXXX'
found_index = idx if is_match && is_unused
end
found_index
end
def mark_code_as_used(codes, index)
codes[index] = 'XXXXXXXX'
user.otp_backup_codes = codes
user.save!
true
end
public
def backup_codes_generated?
user.otp_backup_codes.present?
end
def mfa_enabled?
user.otp_required_for_login?
end
def two_factor_setup_pending?
user.otp_secret.present? && !user.otp_required_for_login?
end
end

View File

@@ -0,0 +1,28 @@
class Mfa::TokenService < BaseTokenService
pattr_initialize [:user, :token]
MFA_TOKEN_EXPIRY = 5.minutes
def generate_token
@payload = build_payload
super
end
def verify_token
decoded = decode_token
return nil if decoded.blank?
User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound
nil
end
private
def build_payload
{
user_id: user.id,
exp: MFA_TOKEN_EXPIRY.from_now.to_i
}
end
end

View File

@@ -1,24 +1,14 @@
class Widget::TokenService
class Widget::TokenService < BaseTokenService
DEFAULT_EXPIRY_DAYS = 180
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload_with_expiry, secret_key, 'HS256'
end
def decode_token
JWT.decode(
token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
{}
JWT.encode(token_payload, secret_key, algorithm)
end
private
def payload_with_expiry
payload.merge(exp: exp, iat: iat)
def token_payload
(payload || {}).merge(exp: exp, iat: iat)
end
def iat
@@ -34,8 +24,4 @@ class Widget::TokenService
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
def secret_key
Rails.application.secret_key_base
end
end

View File

@@ -0,0 +1 @@
json.backup_codes @backup_codes

View File

@@ -0,0 +1,2 @@
json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
json.secret @user.otp_secret

View File

@@ -0,0 +1 @@
json.enabled @user.mfa_enabled?

View File

@@ -0,0 +1,3 @@
json.feature_available Chatwoot.mfa_enabled?
json.enabled @user.mfa_enabled?
json.backup_codes_generated @user.mfa_service.backup_codes_generated? if Chatwoot.mfa_enabled?

View File

@@ -0,0 +1,2 @@
json.enabled @user.mfa_enabled?
json.backup_codes @backup_codes if @backup_codes.present?