## 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,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

View File

@@ -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

View File

@@ -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) }

View 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

View 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

View 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

View 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

View 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