feat: SAML authentication controllers [CW-2958] (#12319)
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -81,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
@@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-saml'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
|
||||
10
Gemfile.lock
10
Gemfile.lock
@@ -589,8 +589,9 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.2)
|
||||
omniauth (2.1.3)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
@@ -604,6 +605,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
omniauth (~> 2.1)
|
||||
ruby-saml (~> 1.18)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
@@ -773,6 +777,9 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -1047,6 +1054,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-saml
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
|
||||
@@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
|
||||
# find the user with their email instead of UID and token
|
||||
@resource = resource_class.where(
|
||||
email: auth_hash['info']['email']
|
||||
).first
|
||||
email = auth_hash.dig('info', 'email')
|
||||
@resource = resource_class.from_email(email)
|
||||
end
|
||||
|
||||
def validate_signup_email_is_business_domain?
|
||||
@@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
'user'
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
|
||||
|
||||
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
}, status: status
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
|
||||
|
||||
@@ -58,7 +58,7 @@ class User < ApplicationRecord
|
||||
:validatable,
|
||||
:confirmable,
|
||||
:password_has_required_content,
|
||||
:omniauthable, omniauth_providers: [:google_oauth2]
|
||||
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
|
||||
|
||||
# TODO: remove in a future version once online status is moved to account users
|
||||
# remove the column availability from users
|
||||
|
||||
@@ -47,6 +47,10 @@ module Chatwoot
|
||||
# Add enterprise views to the view paths
|
||||
config.paths['app/views'].unshift('enterprise/app/views')
|
||||
|
||||
# Load enterprise initializers alongside standard initializers
|
||||
enterprise_initializers = Rails.root.join('enterprise/config/initializers')
|
||||
Dir[enterprise_initializers.join('**/*.rb')].each { |f| require f } if enterprise_initializers.exist?
|
||||
|
||||
# Settings in config/environments/* take precedence over those specified here.
|
||||
# Application configuration can go into files in config/initializers
|
||||
# -- all .rb files in that directory are automatically loaded after loading
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# OmniAuth configuration
|
||||
# Sets the full host URL for callbacks and proper redirect handling
|
||||
OmniAuth.config.full_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), {
|
||||
provider_ignores_state: true
|
||||
|
||||
@@ -39,6 +39,9 @@ en:
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
reset_password_saml_user: This account uses SAML authentication. Password reset is not available. Please contact your administrator.
|
||||
login_saml_user: This account uses SAML authentication. Please sign in through your organization's SAML provider.
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||
|
||||
errors:
|
||||
|
||||
101
enterprise/app/builders/saml_user_builder.rb
Normal file
101
enterprise/app/builders/saml_user_builder.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
class SamlUserBuilder
|
||||
def initialize(auth_hash, account_id)
|
||||
@auth_hash = auth_hash
|
||||
@account_id = account_id
|
||||
@saml_settings = AccountSamlSettings.find_by(account_id: account_id)
|
||||
end
|
||||
|
||||
def perform
|
||||
@user = find_or_create_user
|
||||
add_user_to_account if @user.persisted?
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_user
|
||||
user = User.from_email(auth_attribute('email'))
|
||||
|
||||
if user
|
||||
convert_existing_user_to_saml(user)
|
||||
return user
|
||||
end
|
||||
|
||||
create_user
|
||||
end
|
||||
|
||||
def convert_existing_user_to_saml(user)
|
||||
return if user.provider == 'saml'
|
||||
|
||||
user.update!(provider: 'saml')
|
||||
end
|
||||
|
||||
def create_user
|
||||
full_name = [auth_attribute('first_name'), auth_attribute('last_name')].compact.join(' ')
|
||||
fallback_name = auth_attribute('name') || auth_attribute('email').split('@').first
|
||||
|
||||
User.create(
|
||||
email: auth_attribute('email'),
|
||||
name: (full_name.presence || fallback_name),
|
||||
display_name: auth_attribute('first_name'),
|
||||
provider: 'saml',
|
||||
uid: uid,
|
||||
password: SecureRandom.hex(32),
|
||||
confirmed_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def add_user_to_account
|
||||
account = Account.find_by(id: @account_id)
|
||||
return unless account
|
||||
|
||||
# Create account_user if not exists
|
||||
account_user = AccountUser.find_or_create_by(
|
||||
user: @user,
|
||||
account: account
|
||||
)
|
||||
|
||||
# Set default role as agent if not set
|
||||
account_user.update(role: 'agent') if account_user.role.blank?
|
||||
|
||||
# Handle role mappings if configured
|
||||
apply_role_mappings(account_user, account)
|
||||
end
|
||||
|
||||
def apply_role_mappings(account_user, account)
|
||||
matching_mapping = find_matching_role_mapping(account)
|
||||
return unless matching_mapping
|
||||
|
||||
if matching_mapping['role']
|
||||
account_user.update(role: matching_mapping['role'])
|
||||
elsif matching_mapping['custom_role_id']
|
||||
account_user.update(custom_role_id: matching_mapping['custom_role_id'])
|
||||
end
|
||||
end
|
||||
|
||||
def find_matching_role_mapping(_account)
|
||||
return if @saml_settings&.role_mappings.blank?
|
||||
|
||||
saml_groups.each do |group|
|
||||
mapping = @saml_settings.role_mappings[group]
|
||||
return mapping if mapping.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def auth_attribute(key, fallback = nil)
|
||||
@auth_hash.dig('info', key) || fallback
|
||||
end
|
||||
|
||||
def uid
|
||||
@auth_hash['uid']
|
||||
end
|
||||
|
||||
def saml_groups
|
||||
# Groups can come from different attributes depending on IdP
|
||||
@auth_hash.dig('extra', 'raw_info', 'groups') ||
|
||||
@auth_hash.dig('extra', 'raw_info', 'Group') ||
|
||||
@auth_hash.dig('extra', 'raw_info', 'memberOf') ||
|
||||
[]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
def saml
|
||||
# Call parent's omniauth_success which handles the auth
|
||||
omniauth_success
|
||||
end
|
||||
|
||||
def redirect_callbacks
|
||||
# derive target redirect route from 'resource_class' param, which was set
|
||||
# before authentication.
|
||||
devise_mapping = get_devise_mapping
|
||||
redirect_route = get_redirect_route(devise_mapping)
|
||||
|
||||
# preserve omniauth info for success route. ignore 'extra' in twitter
|
||||
# auth response to avoid CookieOverflow.
|
||||
session['dta.omniauth.auth'] = request.env['omniauth.auth'].except('extra')
|
||||
session['dta.omniauth.params'] = request.env['omniauth.params']
|
||||
|
||||
# For SAML, use 303 See Other to convert POST to GET and preserve session
|
||||
if params[:provider] == 'saml'
|
||||
redirect_to redirect_route, { status: 303 }.merge(redirect_options)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def omniauth_success
|
||||
case auth_hash&.dig('provider')
|
||||
when 'saml'
|
||||
handle_saml_auth
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_saml_auth
|
||||
account_id = extract_saml_account_id
|
||||
return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
|
||||
|
||||
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||
|
||||
if @resource.persisted?
|
||||
sign_in_user
|
||||
else
|
||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||
end
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
|
||||
end
|
||||
|
||||
def saml_enabled_for_account?(account_id)
|
||||
return false if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
|
||||
return false if account.nil?
|
||||
return false unless account.feature_enabled?('saml')
|
||||
|
||||
AccountSamlSettings.find_by(account_id: account_id).present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::DeviseOverrides::PasswordsController
|
||||
include SamlAuthenticationHelper
|
||||
|
||||
def create
|
||||
if saml_user_attempting_password_auth?(params[:email])
|
||||
render json: {
|
||||
success: false,
|
||||
errors: [I18n.t('messages.reset_password_saml_user')]
|
||||
}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,18 @@
|
||||
module Enterprise::DeviseOverrides::SessionsController
|
||||
include SamlAuthenticationHelper
|
||||
|
||||
def create
|
||||
if saml_user_attempting_password_auth?(params[:email], sso_auth_token: params[:sso_auth_token])
|
||||
render json: {
|
||||
success: false,
|
||||
errors: [I18n.t('messages.login_saml_user')]
|
||||
}, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
create_audit_event('sign_in')
|
||||
super
|
||||
|
||||
12
enterprise/app/helpers/saml_authentication_helper.rb
Normal file
12
enterprise/app/helpers/saml_authentication_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module SamlAuthenticationHelper
|
||||
def saml_user_attempting_password_auth?(email, sso_auth_token: nil)
|
||||
return false if email.blank?
|
||||
|
||||
user = User.from_email(email)
|
||||
return false unless user&.provider == 'saml'
|
||||
|
||||
return false if sso_auth_token.present? && user.valid_sso_auth_token?(sso_auth_token)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
43
enterprise/config/initializers/omniauth_saml.rb
Normal file
43
enterprise/config/initializers/omniauth_saml.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# Enterprise Edition SAML SSO Provider
|
||||
# This initializer adds SAML authentication support for Enterprise customers
|
||||
|
||||
# SAML setup proc for multi-tenant configuration
|
||||
SAML_SETUP_PROC = proc do |env|
|
||||
request = ActionDispatch::Request.new(env)
|
||||
|
||||
# Extract account_id from various sources
|
||||
account_id = request.params['account_id'] ||
|
||||
request.session[:saml_account_id] ||
|
||||
env['omniauth.params']&.dig('account_id')
|
||||
|
||||
if account_id
|
||||
# Store in session and omniauth params for callback
|
||||
request.session[:saml_account_id] = account_id
|
||||
env['omniauth.params'] ||= {}
|
||||
env['omniauth.params']['account_id'] = account_id
|
||||
|
||||
# Find SAML settings for this account
|
||||
settings = AccountSamlSettings.find_by(account_id: account_id)
|
||||
|
||||
if settings
|
||||
# Configure the strategy options dynamically
|
||||
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
|
||||
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
|
||||
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
|
||||
env['omniauth.strategy'].options[:idp_sso_service_url] = settings.sso_url
|
||||
env['omniauth.strategy'].options[:idp_cert] = settings.certificate
|
||||
env['omniauth.strategy'].options[:name_identifier_format] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
|
||||
else
|
||||
# Set a dummy certificate to avoid the error
|
||||
env['omniauth.strategy'].options[:idp_cert] = 'DUMMY'
|
||||
end
|
||||
else
|
||||
# Set a dummy certificate to avoid the error
|
||||
env['omniauth.strategy'].options[:idp_cert] = 'DUMMY'
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
# SAML provider with setup phase for multi-tenant configuration
|
||||
provider :saml, setup: SAML_SETUP_PROC
|
||||
end
|
||||
214
spec/enterprise/builders/saml_user_builder_spec.rb
Normal file
214
spec/enterprise/builders/saml_user_builder_spec.rb
Normal file
@@ -0,0 +1,214 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SamlUserBuilder do
|
||||
let(:email) { 'saml.user@example.com' }
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User',
|
||||
'first_name' => 'SAML',
|
||||
'last_name' => 'User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'groups' => %w[Administrators Users]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
let(:account) { create(:account) }
|
||||
let(:builder) { described_class.new(auth_hash, account.id) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { builder.perform }.to change(User, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates user with correct attributes' do
|
||||
user = builder.perform
|
||||
|
||||
expect(user.email).to eq(email)
|
||||
expect(user.name).to eq('SAML User')
|
||||
expect(user.display_name).to eq('SAML')
|
||||
expect(user.provider).to eq('saml')
|
||||
expect(user.uid).to eq(email) # User model sets uid to email in before_validation callback
|
||||
expect(user.confirmed_at).to be_present
|
||||
end
|
||||
|
||||
it 'creates user with a random password' do
|
||||
user = builder.perform
|
||||
expect(user.encrypted_password).to be_present
|
||||
end
|
||||
|
||||
it 'adds user to the account' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
|
||||
it 'sets default role as agent' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('agent')
|
||||
end
|
||||
|
||||
context 'when name is not provided' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'derives name from email' do
|
||||
user = builder.perform
|
||||
expect(user.name).to eq('saml.user')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
end
|
||||
|
||||
it 'returns the existing user' do
|
||||
user = builder.perform
|
||||
expect(user).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'adds existing user to the account if not already added' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
|
||||
it 'converts existing user to SAML' do
|
||||
expect(existing_user.provider).not_to eq('saml')
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(existing_user.reload.provider).to eq('saml')
|
||||
end
|
||||
|
||||
it 'does not change provider if user is already SAML' do
|
||||
existing_user.update!(provider: 'saml')
|
||||
|
||||
expect { builder.perform }.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
|
||||
it 'does not duplicate account association' do
|
||||
existing_user.account_users.create!(account: account, role: 'agent')
|
||||
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with role mappings' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
role_mappings: {
|
||||
'Administrators' => { 'role' => 'administrator' },
|
||||
'Agents' => { 'role' => 'agent' }
|
||||
})
|
||||
end
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
it 'applies administrator role based on SAML groups' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('administrator')
|
||||
end
|
||||
|
||||
context 'with custom role mapping' do
|
||||
let!(:custom_role) { create(:custom_role, account: account) }
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
role_mappings: {
|
||||
'Administrators' => { 'custom_role_id' => custom_role.id }
|
||||
})
|
||||
end
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
it 'applies custom role based on SAML groups' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.custom_role_id).to eq(custom_role.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not in any mapped groups' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'groups' => ['UnmappedGroup']
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'keeps default agent role' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('agent')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different group attribute names' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'memberOf' => ['CN=Administrators,OU=Groups,DC=example,DC=com']
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'reads groups from memberOf attribute' do
|
||||
builder_instance = described_class.new(auth_hash, account_id: account.id)
|
||||
allow(builder_instance).to receive(:saml_groups).and_return(['CN=Administrators,OU=Groups,DC=example,DC=com'])
|
||||
user = builder_instance.perform
|
||||
expect(user).to be_persisted
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are errors' do
|
||||
it 'returns unsaved user object when user creation fails' do
|
||||
allow(User).to receive(:create).and_return(User.new(email: email))
|
||||
user = builder.perform
|
||||
expect(user.persisted?).to be false
|
||||
end
|
||||
|
||||
it 'does not create account association for failed user' do
|
||||
allow(User).to receive(:create).and_return(User.new(email: email))
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
def set_saml_config(email = 'test@example.com')
|
||||
OmniAuth.config.test_mode = true
|
||||
OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new(
|
||||
provider: 'saml',
|
||||
uid: '123545',
|
||||
info: {
|
||||
name: 'Test User',
|
||||
email: email
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
|
||||
account.enable_features!('saml')
|
||||
saml_settings
|
||||
end
|
||||
|
||||
describe '#saml callback' do
|
||||
it 'creates new user and logs them in' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
set_saml_config('new_user@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
# expect redirect to login with SSO token
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# verify user was created
|
||||
user = User.from_email('new_user@example.com')
|
||||
expect(user).to be_present
|
||||
expect(user.provider).to eq('saml')
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs in existing user' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'existing@example.com', account: account)
|
||||
set_saml_config('existing@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Passwords Controller', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
describe 'POST /auth/password' do
|
||||
context 'with SAML user email' do
|
||||
let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
|
||||
|
||||
it 'prevents password reset and returns forbidden with custom error message' do
|
||||
params = { email: saml_user.email, redirect_url: 'http://test.host' }
|
||||
|
||||
post user_password_path, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(false)
|
||||
expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-SAML user email' do
|
||||
let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) }
|
||||
|
||||
it 'allows password reset for non-SAML users' do
|
||||
params = { email: regular_user.email, redirect_url: 'http://test.host' }
|
||||
|
||||
post user_password_path, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['message']).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,32 +5,75 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
||||
|
||||
describe 'POST /sign_in' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, password: 'Password1!' }
|
||||
context 'with SAML user attempting password login' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
|
||||
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
before do
|
||||
saml_settings
|
||||
saml_user
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(user.email)
|
||||
it 'prevents login and returns SAML authentication error' do
|
||||
params = { email: saml_user.email, password: 'Password1!' }
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
post new_user_session_url, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(false)
|
||||
expect(json_response['errors']).to include(I18n.t('messages.login_saml_user'))
|
||||
end
|
||||
|
||||
it 'allows login with valid SSO token' do
|
||||
valid_token = saml_user.generate_sso_auth_token
|
||||
params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' }
|
||||
|
||||
expect do
|
||||
post new_user_session_url, params: params, as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(saml_user.email)
|
||||
end
|
||||
end
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
context 'with regular user credentials' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, password: 'Password1!' }
|
||||
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(user.email)
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank email' do
|
||||
it 'skips SAML check and processes normally' do
|
||||
params = { email: '', password: 'Password1!' }
|
||||
post new_user_session_url, params: params, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user