diff --git a/.env.example b/.env.example index 2ab2933dc..de671599c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml new file mode 100644 index 000000000..61b406f8a --- /dev/null +++ b/.github/workflows/run_mfa_spec.yml @@ -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/ diff --git a/Gemfile b/Gemfile index 927a853a0..265c609c1 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 2cce9f322..f9e253e6c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb new file mode 100644 index 000000000..dd874f222 --- /dev/null +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -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 diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index fc7b12767..bf3a7f221 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -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') diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb index f41610ee4..316d60c7b 100644 --- a/app/models/super_admin.rb +++ b/app/models/super_admin.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index e0440a67c..4923d0a35 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/base_token_service.rb b/app/services/base_token_service.rb new file mode 100644 index 000000000..966404108 --- /dev/null +++ b/app/services/base_token_service.rb @@ -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 diff --git a/app/services/mfa/authentication_service.rb b/app/services/mfa/authentication_service.rb new file mode 100644 index 000000000..caad66cf7 --- /dev/null +++ b/app/services/mfa/authentication_service.rb @@ -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 diff --git a/app/services/mfa/management_service.rb b/app/services/mfa/management_service.rb new file mode 100644 index 000000000..d4c01ec1f --- /dev/null +++ b/app/services/mfa/management_service.rb @@ -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 diff --git a/app/services/mfa/token_service.rb b/app/services/mfa/token_service.rb new file mode 100644 index 000000000..a7994b60c --- /dev/null +++ b/app/services/mfa/token_service.rb @@ -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 diff --git a/app/services/widget/token_service.rb b/app/services/widget/token_service.rb index 4eebaab9e..b50119eb1 100644 --- a/app/services/widget/token_service.rb +++ b/app/services/widget/token_service.rb @@ -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 diff --git a/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder b/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder new file mode 100644 index 000000000..2aafdd3cd --- /dev/null +++ b/app/views/api/v1/profile/mfa/backup_codes.json.jbuilder @@ -0,0 +1 @@ +json.backup_codes @backup_codes diff --git a/app/views/api/v1/profile/mfa/create.json.jbuilder b/app/views/api/v1/profile/mfa/create.json.jbuilder new file mode 100644 index 000000000..52072ccfb --- /dev/null +++ b/app/views/api/v1/profile/mfa/create.json.jbuilder @@ -0,0 +1,2 @@ +json.provisioning_url @user.mfa_service.two_factor_provisioning_uri +json.secret @user.otp_secret diff --git a/app/views/api/v1/profile/mfa/destroy.json.jbuilder b/app/views/api/v1/profile/mfa/destroy.json.jbuilder new file mode 100644 index 000000000..a5bf2ea84 --- /dev/null +++ b/app/views/api/v1/profile/mfa/destroy.json.jbuilder @@ -0,0 +1 @@ +json.enabled @user.mfa_enabled? diff --git a/app/views/api/v1/profile/mfa/show.json.jbuilder b/app/views/api/v1/profile/mfa/show.json.jbuilder new file mode 100644 index 000000000..4568f48f9 --- /dev/null +++ b/app/views/api/v1/profile/mfa/show.json.jbuilder @@ -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? diff --git a/app/views/api/v1/profile/mfa/verify.json.jbuilder b/app/views/api/v1/profile/mfa/verify.json.jbuilder new file mode 100644 index 000000000..54be3fc35 --- /dev/null +++ b/app/views/api/v1/profile/mfa/verify.json.jbuilder @@ -0,0 +1,2 @@ +json.enabled @user.mfa_enabled? +json.backup_codes @backup_codes if @backup_codes.present? diff --git a/config/application.rb b/config/application.rb index 7fd1b94ba..d644dd28f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 4c9ecc032..18855c95f 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -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' diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index fe3f6c554..fe40974f2 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -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? diff --git a/config/locales/en.yml b/config/locales/en.yml index d5345a411..0d869c75f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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: diff --git a/config/routes.rb b/config/routes.rb index 5fc602e0b..244bd67bf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/db/migrate/20250820130619_add_two_factor_to_users.rb b/db/migrate/20250820130619_add_two_factor_to_users.rb new file mode 100644 index 000000000..3178aae05 --- /dev/null +++ b/db/migrate/20250820130619_add_two_factor_to_users.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 36a532e86..00b6f9109 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb new file mode 100644 index 000000000..31f308c8e --- /dev/null +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -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 diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index 6b0130c28..4ba1a7021 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 845ec0625..213988a0d 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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) } diff --git a/spec/requests/api/v1/profile/mfa_controller_spec.rb b/spec/requests/api/v1/profile/mfa_controller_spec.rb new file mode 100644 index 000000000..97a2e206f --- /dev/null +++ b/spec/requests/api/v1/profile/mfa_controller_spec.rb @@ -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 diff --git a/spec/services/base_token_service_spec.rb b/spec/services/base_token_service_spec.rb new file mode 100644 index 000000000..1b34aedf2 --- /dev/null +++ b/spec/services/base_token_service_spec.rb @@ -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 diff --git a/spec/services/mfa/authentication_service_spec.rb b/spec/services/mfa/authentication_service_spec.rb new file mode 100644 index 000000000..c4cc5ef5e --- /dev/null +++ b/spec/services/mfa/authentication_service_spec.rb @@ -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 diff --git a/spec/services/mfa/token_service_spec.rb b/spec/services/mfa/token_service_spec.rb new file mode 100644 index 000000000..7d4fe55b6 --- /dev/null +++ b/spec/services/mfa/token_service_spec.rb @@ -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 diff --git a/spec/services/widget/token_service_spec.rb b/spec/services/widget/token_service_spec.rb new file mode 100644 index 000000000..724728b51 --- /dev/null +++ b/spec/services/widget/token_service_spec.rb @@ -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