mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 10:42:38 +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