mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	 239c4dcb91
			
		
	
	239c4dcb91
	
	
	
		
			
			## 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>
		
			
				
	
	
		
			258 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			258 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| require 'rails_helper'
 | |
| require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
 | |
| require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
 | |
| 
 | |
| RSpec.describe User do
 | |
|   let!(:user) { create(:user) }
 | |
| 
 | |
|   context 'with validations' do
 | |
|     it { is_expected.to validate_presence_of(:email) }
 | |
|   end
 | |
| 
 | |
|   context 'with associations' do
 | |
|     it { is_expected.to have_many(:accounts).through(:account_users) }
 | |
|     it { is_expected.to have_many(:account_users) }
 | |
|     it { is_expected.to have_many(:assigned_conversations).class_name('Conversation').dependent(:nullify) }
 | |
|     it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) }
 | |
|     it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) }
 | |
|     it { is_expected.to have_many(:messages) }
 | |
|     it { is_expected.to have_many(:reporting_events) }
 | |
|     it { is_expected.to have_many(:teams) }
 | |
|   end
 | |
| 
 | |
|   describe 'concerns' do
 | |
|     it_behaves_like 'access_tokenable'
 | |
|     it_behaves_like 'avatarable'
 | |
|   end
 | |
| 
 | |
|   describe 'pubsub_token' do
 | |
|     before { user.update(name: Faker::Name.name) }
 | |
| 
 | |
|     it { expect(user.pubsub_token).not_to be_nil }
 | |
|     it { expect(user.saved_changes.keys).not_to eq('pubsub_token') }
 | |
| 
 | |
|     context 'with rotate the pubsub_token' do
 | |
|       it 'changes the pubsub_token when password changes' do
 | |
|         pubsub_token = user.pubsub_token
 | |
|         user.password = Faker::Internet.password(special_characters: true)
 | |
|         user.save!
 | |
|         expect(user.pubsub_token).not_to eq(pubsub_token)
 | |
|       end
 | |
| 
 | |
|       it 'will not change pubsub_token when other attributes change' do
 | |
|         pubsub_token = user.pubsub_token
 | |
|         user.name = Faker::Name.name
 | |
|         user.save!
 | |
|         expect(user.pubsub_token).to eq(pubsub_token)
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe 'hmac_identifier' do
 | |
|     it 'return nil if CHATWOOT_INBOX_HMAC_KEY is not set' do
 | |
|       expect(user.hmac_identifier).to eq('')
 | |
|     end
 | |
| 
 | |
|     it 'return value if CHATWOOT_INBOX_HMAC_KEY is set' do
 | |
|       ConfigLoader.new.process
 | |
|       i = InstallationConfig.find_by(name: 'CHATWOOT_INBOX_HMAC_KEY')
 | |
|       i.value = 'random_secret_key'
 | |
|       i.save!
 | |
|       GlobalConfig.clear_cache
 | |
| 
 | |
|       expected_hmac_identifier = OpenSSL::HMAC.hexdigest('sha256', 'random_secret_key', user.email)
 | |
| 
 | |
|       expect(user.hmac_identifier).to eq expected_hmac_identifier
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   context 'with sso_auth_token' do
 | |
|     it 'can generate multiple sso tokens which can be validated' do
 | |
|       sso_auth_token1 = user.generate_sso_auth_token
 | |
|       sso_auth_token2 = user.generate_sso_auth_token
 | |
|       expect(sso_auth_token1).present?
 | |
|       expect(sso_auth_token2).present?
 | |
|       expect(user.valid_sso_auth_token?(sso_auth_token1)).to be true
 | |
|       expect(user.valid_sso_auth_token?(sso_auth_token2)).to be true
 | |
|     end
 | |
| 
 | |
|     it 'wont validate an invalid token' do
 | |
|       expect(user.valid_sso_auth_token?(SecureRandom.hex(32))).to be false
 | |
|     end
 | |
| 
 | |
|     it 'wont validate an invalidated token' do
 | |
|       sso_auth_token = user.generate_sso_auth_token
 | |
|       user.invalidate_sso_auth_token(sso_auth_token)
 | |
|       expect(user.valid_sso_auth_token?(sso_auth_token)).to be false
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   describe 'access token' do
 | |
|     it 'creates a single access token upon user creation' do
 | |
|       new_user = create(:user)
 | |
|       token_count = AccessToken.where(owner: new_user).count
 | |
|       expect(token_count).to eq(1)
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   context 'when user changes the email' do
 | |
|     it 'mutates the value' do
 | |
|       user.email = 'user@example.com'
 | |
|       expect(user.will_save_change_to_email?).to be true
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   context 'when the supplied email is uppercase' do
 | |
|     it 'downcases the email on save' do
 | |
|       new_user = create(:user, email: 'Test123@test.com')
 | |
|       expect(new_user.email).to eq('test123@test.com')
 | |
|     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) }
 | |
|     let(:account2) { create(:account) }
 | |
|     let(:account3) { create(:account) }
 | |
| 
 | |
|     before do
 | |
|       # Create account_users with different active_at values
 | |
|       create(:account_user, user: user, account: account1, active_at: 2.days.ago)
 | |
|       create(:account_user, user: user, account: account2, active_at: 1.day.ago)
 | |
|       create(:account_user, user: user, account: account3, active_at: nil) # New account with NULL active_at
 | |
|     end
 | |
| 
 | |
|     it 'returns the account_user with the most recent active_at, prioritizing timestamps over NULL values' do
 | |
|       # Should return account2 (most recent timestamp) even though account3 was created last with NULL active_at
 | |
|       expect(user.active_account_user.account_id).to eq(account2.id)
 | |
|     end
 | |
| 
 | |
|     it 'returns NULL active_at account only when no other accounts have active_at' do
 | |
|       # Remove active_at from all accounts
 | |
|       user.account_users.each { |au| au.update!(active_at: nil) }
 | |
| 
 | |
|       # Should return one of the accounts (behavior is undefined but consistent)
 | |
|       expect(user.active_account_user).to be_present
 | |
|     end
 | |
| 
 | |
|     context 'when multiple accounts have NULL active_at' do
 | |
|       before do
 | |
|         create(:account_user, user: user, account: create(:account), active_at: nil)
 | |
|       end
 | |
| 
 | |
|       it 'still prioritizes accounts with timestamps' do
 | |
|         expect(user.active_account_user.account_id).to eq(account2.id)
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| end
 |