mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-28 17:52:39 +00:00
feat: MFA (#12290)
## 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:
committed by
GitHub
parent
f03a52bd77
commit
239c4dcb91
@@ -6,6 +6,13 @@
|
||||
# Use `rake secret` to generate this variable
|
||||
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
|
||||
|
||||
# Active Record Encryption keys (required for MFA/2FA functionality)
|
||||
# Generate these keys by running: rails db:encryption:init
|
||||
# IMPORTANT: Use different keys for each environment (development, staging, production)
|
||||
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||
|
||||
# Replace with the URL you are planning to use for your app
|
||||
FRONTEND_URL=http://0.0.0.0:3000
|
||||
# To use a dedicated URL for help center pages
|
||||
|
||||
99
.github/workflows/run_mfa_spec.yml
vendored
Normal file
99
.github/workflows/run_mfa_spec.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Run MFA Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run if MFA test keys are available
|
||||
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_HOST: localhost
|
||||
# Active Record encryption keys required for MFA - test keys only, not for production use
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Install pgvector extension
|
||||
run: |
|
||||
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run MFA-related backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/services/mfa/token_service_spec.rb \
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Run MFA-related tests in user_spec
|
||||
run: |
|
||||
# Run specific MFA-related tests from user_spec
|
||||
bundle exec rspec spec/models/user_spec.rb \
|
||||
-e "two factor" \
|
||||
-e "2FA" \
|
||||
-e "MFA" \
|
||||
-e "otp" \
|
||||
-e "backup code" \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: mfa-test-logs
|
||||
path: |
|
||||
log/test.log
|
||||
tmp/screenshots/
|
||||
2
Gemfile
2
Gemfile
@@ -78,6 +78,8 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
@@ -212,6 +212,11 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.1.0)
|
||||
activesupport (>= 7.0, < 8.1)
|
||||
devise (~> 4.0)
|
||||
railties (>= 7.0, < 8.1)
|
||||
rotp (~> 6.0)
|
||||
devise_token_auth (1.2.5)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
@@ -723,6 +728,7 @@ GEM
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
@@ -1005,6 +1011,7 @@ DEPENDENCIES
|
||||
debug (~> 1.8)
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
devise-two-factor (>= 5.0.0)
|
||||
devise_token_auth (>= 1.2.3)
|
||||
dotenv-rails (>= 3.0.0)
|
||||
down
|
||||
|
||||
68
app/controllers/api/v1/profile/mfa_controller.rb
Normal file
68
app/controllers/api/v1/profile/mfa_controller.rb
Normal 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
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
27
app/services/base_token_service.rb
Normal file
27
app/services/base_token_service.rb
Normal 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
|
||||
23
app/services/mfa/authentication_service.rb
Normal file
23
app/services/mfa/authentication_service.rb
Normal 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
|
||||
88
app/services/mfa/management_service.rb
Normal file
88
app/services/mfa/management_service.rb
Normal 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
|
||||
28
app/services/mfa/token_service.rb
Normal file
28
app/services/mfa/token_service.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
1
app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
Normal file
1
app/views/api/v1/profile/mfa/backup_codes.json.jbuilder
Normal file
@@ -0,0 +1 @@
|
||||
json.backup_codes @backup_codes
|
||||
2
app/views/api/v1/profile/mfa/create.json.jbuilder
Normal file
2
app/views/api/v1/profile/mfa/create.json.jbuilder
Normal file
@@ -0,0 +1,2 @@
|
||||
json.provisioning_url @user.mfa_service.two_factor_provisioning_uri
|
||||
json.secret @user.otp_secret
|
||||
1
app/views/api/v1/profile/mfa/destroy.json.jbuilder
Normal file
1
app/views/api/v1/profile/mfa/destroy.json.jbuilder
Normal file
@@ -0,0 +1 @@
|
||||
json.enabled @user.mfa_enabled?
|
||||
3
app/views/api/v1/profile/mfa/show.json.jbuilder
Normal file
3
app/views/api/v1/profile/mfa/show.json.jbuilder
Normal 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?
|
||||
2
app/views/api/v1/profile/mfa/verify.json.jbuilder
Normal file
2
app/views/api/v1/profile/mfa/verify.json.jbuilder
Normal file
@@ -0,0 +1,2 @@
|
||||
json.enabled @user.mfa_enabled?
|
||||
json.backup_codes @backup_codes if @backup_codes.present?
|
||||
@@ -68,6 +68,16 @@ module Chatwoot
|
||||
|
||||
# Disable PDF/video preview generation as we don't use them
|
||||
config.active_storage.previewers = []
|
||||
|
||||
# Active Record Encryption configuration
|
||||
# Required for MFA/2FA features - skip if not using encryption
|
||||
if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
|
||||
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
|
||||
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
|
||||
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
|
||||
config.active_record.encryption.support_unencrypted_data = true
|
||||
config.active_record.encryption.store_key_references = true
|
||||
end
|
||||
end
|
||||
|
||||
def self.config
|
||||
@@ -82,4 +92,16 @@ module Chatwoot
|
||||
# ref: https://www.rubydoc.info/stdlib/openssl/OpenSSL/SSL/SSLContext#DEFAULT_PARAMS-constant
|
||||
ENV['REDIS_OPENSSL_VERIFY_MODE'] == 'none' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
||||
end
|
||||
|
||||
def self.encryption_configured?
|
||||
# Check if proper encryption keys are configured
|
||||
# MFA/2FA features should only be enabled when proper keys are set
|
||||
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
|
||||
ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY'].present? &&
|
||||
ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT'].present?
|
||||
end
|
||||
|
||||
def self.mfa_enabled?
|
||||
encryption_configured?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
# Configure sensitive parameters which will be filtered from the log file.
|
||||
Rails.application.config.filter_parameters += [
|
||||
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn
|
||||
:password, :secret, :_key, :auth, :crypt, :salt, :certificate, :otp, :access, :private, :protected, :ssn,
|
||||
:otp_secret, :otp_code, :backup_code, :mfa_token, :otp_backup_codes
|
||||
]
|
||||
|
||||
# Regex to filter all occurrences of 'token' in keys except for 'website_token'
|
||||
|
||||
@@ -83,12 +83,17 @@ class Rack::Attack
|
||||
end
|
||||
|
||||
# ### Prevent Brute-Force Login Attacks ###
|
||||
# Exclude MFA verification attempts from regular login throttling
|
||||
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# Skip if this is an MFA verification request
|
||||
req.ip
|
||||
end
|
||||
end
|
||||
|
||||
throttle('login/email', limit: 10, period: 15.minutes) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
# Skip if this is an MFA verification request
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
|
||||
# ref: https://github.com/rack/rack-attack/issues/399
|
||||
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
|
||||
# Hence placed in the if block
|
||||
@@ -114,6 +119,28 @@ class Rack::Attack
|
||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
## MFA throttling - prevent brute force attacks
|
||||
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/api/v1/profile/mfa'
|
||||
req.ip if req.delete? # Throttle disable attempts
|
||||
elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
|
||||
req.ip if req.post? # Throttle verify and backup_codes attempts
|
||||
end
|
||||
end
|
||||
|
||||
# Separate rate limiting for MFA verification attempts
|
||||
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
|
||||
req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
|
||||
end
|
||||
|
||||
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
|
||||
if req.path_without_extentions == '/auth/sign_in' && req.post?
|
||||
# Track by MFA token to prevent brute force on a specific token
|
||||
mfa_token = req.params['mfa_token'].presence
|
||||
(mfa_token.presence)
|
||||
end
|
||||
end
|
||||
|
||||
## Prevent Brute-Force Signup Attacks ###
|
||||
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
|
||||
|
||||
@@ -103,6 +103,18 @@ en:
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
custom_attribute_definition:
|
||||
key_conflict: The provided key is not allowed as it might conflict with default attributes.
|
||||
mfa:
|
||||
already_enabled: MFA is already enabled
|
||||
not_enabled: MFA is not enabled
|
||||
invalid_code: Invalid verification code
|
||||
invalid_backup_code: Invalid backup code
|
||||
invalid_token: Invalid or expired MFA token
|
||||
invalid_credentials: Invalid credentials or verification code
|
||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
||||
profile:
|
||||
mfa:
|
||||
enabled: MFA enabled successfully
|
||||
disabled: MFA disabled successfully
|
||||
account_saml_settings:
|
||||
invalid_certificate: must be a valid X.509 certificate in PEM format
|
||||
reports:
|
||||
|
||||
@@ -334,6 +334,14 @@ Rails.application.routes.draw do
|
||||
post :resend_confirmation
|
||||
post :reset_access_token
|
||||
end
|
||||
|
||||
# MFA routes
|
||||
scope module: 'profile' do
|
||||
resource :mfa, controller: 'mfa', only: [:show, :create, :destroy] do
|
||||
post :verify
|
||||
post :backup_codes
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resource :notification_subscriptions, only: [:create, :destroy]
|
||||
|
||||
11
db/migrate/20250820130619_add_two_factor_to_users.rb
Normal file
11
db/migrate/20250820130619_add_two_factor_to_users.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class AddTwoFactorToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :otp_secret, :string
|
||||
add_column :users, :consumed_timestep, :integer
|
||||
add_column :users, :otp_required_for_login, :boolean, default: false, null: false
|
||||
add_column :users, :otp_backup_codes, :text
|
||||
|
||||
add_index :users, :otp_secret, unique: true
|
||||
add_index :users, :otp_required_for_login
|
||||
end
|
||||
end
|
||||
@@ -1175,7 +1175,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_16_024703) do
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.string "type"
|
||||
t.text "message_signature"
|
||||
t.string "otp_secret"
|
||||
t.integer "consumed_timestep"
|
||||
t.boolean "otp_required_for_login", default: false
|
||||
t.text "otp_backup_codes"
|
||||
t.index ["email"], name: "index_users_on_email"
|
||||
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
|
||||
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true
|
||||
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true
|
||||
|
||||
146
spec/controllers/devise_overrides/sessions_controller_spec.rb
Normal file
146
spec/controllers/devise_overrides/sessions_controller_spec.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
include Devise::Test::ControllerHelpers
|
||||
|
||||
before do
|
||||
request.env['devise.mapping'] = Devise.mappings[:user]
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:user) { create(:user, password: 'Test@123456') }
|
||||
|
||||
context 'with standard authentication' do
|
||||
it 'authenticates with valid credentials' do
|
||||
post :create, params: { email: user.email, password: 'Test@123456' }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'rejects invalid credentials' do
|
||||
post :create, params: { email: user.email, password: 'wrong' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with MFA authentication' do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
it 'requires MFA verification after successful password authentication' do
|
||||
post :create, params: { email: user.email, password: 'Test@123456' }
|
||||
|
||||
expect(response).to have_http_status(:partial_content)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['mfa_required']).to be(true)
|
||||
expect(json_response['mfa_token']).to be_present
|
||||
end
|
||||
|
||||
context 'when verifying MFA' do
|
||||
let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
|
||||
|
||||
it 'authenticates with valid OTP' do
|
||||
post :create, params: {
|
||||
mfa_token: mfa_token,
|
||||
otp_code: user.current_otp
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'authenticates with valid backup code' do
|
||||
backup_codes = user.generate_backup_codes!
|
||||
|
||||
post :create, params: {
|
||||
mfa_token: mfa_token,
|
||||
backup_code: backup_codes.first
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'rejects invalid OTP' do
|
||||
post :create, params: {
|
||||
mfa_token: mfa_token,
|
||||
otp_code: '000000'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
|
||||
it 'rejects invalid backup code' do
|
||||
user.generate_backup_codes!
|
||||
|
||||
post :create, params: {
|
||||
mfa_token: mfa_token,
|
||||
backup_code: 'invalid'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
|
||||
it 'rejects expired MFA token' do
|
||||
expired_token = JWT.encode(
|
||||
{ user_id: user.id, exp: 1.minute.ago.to_i },
|
||||
Rails.application.secret_key_base,
|
||||
'HS256'
|
||||
)
|
||||
|
||||
post :create, params: {
|
||||
mfa_token: expired_token,
|
||||
otp_code: user.current_otp
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_token'))
|
||||
end
|
||||
|
||||
it 'requires either OTP or backup code' do
|
||||
post :create, params: { mfa_token: mfa_token }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with SSO authentication' do
|
||||
it 'authenticates with valid SSO token' do
|
||||
sso_token = user.generate_sso_auth_token
|
||||
|
||||
post :create, params: {
|
||||
email: user.email,
|
||||
sso_auth_token: sso_token
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'rejects invalid SSO token' do
|
||||
post :create, params: {
|
||||
email: user.email,
|
||||
sso_auth_token: 'invalid'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #new' do
|
||||
it 'redirects to frontend login page' do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('/frontend')
|
||||
|
||||
get :new
|
||||
|
||||
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Inbox do
|
||||
end
|
||||
|
||||
it 'returns all member ids when inbox max_assignment_limit is not configured' do
|
||||
expect(inbox.member_ids_with_assignment_capacity).to eq(inbox.members.ids)
|
||||
expect(inbox.member_ids_with_assignment_capacity).to match_array(inbox.members.ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -111,6 +111,113 @@ RSpec.describe User do
|
||||
end
|
||||
end
|
||||
|
||||
describe '2FA/MFA functionality' do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
let(:user) { create(:user, password: 'Test@123456') }
|
||||
|
||||
describe '#enable_two_factor!' do
|
||||
it 'generates OTP secret for 2FA setup' do
|
||||
expect(user.otp_secret).to be_nil
|
||||
expect(user.otp_required_for_login).to be_falsey
|
||||
|
||||
user.enable_two_factor!
|
||||
|
||||
expect(user.otp_secret).not_to be_nil
|
||||
# otp_required_for_login is false until verification is complete
|
||||
expect(user.otp_required_for_login).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_two_factor!' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true) # Simulate verified 2FA
|
||||
user.generate_backup_codes!
|
||||
end
|
||||
|
||||
it 'disables 2FA and clears OTP secret' do
|
||||
user.disable_two_factor!
|
||||
|
||||
expect(user.otp_secret).to be_nil
|
||||
expect(user.otp_required_for_login).to be_falsey
|
||||
expect(user.otp_backup_codes).to be_blank # Can be nil or empty array
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_backup_codes!' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
end
|
||||
|
||||
it 'generates 10 backup codes' do
|
||||
codes = user.generate_backup_codes!
|
||||
|
||||
expect(codes).to be_an(Array)
|
||||
expect(codes.length).to eq(10)
|
||||
expect(codes.first).to match(/\A[A-F0-9]{8}\z/) # 8-character hex codes
|
||||
expect(user.otp_backup_codes).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#two_factor_provisioning_uri' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
end
|
||||
|
||||
it 'generates a valid provisioning URI for QR code' do
|
||||
uri = user.two_factor_provisioning_uri
|
||||
|
||||
expect(uri).to include('otpauth://totp/')
|
||||
expect(uri).to include(CGI.escape(user.email))
|
||||
expect(uri).to include('Chatwoot')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate_backup_code!' do
|
||||
let(:backup_codes) { user.generate_backup_codes! }
|
||||
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
backup_codes
|
||||
end
|
||||
|
||||
it 'validates and invalidates correct backup code' do
|
||||
code = backup_codes.first
|
||||
result = user.validate_backup_code!(code)
|
||||
expect(result).to be_truthy
|
||||
|
||||
# Verify it's marked as used
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).to include('XXXXXXXX')
|
||||
end
|
||||
|
||||
it 'rejects invalid backup code' do
|
||||
result = user.validate_backup_code!('invalid')
|
||||
expect(result).to be_falsey
|
||||
end
|
||||
|
||||
it 'rejects already used backup code' do
|
||||
code = backup_codes.first
|
||||
user.validate_backup_code!(code)
|
||||
|
||||
# Try to use the same code again
|
||||
result = user.validate_backup_code!(code)
|
||||
expect(result).to be_falsey
|
||||
end
|
||||
|
||||
it 'handles blank code' do
|
||||
result = user.validate_backup_code!(nil)
|
||||
expect(result).to be_falsey
|
||||
|
||||
result = user.validate_backup_code!('')
|
||||
expect(result).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active_account_user' do
|
||||
let(:user) { create(:user) }
|
||||
let(:account1) { create(:account) }
|
||||
|
||||
274
spec/requests/api/v1/profile/mfa_controller_spec.rb
Normal file
274
spec/requests/api/v1/profile/mfa_controller_spec.rb
Normal file
@@ -0,0 +1,274 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'MFA API', type: :request do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
allow(Chatwoot).to receive(:mfa_enabled?).and_return(true)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, password: 'Test@123456') }
|
||||
|
||||
describe 'GET /api/v1/profile/mfa' do
|
||||
context 'when 2FA is disabled' do
|
||||
it 'returns MFA disabled status' do
|
||||
get '/api/v1/profile/mfa',
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['enabled']).to be_falsey
|
||||
expect(json_response['backup_codes_generated']).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when 2FA is enabled' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
it 'returns MFA enabled status' do
|
||||
get '/api/v1/profile/mfa',
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['enabled']).to be_truthy
|
||||
end
|
||||
|
||||
context 'with backup codes generated' do
|
||||
before do
|
||||
user.generate_backup_codes!
|
||||
end
|
||||
|
||||
it 'indicates backup codes are generated' do
|
||||
get '/api/v1/profile/mfa',
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['backup_codes_generated']).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/profile/mfa' do
|
||||
context 'when 2FA is not enabled' do
|
||||
it 'enables 2FA and returns QR code URL' do
|
||||
post '/api/v1/profile/mfa',
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['provisioning_url']).not_to be_nil
|
||||
expect(json_response['provisioning_url']).to include('otpauth://totp')
|
||||
expect(json_response['secret']).not_to be_nil
|
||||
|
||||
user.reload
|
||||
expect(user.otp_secret).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when 2FA is already enabled' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
post '/api/v1/profile/mfa',
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/profile/mfa/verify' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
end
|
||||
|
||||
context 'with valid OTP code' do
|
||||
it 'verifies and confirms 2FA setup with backup codes' do
|
||||
otp_code = user.current_otp
|
||||
|
||||
post '/api/v1/profile/mfa/verify',
|
||||
params: { otp_code: otp_code },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['enabled']).to be_truthy
|
||||
expect(json_response['backup_codes']).to be_an(Array)
|
||||
expect(json_response['backup_codes'].length).to eq(10)
|
||||
|
||||
user.reload
|
||||
expect(user.otp_required_for_login).to be_truthy
|
||||
expect(user.otp_backup_codes).not_to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid OTP code' do
|
||||
it 'returns error message' do
|
||||
post '/api/v1/profile/mfa/verify',
|
||||
params: { otp_code: '000000' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when 2FA is already verified' do
|
||||
before do
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
it 'returns already enabled error' do
|
||||
post '/api/v1/profile/mfa/verify',
|
||||
params: { otp_code: user.current_otp },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.already_enabled'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/profile/mfa' do
|
||||
context 'when 2FA is enabled' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
user.generate_backup_codes!
|
||||
end
|
||||
|
||||
context 'with valid password and OTP' do
|
||||
it 'disables 2FA successfully' do
|
||||
otp_code = user.current_otp
|
||||
|
||||
delete '/api/v1/profile/mfa',
|
||||
params: { password: 'Test@123456', otp_code: otp_code },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['enabled']).to be_falsey
|
||||
|
||||
user.reload
|
||||
expect(user.otp_required_for_login).to be_falsey
|
||||
expect(user.otp_secret).to be_nil
|
||||
expect(user.otp_backup_codes).to be_blank
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid password' do
|
||||
it 'returns error message' do
|
||||
otp_code = user.current_otp
|
||||
|
||||
delete '/api/v1/profile/mfa',
|
||||
params: { password: 'wrong_password', otp_code: otp_code },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Invalid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid OTP' do
|
||||
it 'returns error message' do
|
||||
delete '/api/v1/profile/mfa',
|
||||
params: { password: 'Test@123456', otp_code: '000000' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Invalid')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when 2FA is not enabled' do
|
||||
it 'returns not enabled error' do
|
||||
delete '/api/v1/profile/mfa',
|
||||
params: { password: 'Test@123456', otp_code: '123456' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/profile/mfa/backup_codes' do
|
||||
context 'when 2FA is enabled' do
|
||||
before do
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
context 'with valid OTP' do
|
||||
it 'generates new backup codes' do
|
||||
otp_code = user.current_otp
|
||||
|
||||
post '/api/v1/profile/mfa/backup_codes',
|
||||
params: { otp_code: otp_code },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['backup_codes']).to be_an(Array)
|
||||
expect(json_response['backup_codes'].length).to eq(10)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid OTP' do
|
||||
it 'returns error message' do
|
||||
post '/api/v1/profile/mfa/backup_codes',
|
||||
params: { otp_code: '000000' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.invalid_code'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when 2FA is not enabled' do
|
||||
it 'returns not enabled error' do
|
||||
post '/api/v1/profile/mfa/backup_codes',
|
||||
params: { otp_code: '123456' },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq(I18n.t('errors.mfa.not_enabled'))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
42
spec/services/base_token_service_spec.rb
Normal file
42
spec/services/base_token_service_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe BaseTokenService do
|
||||
let(:payload) { { user_id: 1, exp: 5.minutes.from_now.to_i } }
|
||||
let(:token_service) { described_class.new(payload: payload) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with the provided payload' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'encodes the payload correctly' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#decode_token' do
|
||||
let(:token) { token_service.generate_token }
|
||||
let(:decoder_service) { described_class.new(token: token) }
|
||||
|
||||
it 'decodes a valid JWT token' do
|
||||
decoded = decoder_service.decode_token
|
||||
expect(decoded[:user_id]).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty hash for invalid token' do
|
||||
invalid_service = described_class.new(token: 'invalid_token')
|
||||
expect(invalid_service.decode_token).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for expired token' do
|
||||
expired_payload = { user_id: 1, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
expired_service = described_class.new(token: expired_token)
|
||||
expect(expired_service.decode_token).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
106
spec/services/mfa/authentication_service_spec.rb
Normal file
106
spec/services/mfa/authentication_service_spec.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::AuthenticationService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#authenticate' do
|
||||
context 'with OTP code' do
|
||||
context 'when OTP is valid' do
|
||||
it 'returns true' do
|
||||
valid_otp = user.current_otp
|
||||
service = described_class.new(user: user, otp_code: valid_otp)
|
||||
expect(service.authenticate).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: '000000')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: nil)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with backup code' do
|
||||
let(:backup_codes) { user.generate_backup_codes! }
|
||||
|
||||
context 'when backup code is valid' do
|
||||
it 'returns true and invalidates the code' do
|
||||
valid_code = backup_codes.first
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
|
||||
# Code should be invalidated after use
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, backup_code: 'invalid')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code has already been used' do
|
||||
it 'returns false' do
|
||||
valid_code = backup_codes.first
|
||||
# Use the code once
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
service.authenticate
|
||||
|
||||
# Try to use it again
|
||||
service2 = described_class.new(user: user.reload, backup_code: valid_code)
|
||||
expect(service2.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with neither OTP nor backup code' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: nil, otp_code: '123456')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both OTP and backup code are provided' do
|
||||
it 'uses OTP authentication first' do
|
||||
valid_otp = user.current_otp
|
||||
backup_codes = user.generate_backup_codes!
|
||||
|
||||
service = described_class.new(
|
||||
user: user,
|
||||
otp_code: valid_otp,
|
||||
backup_code: backup_codes.first
|
||||
)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
# Backup code should not be consumed
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).not_to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
72
spec/services/mfa/token_service_spec.rb
Normal file
72
spec/services/mfa/token_service_spec.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::TokenService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:token_service) { described_class.new(user: user) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with user_id' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'includes user_id in the payload' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'sets expiration to 5 minutes from now' do
|
||||
allow(Time).to receive(:now).and_return(Time.zone.parse('2024-01-01 12:00:00'))
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expected_exp = Time.zone.parse('2024-01-01 12:05:00').to_i
|
||||
expect(decoded['exp']).to eq(expected_exp)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#verify_token' do
|
||||
let(:valid_token) { token_service.generate_token }
|
||||
|
||||
context 'with valid token' do
|
||||
it 'returns the user' do
|
||||
verifier = described_class.new(token: valid_token)
|
||||
verified_user = verifier.verify_token
|
||||
expect(verified_user).to eq(user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid token' do
|
||||
it 'returns nil for malformed token' do
|
||||
verifier = described_class.new(token: 'invalid_token')
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for expired token' do
|
||||
expired_payload = { user_id: user.id, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: expired_token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for non-existent user' do
|
||||
payload = { user_id: 999_999, exp: 5.minutes.from_now.to_i }
|
||||
token = JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank token' do
|
||||
it 'returns nil' do
|
||||
verifier = described_class.new(token: nil)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
spec/services/widget/token_service_spec.rb
Normal file
43
spec/services/widget/token_service_spec.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Widget::TokenService do
|
||||
let(:payload) { { source_id: 'contact_123', inbox_id: 1 } }
|
||||
let(:token_service) { described_class.new(payload: payload) }
|
||||
|
||||
describe 'inheritance' do
|
||||
it 'inherits from BaseTokenService' do
|
||||
expect(described_class.superclass).to eq(BaseTokenService)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with the provided payload' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'encodes the payload correctly' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['source_id']).to eq('contact_123')
|
||||
expect(decoded['inbox_id']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#decode_token' do
|
||||
let(:token) { token_service.generate_token }
|
||||
let(:decoder_service) { described_class.new(token: token) }
|
||||
|
||||
it 'decodes a valid JWT token' do
|
||||
decoded = decoder_service.decode_token
|
||||
expect(decoded[:source_id]).to eq('contact_123')
|
||||
expect(decoded[:inbox_id]).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty hash for invalid token' do
|
||||
invalid_service = described_class.new(token: 'invalid_token')
|
||||
expect(invalid_service.decode_token).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user