diff --git a/apps/meteor/app/lib/server/methods/addOAuthService.ts b/apps/meteor/app/lib/server/methods/addOAuthService.ts index abf1b7035af1..05b0e5a7e4e6 100644 --- a/apps/meteor/app/lib/server/methods/addOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/addOAuthService.ts @@ -2,8 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { addOAuthService } from '../../../../server/lib/oauth/addOAuthService'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { addOAuthService } from '../functions/addOAuthService'; declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts index 9faa67f239a1..e5b1c377a33e 100644 --- a/apps/meteor/app/lib/server/methods/refreshOAuthService.ts +++ b/apps/meteor/app/lib/server/methods/refreshOAuthService.ts @@ -1,8 +1,7 @@ -import { Settings } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; +import { refreshLoginServices } from '../../../../server/lib/refreshLoginServices'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; declare module '@rocket.chat/ui-contexts' { @@ -29,8 +28,6 @@ Meteor.methods({ }); } - await ServiceConfiguration.configurations.removeAsync({}); - - await Settings.update({ _id: /^(Accounts_OAuth_|SAML_|CAS_).+/ }, { $set: { _updatedAt: new Date() } }, { multi: true }); + await refreshLoginServices(); }, }); diff --git a/apps/meteor/app/lib/server/startup/index.ts b/apps/meteor/app/lib/server/startup/index.ts index d52acdf6b7d9..deadb8a44c06 100644 --- a/apps/meteor/app/lib/server/startup/index.ts +++ b/apps/meteor/app/lib/server/startup/index.ts @@ -1,10 +1,5 @@ -import { customOAuthServicesInit } from './oAuthServicesUpdate'; import './rateLimiter'; import './robots'; import './settingsOnLoadCdnPrefix'; import './settingsOnLoadDirectReply'; import './settingsOnLoadSMTP'; - -export const libStartup = async () => { - await customOAuthServicesInit(); -}; diff --git a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js b/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js deleted file mode 100644 index 32f7417e8444..000000000000 --- a/apps/meteor/app/lib/server/startup/oAuthServicesUpdate.js +++ /dev/null @@ -1,204 +0,0 @@ -import { Logger } from '@rocket.chat/logger'; -import { ServiceConfiguration } from 'meteor/service-configuration'; -import _ from 'underscore'; - -import { CustomOAuth } from '../../../custom-oauth/server/custom_oauth_server'; -import { settings } from '../../../settings/server'; -import { addOAuthService } from '../functions/addOAuthService'; - -const logger = new Logger('rocketchat:lib'); - -async function _OAuthServicesUpdate() { - const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); - const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); - for await (const [key, value] of filteredServices) { - logger.debug({ oauth_updated: key }); - let serviceName = key.replace('Accounts_OAuth_', ''); - if (serviceName === 'Meteor') { - serviceName = 'meteor-developer'; - } - if (/Accounts_OAuth_Custom-/.test(key)) { - serviceName = key.replace('Accounts_OAuth_Custom-', ''); - } - - if (value === true) { - const data = { - clientId: settings.get(`${key}_id`), - secret: settings.get(`${key}_secret`), - }; - - if (/Accounts_OAuth_Custom-/.test(key)) { - data.custom = true; - data.clientId = settings.get(`${key}-id`); - data.secret = settings.get(`${key}-secret`); - data.serverURL = settings.get(`${key}-url`); - data.tokenPath = settings.get(`${key}-token_path`); - data.identityPath = settings.get(`${key}-identity_path`); - data.authorizePath = settings.get(`${key}-authorize_path`); - data.scope = settings.get(`${key}-scope`); - data.accessTokenParam = settings.get(`${key}-access_token_param`); - data.buttonLabelText = settings.get(`${key}-button_label_text`); - data.buttonLabelColor = settings.get(`${key}-button_label_color`); - data.loginStyle = settings.get(`${key}-login_style`); - data.buttonColor = settings.get(`${key}-button_color`); - data.tokenSentVia = settings.get(`${key}-token_sent_via`); - data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); - data.keyField = settings.get(`${key}-key_field`); - data.usernameField = settings.get(`${key}-username_field`); - data.emailField = settings.get(`${key}-email_field`); - data.nameField = settings.get(`${key}-name_field`); - data.avatarField = settings.get(`${key}-avatar_field`); - data.rolesClaim = settings.get(`${key}-roles_claim`); - data.groupsClaim = settings.get(`${key}-groups_claim`); - data.channelsMap = settings.get(`${key}-groups_channel_map`); - data.channelsAdmin = settings.get(`${key}-channels_admin`); - data.mergeUsers = settings.get(`${key}-merge_users`); - data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); - data.mapChannels = settings.get(`${key}-map_channels`); - data.mergeRoles = settings.get(`${key}-merge_roles`); - data.rolesToSync = settings.get(`${key}-roles_to_sync`); - data.showButton = settings.get(`${key}-show_button`); - - new CustomOAuth(serviceName.toLowerCase(), { - serverURL: data.serverURL, - tokenPath: data.tokenPath, - identityPath: data.identityPath, - authorizePath: data.authorizePath, - scope: data.scope, - loginStyle: data.loginStyle, - tokenSentVia: data.tokenSentVia, - identityTokenSentVia: data.identityTokenSentVia, - keyField: data.keyField, - usernameField: data.usernameField, - emailField: data.emailField, - nameField: data.nameField, - avatarField: data.avatarField, - rolesClaim: data.rolesClaim, - groupsClaim: data.groupsClaim, - mapChannels: data.mapChannels, - channelsMap: data.channelsMap, - channelsAdmin: data.channelsAdmin, - mergeUsers: data.mergeUsers, - mergeUsersDistinctServices: data.mergeUsersDistinctServices, - mergeRoles: data.mergeRoles, - rolesToSync: data.rolesToSync, - accessTokenParam: data.accessTokenParam, - showButton: data.showButton, - }); - } - if (serviceName === 'Facebook') { - data.appId = data.clientId; - delete data.clientId; - } - if (serviceName === 'Twitter') { - data.consumerKey = data.clientId; - delete data.clientId; - } - - if (serviceName === 'Linkedin') { - data.clientConfig = { - requestPermissions: ['openid', 'email', 'profile'], - }; - } - - if (serviceName === 'Nextcloud') { - data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); - data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); - data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); - } - - // If there's no data other than the service name, then put the service name in the data object so the operation won't fail - const keys = Object.keys(data).filter((key) => data[key] !== undefined); - if (!keys.length) { - data.service = serviceName.toLowerCase(); - } - - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: data, - }, - ); - } else { - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); - } - } -} - -const OAuthServicesUpdate = _.debounce(_OAuthServicesUpdate, 2000); - -async function OAuthServicesRemove(_id) { - const serviceName = _id.replace('Accounts_OAuth_Custom-', ''); - return ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); -} - -settings.watchByRegex(/^Accounts_OAuth_.+/, () => { - return OAuthServicesUpdate(); // eslint-disable-line new-cap -}); - -settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { - if (!value) { - return OAuthServicesRemove(key); // eslint-disable-line new-cap - } -}); - -export async function customOAuthServicesInit() { - // Add settings for custom OAuth providers to the settings so they get - // automatically added when they are defined in ENV variables - for await (const key of Object.keys(process.env)) { - if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { - // Most all shells actually prohibit the usage of - in environment variables - // So this will allow replacing - with _ and translate it back to the setting name - let name = key.replace('Accounts_OAuth_Custom_', ''); - - if (name.indexOf('_') > -1) { - name = name.replace(name.substr(name.indexOf('_')), ''); - } - - const serviceKey = `Accounts_OAuth_Custom_${name}`; - - if (key === serviceKey) { - const values = { - enabled: process.env[`${serviceKey}`] === 'true', - clientId: process.env[`${serviceKey}_id`], - clientSecret: process.env[`${serviceKey}_secret`], - serverURL: process.env[`${serviceKey}_url`], - tokenPath: process.env[`${serviceKey}_token_path`], - identityPath: process.env[`${serviceKey}_identity_path`], - authorizePath: process.env[`${serviceKey}_authorize_path`], - scope: process.env[`${serviceKey}_scope`], - accessTokenParam: process.env[`${serviceKey}_access_token_param`], - buttonLabelText: process.env[`${serviceKey}_button_label_text`], - buttonLabelColor: process.env[`${serviceKey}_button_label_color`], - loginStyle: process.env[`${serviceKey}_login_style`], - buttonColor: process.env[`${serviceKey}_button_color`], - tokenSentVia: process.env[`${serviceKey}_token_sent_via`], - identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], - keyField: process.env[`${serviceKey}_key_field`], - usernameField: process.env[`${serviceKey}_username_field`], - nameField: process.env[`${serviceKey}_name_field`], - emailField: process.env[`${serviceKey}_email_field`], - rolesClaim: process.env[`${serviceKey}_roles_claim`], - groupsClaim: process.env[`${serviceKey}_groups_claim`], - channelsMap: process.env[`${serviceKey}_groups_channel_map`], - channelsAdmin: process.env[`${serviceKey}_channels_admin`], - mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', - mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', - mapChannels: process.env[`${serviceKey}_map_channels`], - mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', - rolesToSync: process.env[`${serviceKey}_roles_to_sync`], - showButton: process.env[`${serviceKey}_show_button`] === 'true', - avatarField: process.env[`${serviceKey}_avatar_field`], - }; - - await addOAuthService(name, values); - } - } - } -} diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 31bb8e37cfac..2086e934271e 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -1,5 +1,6 @@ +import type { SAMLConfiguration } from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { ServiceConfiguration } from 'meteor/service-configuration'; import { SystemLogger } from '../../../../server/lib/logger/system'; import { settings, settingsRegistry } from '../../../settings/server'; @@ -17,13 +18,13 @@ import { defaultMetadataCertificateTemplate, } from './constants'; -const getSamlConfigs = function (service: string): Record { - const configs = { +const getSamlConfigs = function (service: string): SAMLConfiguration { + const configs: SAMLConfiguration = { buttonLabelText: settings.get(`${service}_button_label_text`), buttonLabelColor: settings.get(`${service}_button_label_color`), buttonColor: settings.get(`${service}_button_color`), clientConfig: { - provider: settings.get(`${service}_provider`), + provider: settings.get(`${service}_provider`), }, entryPoint: settings.get(`${service}_entry_point`), idpSLORedirectURL: settings.get(`${service}_idp_slo_redirect_url`), @@ -115,19 +116,10 @@ export const loadSamlServiceProviders = async function (): Promise { if (value === true) { const samlConfigs = getSamlConfigs(key); SAMLUtils.log(key); - await ServiceConfiguration.configurations.upsertAsync( - { - service: serviceName.toLowerCase(), - }, - { - $set: samlConfigs, - }, - ); + await LoginServiceConfiguration.createOrUpdateService(serviceName, samlConfigs); return configureSamlService(samlConfigs); } - await ServiceConfiguration.configurations.removeAsync({ - service: serviceName.toLowerCase(), - }); + await LoginServiceConfiguration.removeService(serviceName); return false; }), ) diff --git a/apps/meteor/ee/server/configuration/oauth.ts b/apps/meteor/ee/server/configuration/oauth.ts index aa66a46caf69..4099b159918c 100644 --- a/apps/meteor/ee/server/configuration/oauth.ts +++ b/apps/meteor/ee/server/configuration/oauth.ts @@ -21,8 +21,8 @@ interface IOAuthUserIdentity { } interface IOAuthSettings { - mapChannels: string; - mergeRoles: string; + mapChannels: boolean; + mergeRoles: boolean; rolesToSync: string; rolesClaim: string; groupsClaim: string; @@ -34,13 +34,13 @@ const logger = new Logger('EECustomOAuth'); function getOAuthSettings(serviceName: string): IOAuthSettings { return { - mapChannels: settings.get(`Accounts_OAuth_Custom-${serviceName}-map_channels`) as string, - mergeRoles: settings.get(`Accounts_OAuth_Custom-${serviceName}-merge_roles`) as string, - rolesToSync: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`) as string, - rolesClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_claim`) as string, - groupsClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_claim`) as string, - channelsAdmin: settings.get(`Accounts_OAuth_Custom-${serviceName}-channels_admin`) as string, - channelsMap: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`) as string, + mapChannels: settings.get(`Accounts_OAuth_Custom-${serviceName}-map_channels`), + mergeRoles: settings.get(`Accounts_OAuth_Custom-${serviceName}-merge_roles`), + rolesToSync: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_to_sync`), + rolesClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-roles_claim`), + groupsClaim: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_claim`), + channelsAdmin: settings.get(`Accounts_OAuth_Custom-${serviceName}-channels_admin`), + channelsMap: settings.get(`Accounts_OAuth_Custom-${serviceName}-groups_channel_map`), }; } diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js index fd915de86f1c..6e4cd894a236 100644 --- a/apps/meteor/server/configuration/accounts_meld.js +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -2,51 +2,53 @@ import { Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import _ from 'underscore'; -const orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService; +export async function configureAccounts() { + const orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService; -const updateOrCreateUserFromExternalServiceAsync = async function (serviceName, serviceData = {}, ...args /* , options*/) { - const services = ['facebook', 'github', 'gitlab', 'google', 'meteor-developer', 'linkedin', 'twitter', 'apple']; + const updateOrCreateUserFromExternalServiceAsync = async function (serviceName, serviceData = {}, ...args /* , options*/) { + const services = ['facebook', 'github', 'gitlab', 'google', 'meteor-developer', 'linkedin', 'twitter', 'apple']; - if (services.includes(serviceName) === false && serviceData._OAuthCustom !== true) { - return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); - } - - if (serviceName === 'meteor-developer') { - if (Array.isArray(serviceData.emails)) { - const primaryEmail = serviceData.emails.sort((a) => a.primary !== true).filter((item) => item.verified === true)[0]; - serviceData.email = primaryEmail && primaryEmail.address; + if (services.includes(serviceName) === false && serviceData._OAuthCustom !== true) { + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); } - } - - if (serviceName === 'linkedin') { - serviceData.email = serviceData.emailAddress; - } - - if (serviceData.email) { - const user = await Users.findOneByEmailAddress(serviceData.email); - if (user != null) { - const findQuery = { - address: serviceData.email, - verified: true, - }; - - if (user.services?.password && !_.findWhere(user.emails, findQuery)) { - await Users.resetPasswordAndSetRequirePasswordChange( - user._id, - true, - 'This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password', - ); + + if (serviceName === 'meteor-developer') { + if (Array.isArray(serviceData.emails)) { + const primaryEmail = serviceData.emails.sort((a) => a.primary !== true).filter((item) => item.verified === true)[0]; + serviceData.email = primaryEmail && primaryEmail.address; } + } - await Users.setServiceId(user._id, serviceName, serviceData.id); - await Users.setEmailVerified(user._id, serviceData.email); + if (serviceName === 'linkedin') { + serviceData.email = serviceData.emailAddress; } - } - return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); -}; + if (serviceData.email) { + const user = await Users.findOneByEmailAddress(serviceData.email); + if (user != null) { + const findQuery = { + address: serviceData.email, + verified: true, + }; + + if (user.services?.password && !_.findWhere(user.emails, findQuery)) { + await Users.resetPasswordAndSetRequirePasswordChange( + user._id, + true, + 'This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password', + ); + } + + await Users.setServiceId(user._id, serviceName, serviceData.id); + await Users.setEmailVerified(user._id, serviceData.email); + } + } + + return orig_updateOrCreateUserFromExternalService.apply(this, [serviceName, serviceData, ...args]); + }; -Accounts.updateOrCreateUserFromExternalService = function (...args) { - // Depends on meteor support for Async - return Promise.await(updateOrCreateUserFromExternalServiceAsync.call(this, ...args)); -}; + Accounts.updateOrCreateUserFromExternalService = function (...args) { + // Depends on meteor support for Async + return Promise.await(updateOrCreateUserFromExternalServiceAsync.call(this, ...args)); + }; +} diff --git a/apps/meteor/server/configuration/cas.ts b/apps/meteor/server/configuration/cas.ts index 7a82f141dfeb..300320dda3fa 100644 --- a/apps/meteor/server/configuration/cas.ts +++ b/apps/meteor/server/configuration/cas.ts @@ -8,28 +8,30 @@ import { loginHandlerCAS } from '../lib/cas/loginHandler'; import { middlewareCAS } from '../lib/cas/middleware'; import { updateCasServices } from '../lib/cas/updateCasService'; -const _updateCasServices = debounce(updateCasServices, 2000); +export async function configureCAS() { + const _updateCasServices = debounce(updateCasServices, 2000); -settings.watchByRegex(/^CAS_.+/, async () => { - await _updateCasServices(); -}); + settings.watchByRegex(/^CAS_.+/, async () => { + await _updateCasServices(); + }); -RoutePolicy.declare('/_cas/', 'network'); + RoutePolicy.declare('/_cas/', 'network'); -// Listen to incoming OAuth http requests -WebApp.connectHandlers.use((req, res, next) => { - middlewareCAS(req, res, next); -}); + // Listen to incoming OAuth http requests + WebApp.connectHandlers.use((req, res, next) => { + middlewareCAS(req, res, next); + }); -/* - * Register a server-side login handler. - * It is called after Accounts.callLoginMethod() is called from client. - * - */ -Accounts.registerLoginHandler('cas', (options) => { - const promise = loginHandlerCAS(options); + /* + * Register a server-side login handler. + * It is called after Accounts.callLoginMethod() is called from client. + * + */ + Accounts.registerLoginHandler('cas', (options) => { + const promise = loginHandlerCAS(options); - // Pretend the promise has been awaited so the types will match - - // #TODO: Fix registerLoginHandler's type definitions (it accepts promises) - return promise as unknown as Awaited; -}); + // Pretend the promise has been awaited so the types will match - + // #TODO: Fix registerLoginHandler's type definitions (it accepts promises) + return promise as unknown as Awaited; + }); +} diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts new file mode 100644 index 000000000000..e81d1a64eda1 --- /dev/null +++ b/apps/meteor/server/configuration/index.ts @@ -0,0 +1,11 @@ +import { configureAccounts } from './accounts_meld'; +import { configureCAS } from './cas'; +import { configureLDAP } from './ldap'; +import { configureOAuth } from './oauth'; + +export async function configureLoginServices() { + await configureAccounts(); + await configureCAS(); + await configureLDAP(); + await configureOAuth(); +} diff --git a/apps/meteor/server/configuration/ldap.ts b/apps/meteor/server/configuration/ldap.ts index 1724a4fa1986..f7d8ee9c64c4 100644 --- a/apps/meteor/server/configuration/ldap.ts +++ b/apps/meteor/server/configuration/ldap.ts @@ -4,47 +4,49 @@ import { Accounts } from 'meteor/accounts-base'; import { settings } from '../../app/settings/server'; import { callbacks } from '../../lib/callbacks'; -// Register ldap login handler -Accounts.registerLoginHandler('ldap', async (loginRequest: Record) => { - if (!loginRequest.ldap || !loginRequest.ldapOptions) { - return undefined; - } - - return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass); -}); - -// Prevent password logins by LDAP users when LDAP is enabled -let ldapEnabled: boolean; -settings.watch('LDAP_Enable', (value) => { - if (ldapEnabled === value) { - return; - } - ldapEnabled = value as boolean; - - if (!value) { - return callbacks.remove('beforeValidateLogin', 'validateLdapLoginFallback'); - } - - callbacks.add( - 'beforeValidateLogin', - (login: Record) => { - if (!login.allowed) { - return login; - } +export async function configureLDAP() { + // Register ldap login handler + Accounts.registerLoginHandler('ldap', async (loginRequest: Record) => { + if (!loginRequest.ldap || !loginRequest.ldapOptions) { + return undefined; + } + + return LDAP.loginRequest(loginRequest.username, loginRequest.ldapPass); + }); + + // Prevent password logins by LDAP users when LDAP is enabled + let ldapEnabled: boolean; + settings.watch('LDAP_Enable', (value) => { + if (ldapEnabled === value) { + return; + } + ldapEnabled = value as boolean; + + if (!value) { + return callbacks.remove('beforeValidateLogin', 'validateLdapLoginFallback'); + } + + callbacks.add( + 'beforeValidateLogin', + (login: Record) => { + if (!login.allowed) { + return login; + } + + // The fallback setting should only block password logins, so users that have other login services can continue using them + if (login.type !== 'password') { + return login; + } + + // LDAP users can still login locally when login fallback is enabled + if (login.user.services?.ldap?.id) { + login.allowed = settings.get('LDAP_Login_Fallback') ?? false; + } - // The fallback setting should only block password logins, so users that have other login services can continue using them - if (login.type !== 'password') { return login; - } - - // LDAP users can still login locally when login fallback is enabled - if (login.user.services?.ldap?.id) { - login.allowed = settings.get('LDAP_Login_Fallback') ?? false; - } - - return login; - }, - callbacks.priority.MEDIUM, - 'validateLdapLoginFallback', - ); -}); + }, + callbacks.priority.MEDIUM, + 'validateLdapLoginFallback', + ); + }); +} diff --git a/apps/meteor/server/configuration/oauth.ts b/apps/meteor/server/configuration/oauth.ts new file mode 100644 index 000000000000..d79705171a7c --- /dev/null +++ b/apps/meteor/server/configuration/oauth.ts @@ -0,0 +1,21 @@ +import debounce from 'lodash.debounce'; + +import { settings } from '../../app/settings/server/cached'; +import { initCustomOAuthServices } from '../lib/oauth/initCustomOAuthServices'; +import { removeOAuthService } from '../lib/oauth/removeOAuthService'; +import { updateOAuthServices } from '../lib/oauth/updateOAuthServices'; + +export async function configureOAuth() { + const _updateOAuthServices = debounce(updateOAuthServices, 2000); + settings.watchByRegex(/^Accounts_OAuth_.+/, () => { + return _updateOAuthServices(); + }); + + settings.watchByRegex(/^Accounts_OAuth_Custom-[a-z0-9_]+/, (key, value) => { + if (!value) { + return removeOAuthService(key); + } + }); + + await initCustomOAuthServices(); +} diff --git a/apps/meteor/app/lib/server/functions/addOAuthService.ts b/apps/meteor/server/lib/oauth/addOAuthService.ts similarity index 99% rename from apps/meteor/app/lib/server/functions/addOAuthService.ts rename to apps/meteor/server/lib/oauth/addOAuthService.ts index eb28c5a7e3eb..2a49a23a1f4e 100644 --- a/apps/meteor/app/lib/server/functions/addOAuthService.ts +++ b/apps/meteor/server/lib/oauth/addOAuthService.ts @@ -2,7 +2,7 @@ /* eslint comma-spacing: 0 */ import { capitalize } from '@rocket.chat/string-helpers'; -import { settingsRegistry } from '../../../settings/server'; +import { settingsRegistry } from '../../../app/settings/server'; export async function addOAuthService(name: string, values: { [k: string]: string | boolean | undefined } = {}): Promise { name = name.toLowerCase().replace(/[^a-z0-9_]/g, ''); diff --git a/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts new file mode 100644 index 000000000000..3c909f6bc1f1 --- /dev/null +++ b/apps/meteor/server/lib/oauth/initCustomOAuthServices.ts @@ -0,0 +1,56 @@ +import { addOAuthService } from './addOAuthService'; + +export async function initCustomOAuthServices(): Promise { + // Add settings for custom OAuth providers to the settings so they get + // automatically added when they are defined in ENV variables + for await (const key of Object.keys(process.env)) { + if (/Accounts_OAuth_Custom_[a-zA-Z0-9_-]+$/.test(key)) { + // Most all shells actually prohibit the usage of - in environment variables + // So this will allow replacing - with _ and translate it back to the setting name + let name = key.replace('Accounts_OAuth_Custom_', ''); + + if (name.indexOf('_') > -1) { + name = name.replace(name.substr(name.indexOf('_')), ''); + } + + const serviceKey = `Accounts_OAuth_Custom_${name}`; + + if (key === serviceKey) { + const values = { + enabled: process.env[`${serviceKey}`] === 'true', + clientId: process.env[`${serviceKey}_id`], + clientSecret: process.env[`${serviceKey}_secret`], + serverURL: process.env[`${serviceKey}_url`], + tokenPath: process.env[`${serviceKey}_token_path`], + identityPath: process.env[`${serviceKey}_identity_path`], + authorizePath: process.env[`${serviceKey}_authorize_path`], + scope: process.env[`${serviceKey}_scope`], + accessTokenParam: process.env[`${serviceKey}_access_token_param`], + buttonLabelText: process.env[`${serviceKey}_button_label_text`], + buttonLabelColor: process.env[`${serviceKey}_button_label_color`], + loginStyle: process.env[`${serviceKey}_login_style`], + buttonColor: process.env[`${serviceKey}_button_color`], + tokenSentVia: process.env[`${serviceKey}_token_sent_via`], + identityTokenSentVia: process.env[`${serviceKey}_identity_token_sent_via`], + keyField: process.env[`${serviceKey}_key_field`], + usernameField: process.env[`${serviceKey}_username_field`], + nameField: process.env[`${serviceKey}_name_field`], + emailField: process.env[`${serviceKey}_email_field`], + rolesClaim: process.env[`${serviceKey}_roles_claim`], + groupsClaim: process.env[`${serviceKey}_groups_claim`], + channelsMap: process.env[`${serviceKey}_groups_channel_map`], + channelsAdmin: process.env[`${serviceKey}_channels_admin`], + mergeUsers: process.env[`${serviceKey}_merge_users`] === 'true', + mergeUsersDistinctServices: process.env[`${serviceKey}_merge_users_distinct_services`] === 'true', + mapChannels: process.env[`${serviceKey}_map_channels`], + mergeRoles: process.env[`${serviceKey}_merge_roles`] === 'true', + rolesToSync: process.env[`${serviceKey}_roles_to_sync`], + showButton: process.env[`${serviceKey}_show_button`] === 'true', + avatarField: process.env[`${serviceKey}_avatar_field`], + }; + + await addOAuthService(name, values); + } + } + } +} diff --git a/apps/meteor/server/lib/oauth/logger.ts b/apps/meteor/server/lib/oauth/logger.ts new file mode 100644 index 000000000000..e1f0fc2a8aeb --- /dev/null +++ b/apps/meteor/server/lib/oauth/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('rocketchat:lib'); diff --git a/apps/meteor/server/lib/oauth/removeOAuthService.ts b/apps/meteor/server/lib/oauth/removeOAuthService.ts new file mode 100644 index 000000000000..383a5acffd92 --- /dev/null +++ b/apps/meteor/server/lib/oauth/removeOAuthService.ts @@ -0,0 +1,9 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +export async function removeOAuthService(mainSettingId: string): Promise { + const serviceName = mainSettingId.replace('Accounts_OAuth_Custom-', ''); + + await ServiceConfiguration.configurations.removeAsync({ + service: serviceName.toLowerCase(), + }); +} diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts new file mode 100644 index 000000000000..ed0ae5977d0d --- /dev/null +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -0,0 +1,120 @@ +import type { + FacebookOAuthConfiguration, + ILoginServiceConfiguration, + LinkedinOAuthConfiguration, + OAuthConfiguration, + TwitterOAuthConfiguration, +} from '@rocket.chat/core-typings'; +import { LoginServiceConfiguration } from '@rocket.chat/models'; + +import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; +import { settings } from '../../../app/settings/server/cached'; +import { logger } from './logger'; + +export async function updateOAuthServices(): Promise { + const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); + const filteredServices = services.filter(([, value]) => typeof value === 'boolean'); + for await (const [key, value] of filteredServices) { + logger.debug({ oauth_updated: key }); + let serviceName = key.replace('Accounts_OAuth_', ''); + if (serviceName === 'Meteor') { + serviceName = 'meteor-developer'; + } + if (/Accounts_OAuth_Custom-/.test(key)) { + serviceName = key.replace('Accounts_OAuth_Custom-', ''); + } + + const serviceKey = serviceName.toLowerCase(); + + if (value === true) { + const data: Partial> = { + clientId: settings.get(`${key}_id`), + secret: settings.get(`${key}_secret`), + }; + + if (/Accounts_OAuth_Custom-/.test(key)) { + data.custom = true; + data.clientId = settings.get(`${key}-id`); + data.secret = settings.get(`${key}-secret`); + data.serverURL = settings.get(`${key}-url`); + data.tokenPath = settings.get(`${key}-token_path`); + data.identityPath = settings.get(`${key}-identity_path`); + data.authorizePath = settings.get(`${key}-authorize_path`); + data.scope = settings.get(`${key}-scope`); + data.accessTokenParam = settings.get(`${key}-access_token_param`); + data.buttonLabelText = settings.get(`${key}-button_label_text`); + data.buttonLabelColor = settings.get(`${key}-button_label_color`); + data.loginStyle = settings.get(`${key}-login_style`); + data.buttonColor = settings.get(`${key}-button_color`); + data.tokenSentVia = settings.get(`${key}-token_sent_via`); + data.identityTokenSentVia = settings.get(`${key}-identity_token_sent_via`); + data.keyField = settings.get(`${key}-key_field`); + data.usernameField = settings.get(`${key}-username_field`); + data.emailField = settings.get(`${key}-email_field`); + data.nameField = settings.get(`${key}-name_field`); + data.avatarField = settings.get(`${key}-avatar_field`); + data.rolesClaim = settings.get(`${key}-roles_claim`); + data.groupsClaim = settings.get(`${key}-groups_claim`); + data.channelsMap = settings.get(`${key}-groups_channel_map`); + data.channelsAdmin = settings.get(`${key}-channels_admin`); + data.mergeUsers = settings.get(`${key}-merge_users`); + data.mergeUsersDistinctServices = settings.get(`${key}-merge_users_distinct_services`); + data.mapChannels = settings.get(`${key}-map_channels`); + data.mergeRoles = settings.get(`${key}-merge_roles`); + data.rolesToSync = settings.get(`${key}-roles_to_sync`); + data.showButton = settings.get(`${key}-show_button`); + + new CustomOAuth(serviceKey, { + serverURL: data.serverURL, + tokenPath: data.tokenPath, + identityPath: data.identityPath, + authorizePath: data.authorizePath, + scope: data.scope, + loginStyle: data.loginStyle, + tokenSentVia: data.tokenSentVia, + identityTokenSentVia: data.identityTokenSentVia, + keyField: data.keyField, + usernameField: data.usernameField, + emailField: data.emailField, + nameField: data.nameField, + avatarField: data.avatarField, + rolesClaim: data.rolesClaim, + groupsClaim: data.groupsClaim, + mapChannels: data.mapChannels, + channelsMap: data.channelsMap, + channelsAdmin: data.channelsAdmin, + mergeUsers: data.mergeUsers, + mergeUsersDistinctServices: data.mergeUsersDistinctServices, + mergeRoles: data.mergeRoles, + rolesToSync: data.rolesToSync, + accessTokenParam: data.accessTokenParam, + showButton: data.showButton, + }); + } + if (serviceName === 'Facebook') { + (data as FacebookOAuthConfiguration).appId = data.clientId as string; + delete data.clientId; + } + if (serviceName === 'Twitter') { + (data as TwitterOAuthConfiguration).consumerKey = data.clientId as string; + delete data.clientId; + } + + if (serviceName === 'Linkedin') { + (data as LinkedinOAuthConfiguration).clientConfig = { + requestPermissions: ['openid', 'email', 'profile'], + }; + } + + if (serviceName === 'Nextcloud') { + data.buttonLabelText = settings.get('Accounts_OAuth_Nextcloud_button_label_text'); + data.buttonLabelColor = settings.get('Accounts_OAuth_Nextcloud_button_label_color'); + data.buttonColor = settings.get('Accounts_OAuth_Nextcloud_button_color'); + } + + await LoginServiceConfiguration.createOrUpdateService(serviceKey, data); + } else { + await LoginServiceConfiguration.removeService(serviceKey); + } + } +} diff --git a/apps/meteor/server/lib/refreshLoginServices.ts b/apps/meteor/server/lib/refreshLoginServices.ts new file mode 100644 index 000000000000..41f3b647f05e --- /dev/null +++ b/apps/meteor/server/lib/refreshLoginServices.ts @@ -0,0 +1,11 @@ +import { ServiceConfiguration } from 'meteor/service-configuration'; + +import { loadSamlServiceProviders } from '../../app/meteor-accounts-saml/server/lib/settings'; +import { updateCasServices } from './cas/updateCasService'; +import { updateOAuthServices } from './oauth/updateOAuthServices'; + +export async function refreshLoginServices(): Promise { + await ServiceConfiguration.configurations.removeAsync({}); + + await Promise.allSettled([updateOAuthServices(), loadSamlServiceProviders(), updateCasServices()]); +} diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index b26a48ee3150..888b84a7807c 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -5,10 +5,11 @@ import './models/startup'; * and the startup should be done in parallel */ import './settings'; +import '../app/lib/server/startup'; -import { libStartup } from '../app/lib/server/startup'; import { startLicense } from '../ee/app/license/server/startup'; import { registerEEBroker } from '../ee/server'; +import { configureLoginServices } from './configuration'; import { configureLogLevel } from './configureLogLevel'; import { registerServices } from './services/startup'; import { startup } from './startup'; @@ -23,17 +24,13 @@ await import('../lib/oauthRedirectUriServer'); await import('./lib/pushConfig'); -await import('./configuration/accounts_meld'); -await import('./configuration/cas'); -await import('./configuration/ldap'); - await import('./stream/stdout'); await import('./features/EmailInbox/index'); -await libStartup(); await configureLogLevel(); await registerServices(); await import('../app/settings/server'); +await configureLoginServices(); await registerEEBroker(); await startup(); await startLicense(); diff --git a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts index 46e54dcfcce3..98b14d2c1947 100644 --- a/apps/meteor/server/models/raw/LoginServiceConfiguration.ts +++ b/apps/meteor/server/models/raw/LoginServiceConfiguration.ts @@ -1,6 +1,6 @@ import type { LoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { ILoginServiceConfigurationModel } from '@rocket.chat/model-typings'; -import type { Collection, Db } from 'mongodb'; +import type { Collection, Db, DeleteResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -13,4 +13,40 @@ export class LoginServiceConfigurationRaw extends BaseRaw, + ): Promise { + const service = serviceName.toLowerCase(); + + const existing = await this.findOne({ service }); + if (!existing) { + const insertResult = await this.insertOne({ + service, + ...serviceData, + }); + + return insertResult.insertedId; + } + + if (Object.keys(serviceData).length > 0) { + await this.updateOne( + { + _id: existing._id, + }, + { + $set: serviceData, + }, + ); + } + + return existing._id; + } + + async removeService(serviceName: string): Promise { + const service = serviceName.toLowerCase(); + + return this.deleteOne({ service }); + } } diff --git a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts index ae8673bdc6e7..5a26607eda06 100644 --- a/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts +++ b/packages/model-typings/src/models/ILoginServiceConfigurationModel.ts @@ -1,8 +1,10 @@ import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; +import type { DeleteResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ILoginServiceConfigurationModel extends IBaseModel { - // + createOrUpdateService(serviceName: string, serviceData: Partial): Promise; + removeService(serviceName: string): Promise; } diff --git a/packages/ui-contexts/src/AuthenticationContext.tsx b/packages/ui-contexts/src/AuthenticationContext.ts similarity index 92% rename from packages/ui-contexts/src/AuthenticationContext.tsx rename to packages/ui-contexts/src/AuthenticationContext.ts index d4a448eecd15..3892be3dc631 100644 --- a/packages/ui-contexts/src/AuthenticationContext.tsx +++ b/packages/ui-contexts/src/AuthenticationContext.ts @@ -2,15 +2,15 @@ import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; import { createContext } from 'react'; export type LoginService = LoginServiceConfiguration & { - title?: string; icon?: string; + title?: string; }; export type AuthenticationContextValue = { loginWithPassword: (user: string | { username: string } | { email: string } | { id: string }, password: string) => Promise; loginWithToken: (user: string) => Promise; - loginWithService(service: T): () => Promise; + loginWithService(service: T): () => Promise; queryLoginServices: { getCurrentValue: () => LoginService[]; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index e34ddd869431..0870eb4417c8 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -1,5 +1,5 @@ export { AttachmentContext, AttachmentContextValue } from './AttachmentContext'; -export { AuthenticationContext, AuthenticationContextValue, LoginService } from './AuthenticationContext'; +export { AuthenticationContextValue, AuthenticationContext, LoginService } from './AuthenticationContext'; export { AuthorizationContext, AuthorizationContextValue } from './AuthorizationContext'; export { AvatarUrlContext, AvatarUrlContextValue } from './AvatarUrlContext'; export { ConnectionStatusContext, ConnectionStatusContextValue } from './ConnectionStatusContext';