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
|
# authorization
|
||||||
gem 'jwt'
|
gem 'jwt'
|
||||||
gem 'pundit'
|
gem 'pundit'
|
||||||
|
|
||||||
# super admin
|
# super admin
|
||||||
gem 'administrate', '>= 0.20.1'
|
gem 'administrate', '>= 0.20.1'
|
||||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||||
@@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
|||||||
|
|
||||||
# need for google auth
|
# need for google auth
|
||||||
gem 'omniauth', '>= 2.1.2'
|
gem 'omniauth', '>= 2.1.2'
|
||||||
|
gem 'omniauth-saml'
|
||||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
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)
|
oj (3.16.10)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
ostruct (>= 0.2)
|
ostruct (>= 0.2)
|
||||||
omniauth (2.1.2)
|
omniauth (2.1.3)
|
||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
|
logger
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-google-oauth2 (1.1.3)
|
omniauth-google-oauth2 (1.1.3)
|
||||||
@@ -604,6 +605,9 @@ GEM
|
|||||||
omniauth-rails_csrf_protection (1.0.2)
|
omniauth-rails_csrf_protection (1.0.2)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
|
omniauth-saml (2.2.4)
|
||||||
|
omniauth (~> 2.1)
|
||||||
|
ruby-saml (~> 1.18)
|
||||||
opensearch-ruby (3.4.0)
|
opensearch-ruby (3.4.0)
|
||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
@@ -773,6 +777,9 @@ GEM
|
|||||||
faraday (>= 1)
|
faraday (>= 1)
|
||||||
faraday-multipart (>= 1)
|
faraday-multipart (>= 1)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby-saml (1.18.1)
|
||||||
|
nokogiri (>= 1.13.10)
|
||||||
|
rexml
|
||||||
ruby-vips (2.1.4)
|
ruby-vips (2.1.4)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
@@ -1047,6 +1054,7 @@ DEPENDENCIES
|
|||||||
omniauth-google-oauth2 (>= 1.1.3)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
|
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
|
||||||
# find the user with their email instead of UID and token
|
email = auth_hash.dig('info', 'email')
|
||||||
@resource = resource_class.where(
|
@resource = resource_class.from_email(email)
|
||||||
email: auth_hash['info']['email']
|
|
||||||
).first
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_signup_email_is_business_domain?
|
def validate_signup_email_is_business_domain?
|
||||||
@@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
'user'
|
'user'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
|||||||
}, status: status
|
}, status: status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class User < ApplicationRecord
|
|||||||
:validatable,
|
:validatable,
|
||||||
:confirmable,
|
:confirmable,
|
||||||
:password_has_required_content,
|
: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
|
# TODO: remove in a future version once online status is moved to account users
|
||||||
# remove the column availability from users
|
# remove the column availability from users
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ module Chatwoot
|
|||||||
# Add enterprise views to the view paths
|
# Add enterprise views to the view paths
|
||||||
config.paths['app/views'].unshift('enterprise/app/views')
|
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.
|
# Settings in config/environments/* take precedence over those specified here.
|
||||||
# Application configuration can go into files in config/initializers
|
# Application configuration can go into files in config/initializers
|
||||||
# -- all .rb files in that directory are automatically loaded after loading
|
# -- 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
|
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 :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), {
|
||||||
provider_ignores_state: true
|
provider_ignores_state: true
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ en:
|
|||||||
messages:
|
messages:
|
||||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
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_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.
|
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||||
|
|
||||||
errors:
|
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
|
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
|
def render_create_success
|
||||||
create_audit_event('sign_in')
|
create_audit_event('sign_in')
|
||||||
super
|
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,6 +5,40 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
|||||||
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
||||||
|
|
||||||
describe 'POST /sign_in' do
|
describe 'POST /sign_in' do
|
||||||
|
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) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
saml_settings
|
||||||
|
saml_user
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents login and returns SAML authentication error' do
|
||||||
|
params = { email: saml_user.email, password: 'Password1!' }
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
context 'with regular user credentials' do
|
||||||
it 'creates a sign_in audit event wwith valid credentials' do
|
it 'creates a sign_in audit event wwith valid credentials' do
|
||||||
params = { email: user.email, password: 'Password1!' }
|
params = { email: user.email, password: 'Password1!' }
|
||||||
|
|
||||||
@@ -34,6 +68,15 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
|||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
describe 'DELETE /sign_out' do
|
describe 'DELETE /sign_out' do
|
||||||
context 'when it is an authenticated user' do
|
context 'when it is an authenticated user' do
|
||||||
it 'signs out the user and creates an audit event' do
|
it 'signs out the user and creates an audit event' do
|
||||||
|
|||||||
Reference in New Issue
Block a user