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

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

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