mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: allow SP initiated SAML (#12447)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
		| @@ -22,6 +22,20 @@ | ||||
|     }, | ||||
|     "FORGOT_PASSWORD": "Forgot your password?", | ||||
|     "CREATE_NEW_ACCOUNT": "Create a new account", | ||||
|     "SUBMIT": "Login" | ||||
|     "SUBMIT": "Login", | ||||
|     "SAML": { | ||||
|       "LABEL": "Log in via SSO", | ||||
|       "TITLE": "Initiate Single Sign-on (SSO)", | ||||
|       "SUBTITLE": "Enter your work email to access your organization", | ||||
|       "BACK_TO_LOGIN": "Login via Password", | ||||
|       "WORK_EMAIL": { | ||||
|         "LABEL": "Work Email", | ||||
|         "PLACEHOLDER": "Enter your work email" | ||||
|       }, | ||||
|       "SUBMIT": "Continue with SSO", | ||||
|       "API": { | ||||
|         "ERROR_MESSAGE": "SSO authentication failed" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import { parseBoolean } from '@chatwoot/utils'; | ||||
|  | ||||
| const { | ||||
|   API_CHANNEL_NAME: apiChannelName, | ||||
|   API_CHANNEL_THUMBNAIL: apiChannelThumbnail, | ||||
| @@ -15,6 +17,7 @@ const { | ||||
|   LOGO: logo, | ||||
|   LOGO_DARK: logoDark, | ||||
|   PRIVACY_URL: privacyURL, | ||||
|   IS_ENTERPRISE: isEnterprise, | ||||
|   TERMS_URL: termsURL, | ||||
|   WIDGET_BRAND_URL: widgetBrandURL, | ||||
|   DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate, | ||||
| @@ -30,8 +33,8 @@ const state = { | ||||
|   chatwootInboxToken, | ||||
|   deploymentEnv, | ||||
|   createNewAccountFromDashboard, | ||||
|   directUploadsEnabled: directUploadsEnabled === 'true', | ||||
|   disableUserProfileUpdate: disableUserProfileUpdate === 'true', | ||||
|   directUploadsEnabled: parseBoolean(directUploadsEnabled), | ||||
|   disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate), | ||||
|   displayManifest, | ||||
|   gitSha, | ||||
|   hCaptchaSiteKey, | ||||
| @@ -42,6 +45,7 @@ const state = { | ||||
|   privacyURL, | ||||
|   termsURL, | ||||
|   widgetBrandURL, | ||||
|   isEnterprise: parseBoolean(isEnterprise), | ||||
| }; | ||||
|  | ||||
| export const getters = { | ||||
|   | ||||
| @@ -55,6 +55,7 @@ const model = defineModel({ | ||||
|     <input | ||||
|       v-bind="$attrs" | ||||
|       v-model="model" | ||||
|       :name="name" | ||||
|       :type="type" | ||||
|       class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3" | ||||
|       :class="{ | ||||
|   | ||||
| @@ -41,7 +41,14 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => { | ||||
|     to.meta && | ||||
|     to.meta.requireSignupEnabled; | ||||
|  | ||||
|   if (!to.name || isAnInalidSignupNavigation) { | ||||
|   // Disable navigation to SAML login if enterprise is not enabled | ||||
|   // SAML route has an attribute (requireEnterprise) in it's definition | ||||
|   const isEnterpriseOnlyPath = | ||||
|     chatwootConfig.isEnterprise !== 'true' && | ||||
|     to.meta && | ||||
|     to.meta.requireEnterprise; | ||||
|  | ||||
|   if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) { | ||||
|     next(frontendURL('login')); | ||||
|     return; | ||||
|   } | ||||
|   | ||||
| @@ -85,6 +85,9 @@ export default { | ||||
|     showSignupLink() { | ||||
|       return parseBoolean(window.chatwootConfig.signupEnabled); | ||||
|     }, | ||||
|     showSamlLogin() { | ||||
|       return this.globalConfig.isEnterprise; | ||||
|     }, | ||||
|   }, | ||||
|   created() { | ||||
|     if (this.ssoAuthToken) { | ||||
| @@ -302,5 +305,13 @@ export default { | ||||
|         <Spinner color-scheme="primary" size="" /> | ||||
|       </div> | ||||
|     </section> | ||||
|     <div v-if="showSamlLogin" class="mt-6 text-center"> | ||||
|       <router-link | ||||
|         to="/app/login/sso" | ||||
|         class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark" | ||||
|       > | ||||
|         {{ $t('LOGIN.SAML.LABEL') }} | ||||
|       </router-link> | ||||
|     </div> | ||||
|   </main> | ||||
| </template> | ||||
|   | ||||
							
								
								
									
										102
									
								
								app/javascript/v3/views/login/Saml.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								app/javascript/v3/views/login/Saml.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | ||||
| <script setup> | ||||
| import { ref, computed, onMounted } from 'vue'; | ||||
| import { useStore } from 'vuex'; | ||||
| import { required, email } from '@vuelidate/validators'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| // components | ||||
| import FormInput from '../../components/Form/Input.vue'; | ||||
| import NextButton from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| const store = useStore(); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const credentials = ref({ | ||||
|   email: '', | ||||
| }); | ||||
|  | ||||
| const loginApi = ref({ | ||||
|   showLoading: false, | ||||
|   hasErrored: false, | ||||
| }); | ||||
|  | ||||
| const validations = { | ||||
|   credentials: { | ||||
|     email: { | ||||
|       required, | ||||
|       email, | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const v$ = useVuelidate(validations, { credentials }); | ||||
|  | ||||
| const globalConfig = computed(() => store.getters['globalConfig/get']); | ||||
| const csrfToken = ref(''); | ||||
|  | ||||
| onMounted(() => { | ||||
|   csrfToken.value = | ||||
|     document | ||||
|       .querySelector('meta[name="csrf-token"]') | ||||
|       ?.getAttribute('content') || ''; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <main | ||||
|     class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8" | ||||
|   > | ||||
|     <section class="max-w-5xl mx-auto"> | ||||
|       <img | ||||
|         :src="globalConfig.logo" | ||||
|         :alt="globalConfig.installationName" | ||||
|         class="block w-auto h-8 mx-auto dark:hidden" | ||||
|       /> | ||||
|       <img | ||||
|         v-if="globalConfig.logoDark" | ||||
|         :src="globalConfig.logoDark" | ||||
|         :alt="globalConfig.installationName" | ||||
|         class="hidden w-auto h-8 mx-auto dark:block" | ||||
|       /> | ||||
|       <h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12"> | ||||
|         {{ t('LOGIN.SAML.TITLE') }} | ||||
|       </h2> | ||||
|     </section> | ||||
|     <section | ||||
|       class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg" | ||||
|       :class="{ | ||||
|         'animate-wiggle': loginApi.hasErrored, | ||||
|       }" | ||||
|     > | ||||
|       <form class="space-y-5" method="POST" action="/api/v1/auth/saml_login"> | ||||
|         <input type="hidden" name="authenticity_token" :value="csrfToken" I /> | ||||
|         <FormInput | ||||
|           v-model="credentials.email" | ||||
|           name="email" | ||||
|           type="text" | ||||
|           :tabindex="1" | ||||
|           required | ||||
|           :label="t('LOGIN.SAML.WORK_EMAIL.LABEL')" | ||||
|           :placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')" | ||||
|           :has-error="v$.credentials.email.$error" | ||||
|           @input="v$.credentials.email.$touch" | ||||
|         /> | ||||
|         <NextButton | ||||
|           lg | ||||
|           type="submit" | ||||
|           class="w-full" | ||||
|           :tabindex="2" | ||||
|           :label="t('LOGIN.SAML.SUBMIT')" | ||||
|           :disabled="loginApi.showLoading" | ||||
|           :is-loading="loginApi.showLoading" | ||||
|         /> | ||||
|       </form> | ||||
|     </section> | ||||
|     <p class="mt-6 text-sm text-center text-n-slate-11"> | ||||
|       <router-link to="/app/login" class="text-link text-n-brand"> | ||||
|         {{ t('LOGIN.SAML.BACK_TO_LOGIN') }} | ||||
|       </router-link> | ||||
|     </p> | ||||
|   </main> | ||||
| </template> | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { frontendURL } from 'dashboard/helper/URLHelper'; | ||||
|  | ||||
| import Login from './login/Index.vue'; | ||||
| import SamlLogin from './login/Saml.vue'; | ||||
| import Signup from './auth/signup/Index.vue'; | ||||
| import ResetPassword from './auth/reset/password/Index.vue'; | ||||
| import Confirmation from './auth/confirmation/Index.vue'; | ||||
| @@ -20,6 +21,12 @@ export default [ | ||||
|       authError: route.query.error, | ||||
|     }), | ||||
|   }, | ||||
|   { | ||||
|     path: frontendURL('login/sso'), | ||||
|     name: 'sso_login', | ||||
|     component: SamlLogin, | ||||
|     meta: { requireEnterprise: true }, | ||||
|   }, | ||||
|   { | ||||
|     path: frontendURL('auth/signup'), | ||||
|     name: 'auth_signup', | ||||
|   | ||||
| @@ -36,6 +36,10 @@ en: | ||||
|       success: 'Channel reauthorized successfully' | ||||
|       not_required: 'Reauthorization is not required for this inbox' | ||||
|       invalid_channel: 'Invalid channel type for reauthorization' | ||||
|   auth: | ||||
|     saml: | ||||
|       invalid_email: 'Please enter a valid email address' | ||||
|       authentication_failed: 'Authentication failed. Please check your credentials and try again.' | ||||
|   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. | ||||
|   | ||||
| @@ -325,6 +325,9 @@ Rails.application.routes.draw do | ||||
|         resources :webhooks, only: [:create] | ||||
|       end | ||||
|  | ||||
|       # Frontend API endpoint to trigger SAML authentication flow | ||||
|       post 'auth/saml_login', to: 'auth#saml_login' | ||||
|  | ||||
|       resource :profile, only: [:show, :update] do | ||||
|         delete :avatar, on: :collection | ||||
|         member do | ||||
|   | ||||
							
								
								
									
										49
									
								
								enterprise/app/controllers/api/v1/auth_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								enterprise/app/controllers/api/v1/auth_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| class Api::V1::AuthController < Api::BaseController | ||||
|   skip_before_action :authenticate_user!, only: [:saml_login] | ||||
|   before_action :find_user_and_account, only: [:saml_login] | ||||
|  | ||||
|   def saml_login | ||||
|     return if @account.nil? | ||||
|  | ||||
|     saml_initiation_url = "/auth/saml?account_id=#{@account.id}" | ||||
|     redirect_to saml_initiation_url, status: :temporary_redirect | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def find_user_and_account | ||||
|     return unless validate_email_presence | ||||
|  | ||||
|     find_saml_enabled_account | ||||
|   end | ||||
|  | ||||
|   def validate_email_presence | ||||
|     @email = params[:email]&.downcase&.strip | ||||
|     return true if @email.present? | ||||
|  | ||||
|     render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request | ||||
|     false | ||||
|   end | ||||
|  | ||||
|   def find_saml_enabled_account | ||||
|     user = User.from_email(@email) | ||||
|     return render_saml_error unless user | ||||
|  | ||||
|     account_user = find_account_with_saml(user) | ||||
|     return render_saml_error unless account_user | ||||
|  | ||||
|     @account = account_user.account | ||||
|   end | ||||
|  | ||||
|   def find_account_with_saml(user) | ||||
|     user.account_users | ||||
|         .joins(account: :saml_settings) | ||||
|         .where.not(saml_settings: { sso_url: [nil, ''] }) | ||||
|         .where.not(saml_settings: { certificate: [nil, ''] }) | ||||
|         .find { |account_user| account_user.account.feature_enabled?('saml') } | ||||
|   end | ||||
|  | ||||
|   def render_saml_error | ||||
|     render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized | ||||
|   end | ||||
| end | ||||
							
								
								
									
										131
									
								
								spec/enterprise/controllers/api/v1/auth_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								spec/enterprise/controllers/api/v1/auth_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe 'Api::V1::Auth', type: :request do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:user) { create(:user, email: 'user@example.com') } | ||||
|  | ||||
|   before do | ||||
|     account.enable_features('saml') | ||||
|     account.save! | ||||
|   end | ||||
|  | ||||
|   def json_response | ||||
|     JSON.parse(response.body, symbolize_names: true) | ||||
|   end | ||||
|  | ||||
|   describe 'POST /api/v1/auth/saml_login' do | ||||
|     context 'when email is blank' do | ||||
|       it 'returns bad request' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: '' } | ||||
|  | ||||
|         expect(response).to have_http_status(:bad_request) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when email is nil' do | ||||
|       it 'returns bad request' do | ||||
|         post '/api/v1/auth/saml_login', params: {} | ||||
|  | ||||
|         expect(response).to have_http_status(:bad_request) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when user does not exist' do | ||||
|       it 'returns unauthorized with generic message' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' } | ||||
|  | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when user exists but has no SAML enabled accounts' do | ||||
|       before do | ||||
|         create(:account_user, user: user, account: account) | ||||
|       end | ||||
|  | ||||
|       it 'returns unauthorized' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||
|  | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when user has account without SAML feature enabled' do | ||||
|       let(:saml_settings) { create(:account_saml_settings, account: account) } | ||||
|  | ||||
|       before do | ||||
|         saml_settings | ||||
|         create(:account_user, user: user, account: account) | ||||
|         account.disable_features('saml') | ||||
|         account.save! | ||||
|       end | ||||
|  | ||||
|       it 'returns unauthorized' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||
|  | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when user has valid SAML configuration' do | ||||
|       let(:saml_settings) do | ||||
|         create(:account_saml_settings, account: account) | ||||
|       end | ||||
|  | ||||
|       before do | ||||
|         saml_settings | ||||
|         create(:account_user, user: user, account: account) | ||||
|       end | ||||
|  | ||||
|       it 'redirects to SAML initiation URL' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||
|  | ||||
|         expect(response).to have_http_status(:temporary_redirect) | ||||
|         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||
|       end | ||||
|  | ||||
|       it 'handles email case insensitivity' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: user.email.upcase } | ||||
|  | ||||
|         expect(response).to have_http_status(:temporary_redirect) | ||||
|         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||
|       end | ||||
|  | ||||
|       it 'strips whitespace from email' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: "  #{user.email}  " } | ||||
|  | ||||
|         expect(response).to have_http_status(:temporary_redirect) | ||||
|         expect(response.location).to include("/auth/saml?account_id=#{account.id}") | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when user has multiple accounts with SAML' do | ||||
|       let(:account2) { create(:account) } | ||||
|       let(:saml_settings1) do | ||||
|         create(:account_saml_settings, account: account) | ||||
|       end | ||||
|       let(:saml_settings2) do | ||||
|         create(:account_saml_settings, account: account2) | ||||
|       end | ||||
|  | ||||
|       before do | ||||
|         account2.enable_features('saml') | ||||
|         account2.save! | ||||
|         saml_settings1 | ||||
|         saml_settings2 | ||||
|         create(:account_user, user: user, account: account) | ||||
|         create(:account_user, user: user, account: account2) | ||||
|       end | ||||
|  | ||||
|       it 'redirects to the first SAML enabled account' do | ||||
|         post '/api/v1/auth/saml_login', params: { email: user.email } | ||||
|  | ||||
|         expect(response).to have_http_status(:temporary_redirect) | ||||
|         returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i | ||||
|         expect([account.id, account2.id]).to include(returned_account_id) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra