feat: SAML authentication controllers [CW-2958] (#12319)

This commit is contained in:
Shivam Mishra
2025-09-10 20:02:27 +05:30
committed by GitHub
parent 257df30589
commit 79b93bed77
18 changed files with 653 additions and 27 deletions

View File

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

View File

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

View File

@@ -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')

View File

@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
}, status: status }, status: status
end end
end end
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View File

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

View File

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