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