Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d8144709 | ||
|
|
63b7b91aad | ||
|
|
690b956f1c | ||
|
|
88b1dfb89d | ||
|
|
cc875bf277 | ||
|
|
9c04202871 | ||
|
|
40830f2707 | ||
|
|
cc83fdcc8e | ||
|
|
af1f7cc1d2 | ||
|
|
9ded118059 | ||
|
|
1a78b68625 | ||
|
|
43d2b37d64 | ||
|
|
5aaf331851 | ||
|
|
da61c9855b | ||
|
|
334aa09005 | ||
|
|
fea2a695f1 | ||
|
|
80018c58b9 | ||
|
|
c31200b9e5 | ||
|
|
86fa54dd13 | ||
|
|
7a9840717e | ||
|
|
f1f2b3f302 | ||
|
|
efbf837861 | ||
|
|
3a3f5c9469 | ||
|
|
e0a5a63f2e | ||
|
|
4f6facb6ac | ||
|
|
2634050f1a | ||
|
|
0f2b278dcd | ||
|
|
b15f408e6a | ||
|
|
92a04e4ab4 | ||
|
|
cedacac9eb | ||
|
|
85150dd418 | ||
|
|
56e7166ec7 | ||
|
|
1030bf69e1 | ||
|
|
4b12f13926 | ||
|
|
ab64f53e67 | ||
|
|
0d54ebf85b | ||
|
|
8859017642 | ||
|
|
315d471468 | ||
|
|
2b08ac4a02 | ||
|
|
d8f306ef5c | ||
|
|
66abb424e4 | ||
|
|
ba94ba3bf4 | ||
|
|
8b93241ed6 | ||
|
|
7cd0863487 | ||
|
|
468413158f | ||
|
|
a5cb283f42 | ||
|
|
cd75ac262a | ||
|
|
9081da755d | ||
|
|
2156598dde | ||
|
|
ac51a2b3d9 | ||
|
|
3d9cb97bbb | ||
|
|
5ba79036ea | ||
|
|
edd78ff4bc | ||
|
|
f6b3cb6295 | ||
|
|
5227b4169f | ||
|
|
61eb729a11 | ||
|
|
1f41e53ef5 | ||
|
|
73992bef97 | ||
|
|
2dbd43217c | ||
|
|
b81683a4de | ||
|
|
71321c2548 | ||
|
|
b22425d5fb | ||
|
|
9b8d6bfc74 | ||
|
|
d48a7aac64 | ||
|
|
eca7704bda | ||
|
|
cb887f0dd9 | ||
|
|
3c0f053983 | ||
|
|
35b09fd8b8 | ||
|
|
cbe5936b7b | ||
|
|
184b735846 | ||
|
|
594ca1f526 | ||
|
|
7c78fd3087 | ||
|
|
f818f462ea | ||
|
|
f17de99d1b | ||
|
|
94bbfff8fd | ||
|
|
abf07c44c2 | ||
|
|
0c9e4f430a | ||
|
|
846ea71e56 | ||
|
|
85fb20013e | ||
|
|
04cc25d55e | ||
|
|
b999fe6439 | ||
|
|
589daac302 | ||
|
|
87171c5b53 | ||
|
|
40f476315b | ||
|
|
2b533aa135 | ||
|
|
3fb2506aab | ||
|
|
9598021f87 | ||
|
|
d7265f21ad | ||
|
|
9f19c41b35 | ||
|
|
995f56f0ad | ||
|
|
34bc1dc4bb | ||
|
|
44e997e3bf | ||
|
|
73e6bc8ecd | ||
|
|
ea8446ecfe | ||
|
|
314ab83040 | ||
|
|
90a38d1065 | ||
|
|
d5b7068394 | ||
|
|
379ef876eb | ||
|
|
5fb0c80891 | ||
|
|
5419b949a4 | ||
|
|
f6fe1b130b | ||
|
|
ecc1857f63 | ||
|
|
5f0feae688 | ||
|
|
1dd8f73c90 | ||
|
|
f2eb8c37af | ||
|
|
9b09ae0e14 | ||
|
|
091586177c | ||
|
|
cac97c543b | ||
|
|
03dc1b6352 | ||
|
|
42ca3d97e6 | ||
|
|
2b1ded8f4d | ||
|
|
2eadb61b8e | ||
|
|
88115441b9 | ||
|
|
e17694da73 | ||
|
|
e8b0b24b36 | ||
|
|
4fcee3bbba | ||
|
|
f9d4816db1 | ||
|
|
6274f0f803 | ||
|
|
572df07365 | ||
|
|
5079e0b3b9 | ||
|
|
92df56f738 | ||
|
|
7a33065efe | ||
|
|
99ab1eadb5 | ||
|
|
7549a2a1d4 | ||
|
|
0562647ab1 | ||
|
|
f5c45c35f2 | ||
|
|
d06e2cfd98 | ||
|
|
e9d852969f | ||
|
|
71285b368c | ||
|
|
f7323b6dec | ||
|
|
94fab7ad1c | ||
|
|
b3fef8f110 | ||
|
|
81cf106578 | ||
|
|
a197f64d2c | ||
|
|
67b8674337 | ||
|
|
46f7390cbe |
@@ -81,6 +81,7 @@ gem 'devise_token_auth', '>= 1.2.3'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
gem 'pundit'
|
||||
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.20.1'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.3'
|
||||
@@ -171,6 +172,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-saml'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
|
||||
+9
-1
@@ -589,8 +589,9 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.2)
|
||||
omniauth (2.1.3)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
@@ -604,6 +605,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
omniauth (~> 2.1)
|
||||
ruby-saml (~> 1.18)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
@@ -773,6 +777,9 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -1047,6 +1054,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-saml
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
|
||||
@@ -47,10 +47,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName
|
||||
# find the user with their email instead of UID and token
|
||||
@resource = resource_class.where(
|
||||
email: auth_hash['info']['email']
|
||||
).first
|
||||
email = auth_hash.dig('info', 'email')
|
||||
@resource = resource_class.from_email(email)
|
||||
end
|
||||
|
||||
def validate_signup_email_is_business_domain?
|
||||
@@ -75,3 +73,5 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
'user'
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::OmniauthCallbacksController.prepend_mod_with('DeviseOverrides::OmniauthCallbacksController')
|
||||
|
||||
@@ -44,3 +44,5 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
}, status: status
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::PasswordsController.prepend_mod_with('DeviseOverrides::PasswordsController')
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SamlSettingsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('saml_settings', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.put(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
delete() {
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SamlSettingsAPI();
|
||||
@@ -494,6 +494,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('sla_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Security',
|
||||
label: t('SIDEBAR.SECURITY'),
|
||||
icon: 'i-lucide-shield',
|
||||
to: accountScopedRoute('security_settings_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Billing',
|
||||
label: t('SIDEBAR.BILLING'),
|
||||
|
||||
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
SAML: 'saml',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
@@ -49,4 +50,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.CAPTAIN_V2,
|
||||
FEATURE_FLAGS.SAML,
|
||||
];
|
||||
|
||||
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -358,7 +358,8 @@
|
||||
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"DOCS": "Read docs"
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security"
|
||||
},
|
||||
"BILLING_SETTINGS": {
|
||||
"TITLE": "Billing",
|
||||
@@ -390,6 +391,77 @@
|
||||
},
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
|
||||
},
|
||||
"SECURITY_SETTINGS": {
|
||||
"TITLE": "Security",
|
||||
"DESCRIPTION": "Manage your account security settings.",
|
||||
"LINK_TEXT": "Learn more about SAML SSO",
|
||||
"SAML": {
|
||||
"TITLE": "SAML SSO",
|
||||
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
|
||||
"ACS_URL": {
|
||||
"LABEL": "ACS URL",
|
||||
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
|
||||
},
|
||||
"SSO_URL": {
|
||||
"LABEL": "SSO URL",
|
||||
"HELP": "The URL where SAML authentication requests will be sent",
|
||||
"PLACEHOLDER": "https://your-idp.com/saml/sso"
|
||||
},
|
||||
"CERTIFICATE": {
|
||||
"LABEL": "Signing certificate in PEM format",
|
||||
"HELP": "The public certificate from your identity provider used to verify SAML responses",
|
||||
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
|
||||
},
|
||||
"FINGERPRINT": {
|
||||
"LABEL": "Fingerprint",
|
||||
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
|
||||
},
|
||||
"COPY_SUCCESS": "Copied to clipboard",
|
||||
"SP_ENTITY_ID": {
|
||||
"LABEL": "SP Entity ID",
|
||||
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
|
||||
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
|
||||
},
|
||||
"IDP_ENTITY_ID": {
|
||||
"LABEL": "Identity Provider Entity ID",
|
||||
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
|
||||
"PLACEHOLDER": "https://your-idp.com/saml"
|
||||
},
|
||||
"UPDATE_BUTTON": "Update SAML Settings",
|
||||
"API": {
|
||||
"SUCCESS": "SAML settings updated successfully",
|
||||
"ERROR": "Failed to update SAML settings",
|
||||
"ERROR_LOADING": "Failed to load SAML settings",
|
||||
"DISABLED": "SAML settings disabled successfully"
|
||||
},
|
||||
"VALIDATION": {
|
||||
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
|
||||
"SSO_URL_ERROR": "Please enter a valid SSO URL",
|
||||
"CERTIFICATE_ERROR": "Certificate is required",
|
||||
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to enable SAML SSO",
|
||||
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ATTRIBUTE_MAPPING": {
|
||||
"TITLE": "SAML Attribute Setup",
|
||||
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
|
||||
},
|
||||
"INFO_SECTION": {
|
||||
"TITLE": "Service Provider Information",
|
||||
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
"NEW_ACCOUNT": "New Account",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import SamlSettings from './components/SamlSettings.vue';
|
||||
import SamlPaywall from './components/SamlPaywall.vue';
|
||||
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
const { shouldShow, shouldShowPaywall } = usePolicy();
|
||||
|
||||
const shouldShowSaml = computed(() =>
|
||||
shouldShow(
|
||||
FEATURE_FLAGS.SAML,
|
||||
['administrator'],
|
||||
[INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE]
|
||||
)
|
||||
);
|
||||
const showPaywall = computed(() => shouldShowPaywall('saml'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
class="max-w-2xl mx-auto"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('SECURITY_SETTINGS.TITLE')"
|
||||
:description="$t('SECURITY_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('SECURITY_SETTINGS.LINK_TEXT')"
|
||||
feature-name="saml"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<SamlPaywall v-if="showPaywall" />
|
||||
<SamlSettings v-else-if="shouldShowSaml" />
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<h4 class="font-medium text-n-slate-12">
|
||||
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }}
|
||||
</h4>
|
||||
<i
|
||||
class="i-lucide-chevron-down transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="transition-[height] duration-200 ease-in-out overflow-hidden"
|
||||
:class="isExpanded ? 'h-auto' : 'h-0'"
|
||||
>
|
||||
<div class="px-4 pb-3">
|
||||
<p class="text-n-slate-11 mb-2">
|
||||
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }}
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<ul class="list-none text-n-slate-12 space-y-1">
|
||||
<li><code class="px-1 rounded bg-n-slate-3">email</code></li>
|
||||
<li><code class="px-1 rounded bg-n-slate-3">first_name</code></li>
|
||||
<li><code class="px-1 rounded bg-n-slate-3">last_name</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
fingerprint: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spEntityId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const acsUrl = computed(() => {
|
||||
const currentHost = window.location.origin;
|
||||
return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`;
|
||||
});
|
||||
|
||||
const allInfoItems = computed(() => [
|
||||
{
|
||||
key: 'ACS_URL',
|
||||
label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'),
|
||||
value: acsUrl.value,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'SP_ENTITY_ID',
|
||||
label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'),
|
||||
value: props.spEntityId,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'),
|
||||
show: !!props.spEntityId,
|
||||
},
|
||||
{
|
||||
key: 'FINGERPRINT',
|
||||
label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'),
|
||||
value: props.fingerprint,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'),
|
||||
show: !!props.fingerprint,
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleInfoItems = computed(() =>
|
||||
allInfoItems.value.filter(item => item.show)
|
||||
);
|
||||
|
||||
const handleCopy = async text => {
|
||||
await copyTextToClipboard(text);
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }}
|
||||
</h3>
|
||||
<i
|
||||
v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')"
|
||||
class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help"
|
||||
/>
|
||||
</div>
|
||||
<section
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||
>
|
||||
<div
|
||||
v-for="item in visibleInfoItems"
|
||||
:key="item.key"
|
||||
class="ps-4 pe-1 py-1 flex justify-between items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-n-slate-11 w-32 flex items-center gap-1">
|
||||
{{ item.label }}
|
||||
<i
|
||||
v-tooltip.top="item.tooltip"
|
||||
class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex-1">{{ item.value }}</span>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-copy"
|
||||
@click="handleCopy(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return currentUser.value.type === 'SuperAdmin';
|
||||
});
|
||||
const { accountId, isOnChatwootCloud } = useAccount();
|
||||
|
||||
const i18nKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
|
||||
);
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="SECURITY_SETTINGS.SAML"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import samlSettingsAPI from 'dashboard/api/samlSettings';
|
||||
|
||||
import SectionLayout from '../../account/components/SectionLayout.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import TextInput from 'next/input/Input.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import SamlInfoSection from './SamlInfoSection.vue';
|
||||
import SamlAttributeMap from './SamlAttributeMap.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const id = ref(null);
|
||||
const fingerprint = ref('');
|
||||
const spEntityId = ref('');
|
||||
const isEnabled = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const formState = reactive({
|
||||
ssoUrl: '',
|
||||
certificate: '',
|
||||
idpEntityId: '',
|
||||
});
|
||||
|
||||
const validations = {
|
||||
ssoUrl: { required },
|
||||
certificate: { required },
|
||||
idpEntityId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, formState);
|
||||
|
||||
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
|
||||
|
||||
const ssoUrlError = computed(() =>
|
||||
v$.value.ssoUrl.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const certificateError = computed(() =>
|
||||
v$.value.certificate.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const idpEntityIdError = computed(() =>
|
||||
v$.value.idpEntityId.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const loadSamlSettings = async () => {
|
||||
if (!hasFeature.value) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await samlSettingsAPI.get();
|
||||
const settings = response.data;
|
||||
|
||||
if (settings.sso_url) {
|
||||
id.value = settings.id;
|
||||
formState.ssoUrl = settings.sso_url;
|
||||
formState.certificate = settings.certificate || '';
|
||||
spEntityId.value = settings.sp_entity_id || '';
|
||||
formState.idpEntityId = settings.idp_entity_id || '';
|
||||
fingerprint.value = settings.fingerprint || '';
|
||||
isEnabled.value = formState.ssoUrl !== '';
|
||||
}
|
||||
} catch (error) {
|
||||
// If no settings exist (404), that's expected - just keep defaults
|
||||
if (error.response?.status !== 404) {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSamlSettings = async settings => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
|
||||
if (isEnabled.value && formState.ssoUrl) {
|
||||
// Create or update settings based on existing id
|
||||
let response;
|
||||
if (id.value) {
|
||||
response = await samlSettingsAPI.update(settings);
|
||||
} else {
|
||||
response = await samlSettingsAPI.create(settings);
|
||||
}
|
||||
|
||||
// Update local state with response data including fingerprint and id
|
||||
if (response?.data) {
|
||||
id.value = response.data.id;
|
||||
fingerprint.value = response.data.fingerprint || '';
|
||||
spEntityId.value = response.data.sp_entity_id || '';
|
||||
}
|
||||
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
|
||||
} else {
|
||||
// Disable/delete settings
|
||||
await samlSettingsAPI.delete();
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle backend validation errors
|
||||
if (error.response?.data?.errors) {
|
||||
const errorMessages = error.response.data.errors;
|
||||
const firstError = Array.isArray(errorMessages)
|
||||
? errorMessages[0]
|
||||
: errorMessages;
|
||||
useAlert(firstError);
|
||||
} else {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const settings = {
|
||||
sso_url: formState.ssoUrl,
|
||||
certificate: formState.certificate,
|
||||
idp_entity_id: formState.idpEntityId,
|
||||
role_mappings: {},
|
||||
};
|
||||
|
||||
await saveSamlSettings(settings);
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
id.value = null;
|
||||
formState.ssoUrl = '';
|
||||
formState.certificate = '';
|
||||
spEntityId.value = '';
|
||||
formState.idpEntityId = '';
|
||||
fingerprint.value = '';
|
||||
|
||||
// the empty save will delete the SAML settings item
|
||||
await saveSamlSettings({});
|
||||
};
|
||||
|
||||
const toggleSaml = async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleDisable();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSamlSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
|
||||
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
|
||||
:hide-content="!hasFeature || !isEnabled || isLoading"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch
|
||||
v-model="isEnabled"
|
||||
:disabled="isLoading"
|
||||
@change="toggleSaml"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SamlInfoSection
|
||||
class="mb-5"
|
||||
:fingerprint="fingerprint"
|
||||
:sp-entity-id="spEntityId"
|
||||
/>
|
||||
<SamlAttributeMap class="mb-5" />
|
||||
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
name="ssoUrl"
|
||||
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
|
||||
:has-error="v$.ssoUrl.$error"
|
||||
:error-message="ssoUrlError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.ssoUrl"
|
||||
class="w-full"
|
||||
type="url"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="idpEntityId"
|
||||
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
|
||||
:has-error="v$.idpEntityId.$error"
|
||||
:error-message="idpEntityIdError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.idpEntityId"
|
||||
class="w-full"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="certificate"
|
||||
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
|
||||
:has-error="v$.certificate.$error"
|
||||
:error-message="certificateError"
|
||||
required
|
||||
>
|
||||
<TextArea
|
||||
v-model="formState.certificate"
|
||||
class="w-full"
|
||||
rows="8"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/security'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'SECURITY_SETTINGS.TITLE',
|
||||
icon: 'i-lucide-shield',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'security_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.SAML,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import sla from './sla/sla.routes';
|
||||
import teams from './teams/teams.routes';
|
||||
import customRoles from './customRoles/customRole.routes';
|
||||
import profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -61,5 +62,6 @@ export default {
|
||||
...teams.routes,
|
||||
...customRoles.routes,
|
||||
...profile.routes,
|
||||
...security.routes,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Provides session management functionality for Devise Token Auth tokens.
|
||||
# Handles logout operations, session limits, and token cleanup.
|
||||
module SessionManageable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Clears all active sessions for the user.
|
||||
# @return [void]
|
||||
def logout_all_sessions!
|
||||
self.tokens = {}
|
||||
save!
|
||||
end
|
||||
|
||||
# Logs out a specific session by client ID.
|
||||
# @param client_id [String] the client identifier to logout
|
||||
# @return [Boolean] true if session was found and removed
|
||||
def logout_session!(client_id)
|
||||
return false unless client_id.present? && tokens.present?
|
||||
|
||||
removed = tokens.delete(client_id)
|
||||
save! if removed
|
||||
|
||||
removed.present?
|
||||
end
|
||||
|
||||
# Removes tokens that expired before the given timestamp.
|
||||
# @param timestamp [Time, Integer] cutoff time for token cleanup
|
||||
# @return [void]
|
||||
def reset_tokens_before!(timestamp)
|
||||
return unless tokens.present?
|
||||
|
||||
self.tokens = tokens.select do |_client_id, token_data|
|
||||
(token_data['expiry'] || 0) >= timestamp.to_i
|
||||
end
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
# Returns count of non-expired active sessions.
|
||||
# @return [Integer] number of active sessions
|
||||
def active_session_count
|
||||
return 0 unless tokens.present?
|
||||
|
||||
current_time = Time.current.to_i
|
||||
tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time }
|
||||
end
|
||||
|
||||
# Checks if user has exceeded configured session limit.
|
||||
# @return [Boolean] true if session limit is exceeded
|
||||
def session_limit_exceeded?
|
||||
active_session_count >= session_limit
|
||||
end
|
||||
|
||||
# Returns session information for all active tokens.
|
||||
# @return [Array<Hash>] array of session info, sorted by expiry (newest first)
|
||||
def session_info
|
||||
return [] unless tokens.present?
|
||||
|
||||
tokens.map do |client_id, token_data|
|
||||
{
|
||||
client_id: client_id,
|
||||
expiry: Time.zone.at(token_data['expiry'] || 0)
|
||||
}
|
||||
end.sort_by { |session| session[:expiry] }.reverse
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns configured session limit from GlobalConfig.
|
||||
# Defaults to infinity if not configured.
|
||||
# @return [Integer, Float] session limit or Float::INFINITY
|
||||
def session_limit
|
||||
@session_limit ||= GlobalConfig.get(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: Current.account
|
||||
)&.to_i || Float::INFINITY
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -47,6 +47,7 @@ class User < ApplicationRecord
|
||||
include Pubsubable
|
||||
include Rails.application.routes.url_helpers
|
||||
include Reportable
|
||||
include SessionManageable
|
||||
include SsoAuthenticatable
|
||||
include UserAttributeHelpers
|
||||
|
||||
@@ -58,7 +59,7 @@ class User < ApplicationRecord
|
||||
:validatable,
|
||||
:confirmable,
|
||||
: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
|
||||
# remove the column availability from users
|
||||
|
||||
@@ -47,6 +47,10 @@ module Chatwoot
|
||||
# Add enterprise views to the view paths
|
||||
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.
|
||||
# Application configuration can go into files in config/initializers
|
||||
# -- 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
|
||||
provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), {
|
||||
provider_ignores_state: true
|
||||
|
||||
@@ -39,6 +39,9 @@ en:
|
||||
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.
|
||||
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.
|
||||
|
||||
errors:
|
||||
@@ -100,6 +103,8 @@ en:
|
||||
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
|
||||
custom_attribute_definition:
|
||||
key_conflict: The provided key is not allowed as it might conflict with default attributes.
|
||||
account_saml_settings:
|
||||
invalid_certificate: must be a valid X.509 certificate in PEM format
|
||||
reports:
|
||||
period: Reporting period %{since} to %{until}
|
||||
utc_warning: The report generated is in UTC timezone
|
||||
|
||||
@@ -8,6 +8,9 @@ Rails.application.routes.draw do
|
||||
omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
|
||||
}, via: [:get, :post]
|
||||
|
||||
# SAML SLO endpoint - handled by OmniAuth SAML strategy
|
||||
post '/auth/saml/slo', to: 'devise_overrides/omniauth_callbacks#passthru'
|
||||
|
||||
## renders the frontend paths only if its not an api only server
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
|
||||
root to: 'api#index'
|
||||
|
||||
@@ -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
|
||||
@@ -7,11 +7,19 @@ class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseControl
|
||||
|
||||
def create
|
||||
@saml_settings = Current.account.build_saml_settings(saml_settings_params)
|
||||
@saml_settings.save!
|
||||
if @saml_settings.save
|
||||
render :show
|
||||
else
|
||||
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@saml_settings.update!(saml_settings_params)
|
||||
if @saml_settings.update(saml_settings_params)
|
||||
render :show
|
||||
else
|
||||
render json: { errors: @saml_settings.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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 unless ChatwootApp.enterprise?
|
||||
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,24 @@
|
||||
module Enterprise::DeviseOverrides::PasswordsController
|
||||
def create
|
||||
if saml_user_attempting_password_reset?
|
||||
render json: {
|
||||
success: false,
|
||||
errors: [I18n.t('messages.reset_password_saml_user')]
|
||||
}, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def saml_user_attempting_password_reset?
|
||||
return false if params[:email].blank?
|
||||
|
||||
user = User.from_email(params[:email])
|
||||
return false unless user&.provider == 'saml'
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,16 @@
|
||||
module Enterprise::DeviseOverrides::SessionsController
|
||||
def create
|
||||
if saml_user_attempting_password_login?
|
||||
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
|
||||
@@ -22,4 +34,17 @@ module Enterprise::DeviseOverrides::SessionsController
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def saml_user_attempting_password_login?
|
||||
return false if params[:email].blank?
|
||||
|
||||
user = User.from_email(params[:email])
|
||||
return false unless user&.provider == 'saml'
|
||||
|
||||
return false if params[:sso_auth_token].present? && user.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,7 @@ class AccountSamlSettings < ApplicationRecord
|
||||
validates :sso_url, presence: true
|
||||
validates :certificate, presence: true
|
||||
validates :idp_entity_id, presence: true
|
||||
validate :certificate_must_be_valid_x509
|
||||
|
||||
before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation?
|
||||
|
||||
@@ -56,4 +57,12 @@ class AccountSamlSettings < ApplicationRecord
|
||||
def installation_name
|
||||
GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')
|
||||
end
|
||||
|
||||
def certificate_must_be_valid_x509
|
||||
return if certificate.blank?
|
||||
|
||||
OpenSSL::X509::Certificate.new(certificate)
|
||||
rescue OpenSSL::X509::CertificateError
|
||||
errors.add(:certificate, I18n.t('errors.account_saml_settings.invalid_certificate'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Enterprise Edition SAML SSO Provider
|
||||
# This initializer adds SAML authentication support for Enterprise customers
|
||||
# Handle IdP-initiated Single Logout (SLO) session destruction
|
||||
SAML_SLO_SESSION_DESTROY_PROC = proc do |env, _session|
|
||||
# Extract user info from SAML logout request
|
||||
logout_request = env['omniauth.strategy'].response_object
|
||||
name_id = logout_request.name_id if logout_request.respond_to?(:name_id)
|
||||
|
||||
next unless name_id
|
||||
|
||||
# Find user by email and verify it's a SAML user
|
||||
user = User.from_email(name_id)
|
||||
user&.logout_all_sessions! if user&.provider == 'saml'
|
||||
end
|
||||
|
||||
# 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'
|
||||
env['omniauth.strategy'].options[:idp_slo_session_destroy] = SAML_SLO_SESSION_DESTROY_PROC
|
||||
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
|
||||
@@ -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
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
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
|
||||
|
||||
describe 'SAML SLO (Single Logout)' do
|
||||
let(:saml_user) { create(:user, email: 'saml_user@example.com', provider: 'saml', account: account) }
|
||||
|
||||
before do
|
||||
# Create some active sessions for the user
|
||||
saml_user.tokens = {
|
||||
'client1' => { 'token' => 'token1', 'expiry' => 1.hour.from_now.to_i },
|
||||
'client2' => { 'token' => 'token2', 'expiry' => 2.hours.from_now.to_i }
|
||||
}
|
||||
saml_user.save!
|
||||
end
|
||||
|
||||
describe 'SAML_SLO_SESSION_DESTROY_PROC' do
|
||||
it 'destroys all user sessions when receiving IdP-initiated logout request' do
|
||||
# Create a mock logout request with the user's email as name_id
|
||||
logout_request = object_double(logout_request, name_id: saml_user.email)
|
||||
|
||||
# Create a mock OmniAuth strategy
|
||||
strategy = object_double(strategy, response_object: logout_request)
|
||||
|
||||
# Create the environment hash that would be passed to the proc
|
||||
env = { 'omniauth.strategy' => strategy }
|
||||
|
||||
# Execute the SLO session destroy proc
|
||||
SAML_SLO_SESSION_DESTROY_PROC.call(env, {})
|
||||
|
||||
# Verify all sessions were destroyed
|
||||
saml_user.reload
|
||||
expect(saml_user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'only destroys sessions for SAML provider users' do
|
||||
# Create a non-SAML user
|
||||
email_user = create(:user, email: 'email_user@example.com', provider: 'email', account: account)
|
||||
email_user.tokens = { 'client1' => { 'token' => 'token1', 'expiry' => 1.hour.from_now.to_i } }
|
||||
email_user.save!
|
||||
|
||||
# Create another SAML user
|
||||
saml_user2 = create(:user, email: 'another_saml@example.com', provider: 'saml', account: account)
|
||||
saml_user2.tokens = { 'client2' => { 'token' => 'token2', 'expiry' => 1.hour.from_now.to_i } }
|
||||
saml_user2.save!
|
||||
|
||||
# Create logout request for the second SAML user
|
||||
logout_request = object_double(logout_request, name_id: 'another_saml@example.com')
|
||||
|
||||
strategy = object_double(strategy, response_object: logout_request)
|
||||
|
||||
env = { 'omniauth.strategy' => strategy }
|
||||
|
||||
# Execute the SLO session destroy proc
|
||||
SAML_SLO_SESSION_DESTROY_PROC.call(env, {})
|
||||
|
||||
# Verify only the targeted SAML user sessions were destroyed
|
||||
email_user.reload
|
||||
saml_user.reload
|
||||
saml_user2.reload
|
||||
|
||||
expect(email_user.tokens).not_to be_empty # Non-SAML user unaffected
|
||||
expect(saml_user.tokens).not_to be_empty # Different SAML user unaffected
|
||||
expect(saml_user2.tokens).to eq({}) # Target SAML user logged out
|
||||
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
|
||||
+64
-21
@@ -5,32 +5,75 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
||||
|
||||
describe 'POST /sign_in' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, password: 'Password1!' }
|
||||
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) }
|
||||
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
before do
|
||||
saml_settings
|
||||
saml_user
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(user.email)
|
||||
it 'prevents login and returns SAML authentication error' do
|
||||
params = { email: saml_user.email, password: 'Password1!' }
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
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
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
context 'with regular user credentials' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, 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(user.email)
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SessionManageable do
|
||||
let(:user) { create(:user) }
|
||||
let(:current_time) { Time.current.to_i }
|
||||
let(:expired_time) { 1.hour.ago.to_i }
|
||||
let(:future_time) { 1.hour.from_now.to_i }
|
||||
|
||||
before do
|
||||
# Mock GlobalConfig for session limit
|
||||
allow(GlobalConfig).to receive(:get).with(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: nil
|
||||
).and_return('5')
|
||||
end
|
||||
|
||||
describe '#logout_all_sessions!' do
|
||||
it 'clears all tokens and saves the user' do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'works with empty tokens' do
|
||||
user.tokens = {}
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'works with nil tokens' do
|
||||
user.tokens = nil
|
||||
expect(user).to receive(:save!)
|
||||
user.logout_all_sessions!
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#logout_session!' do
|
||||
before do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||
}
|
||||
end
|
||||
|
||||
it 'removes specific client token and saves' do
|
||||
expect(user).to receive(:save!)
|
||||
result = user.logout_session!('client1')
|
||||
|
||||
expect(result).to be true
|
||||
expect(user.tokens).not_to have_key('client1')
|
||||
expect(user.tokens).to have_key('client2')
|
||||
end
|
||||
|
||||
it 'returns false for non-existent client' do
|
||||
result = user.logout_session!('non_existent_client')
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false for empty client_id' do
|
||||
result = user.logout_session!('')
|
||||
expect(result).to be false
|
||||
|
||||
result = user.logout_session!(nil)
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false when no tokens present' do
|
||||
user.tokens = nil
|
||||
result = user.logout_session!('client1')
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reset_tokens_before!' do
|
||||
before do
|
||||
user.tokens = {
|
||||
'expired_client' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||
'current_client' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time }
|
||||
}
|
||||
end
|
||||
|
||||
it 'removes tokens that expired before the given timestamp' do
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(current_time)
|
||||
|
||||
expect(user.tokens).not_to have_key('expired_client')
|
||||
expect(user.tokens).to have_key('current_client')
|
||||
expect(user.tokens).to have_key('edge_case_client')
|
||||
end
|
||||
|
||||
it 'handles timestamp as Time object' do
|
||||
timestamp = Time.zone.at(current_time)
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(timestamp)
|
||||
|
||||
expect(user.tokens).not_to have_key('expired_client')
|
||||
expect(user.tokens).to have_key('current_client')
|
||||
end
|
||||
|
||||
it 'does nothing when no tokens present' do
|
||||
user.tokens = nil
|
||||
user.reset_tokens_before!(current_time)
|
||||
# tokens gets initialized to empty hash during the process
|
||||
expect(user.tokens).to eq({})
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'no_expiry_client' => { 'token' => 'token1' },
|
||||
'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 }
|
||||
}
|
||||
|
||||
expect(user).to receive(:save!)
|
||||
user.reset_tokens_before!(current_time)
|
||||
|
||||
expect(user.tokens).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active_session_count' do
|
||||
it 'counts only non-expired tokens' do
|
||||
user.tokens = {
|
||||
'expired1' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||
'active1' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token3', 'expiry' => future_time },
|
||||
'expired2' => { 'token' => 'token4', 'expiry' => expired_time }
|
||||
}
|
||||
|
||||
expect(user.active_session_count).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns 0 when no tokens present' do
|
||||
user.tokens = nil
|
||||
expect(user.active_session_count).to eq(0)
|
||||
|
||||
user.tokens = {}
|
||||
expect(user.active_session_count).to eq(0)
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'no_expiry' => { 'token' => 'token1' },
|
||||
'active' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.active_session_count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#session_limit_exceeded?' do
|
||||
it 'returns true when active sessions exceed limit' do
|
||||
# Mock 3 session limit
|
||||
allow(GlobalConfig).to receive(:get).and_return('3')
|
||||
|
||||
user.tokens = {
|
||||
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token2', 'expiry' => future_time },
|
||||
'active3' => { 'token' => 'token3', 'expiry' => future_time },
|
||||
'active4' => { 'token' => 'token4', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.session_limit_exceeded?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when within limit' do
|
||||
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||
|
||||
user.tokens = {
|
||||
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||
'active2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
expect(user.session_limit_exceeded?).to be false
|
||||
end
|
||||
|
||||
it 'handles infinite limit' do
|
||||
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||
|
||||
user.tokens = {}
|
||||
(1..100).each do |i|
|
||||
user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time }
|
||||
end
|
||||
|
||||
expect(user.session_limit_exceeded?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#session_info' do
|
||||
it 'returns session information sorted by expiry (newest first)' do
|
||||
earlier_time = 2.hours.from_now.to_i
|
||||
later_time = 3.hours.from_now.to_i
|
||||
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'token1', 'expiry' => earlier_time },
|
||||
'client2' => { 'token' => 'token2', 'expiry' => later_time }
|
||||
}
|
||||
|
||||
sessions = user.session_info
|
||||
|
||||
expect(sessions.size).to eq(2)
|
||||
expect(sessions[0][:client_id]).to eq('client2')
|
||||
expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time))
|
||||
expect(sessions[1][:client_id]).to eq('client1')
|
||||
expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time))
|
||||
end
|
||||
|
||||
it 'returns empty array when no tokens' do
|
||||
user.tokens = nil
|
||||
expect(user.session_info).to eq([])
|
||||
|
||||
user.tokens = {}
|
||||
expect(user.session_info).to eq([])
|
||||
end
|
||||
|
||||
it 'handles tokens with missing expiry' do
|
||||
user.tokens = {
|
||||
'client1' => { 'token' => 'token1' },
|
||||
'client2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||
}
|
||||
|
||||
sessions = user.session_info
|
||||
|
||||
expect(sessions.size).to eq(2)
|
||||
expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time))
|
||||
expect(sessions[1][:expiry]).to eq(Time.zone.at(0))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#session_limit' do
|
||||
it 'returns configured limit from GlobalConfig' do
|
||||
allow(GlobalConfig).to receive(:get).with(
|
||||
'USER_SESSION_LIMIT',
|
||||
'USER_SESSION_LIMIT_PER_USER',
|
||||
account: nil
|
||||
).and_return('10')
|
||||
|
||||
limit = user.send(:session_limit)
|
||||
expect(limit).to eq(10)
|
||||
end
|
||||
|
||||
it 'returns infinity when no limit configured' do
|
||||
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||
|
||||
limit = user.send(:session_limit)
|
||||
expect(limit).to eq(Float::INFINITY)
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||
|
||||
# First call
|
||||
limit1 = user.send(:session_limit)
|
||||
# Second call should use memoized value
|
||||
limit2 = user.send(:session_limit)
|
||||
|
||||
expect(limit1).to eq(limit2)
|
||||
expect(GlobalConfig).to have_received(:get).once
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user