diff --git a/.changeset/tiny-geckos-kiss.md b/.changeset/tiny-geckos-kiss.md new file mode 100644 index 000000000000..d38150970310 --- /dev/null +++ b/.changeset/tiny-geckos-kiss.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Added a new setting which allows workspace admins to disable email two factor authentication for SSO (OAuth) users. If enabled, SSO users won't be asked for email two factor authentication. diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts b/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts new file mode 100644 index 000000000000..5c3574f0b395 --- /dev/null +++ b/apps/meteor/app/2fa/server/code/EmailCheck.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const settingsMock = sinon.stub(); + +const { EmailCheck } = proxyquire.noCallThru().load('./EmailCheck', { + '@rocket.chat/models': { + Users: {}, + }, + 'meteor/accounts-base': { + Accounts: { + _bcryptRounds: () => '123', + }, + }, + '../../../../server/lib/i18n': { + i18n: { + t: (key: string) => key, + }, + }, + '../../../mailer/server/api': { + send: () => undefined, + }, + '../../../settings/server': { + settings: { + get: settingsMock, + }, + }, +}); + +const normalUserMock = { services: { email2fa: { enabled: true } }, emails: [{ email: 'abc@gmail.com', verified: true }] }; +const normalUserWithUnverifiedEmailMock = { + services: { email2fa: { enabled: true } }, + emails: [{ email: 'abc@gmail.com', verified: false }], +}; +const OAuthUserMock = { services: { google: {} }, emails: [{ email: 'abc@gmail.com', verified: true }] }; + +describe('EmailCheck', () => { + let emailCheck: typeof EmailCheck; + beforeEach(() => { + settingsMock.reset(); + + emailCheck = new EmailCheck(); + }); + + it('should return EmailCheck is enabled for a normal user', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserMock); + + expect(isEmail2FAEnabled).to.be.equal(true); + }); + + it('should return EmailCheck is not enabled for a normal user with unverified email', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(normalUserWithUnverifiedEmailMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); + + it('should return EmailCheck is not enabled for a OAuth user with setting being false', () => { + settingsMock.returns(true); + + const isEmail2FAEnabled = emailCheck.isEnabled(OAuthUserMock); + + expect(isEmail2FAEnabled).to.be.equal(false); + }); +}); diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index 123df96ee264..d947c1b30c2e 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import bcrypt from 'bcrypt'; @@ -24,6 +24,10 @@ export class EmailCheck implements ICodeCheck { return false; } + if (!settings.get('Accounts_twoFactorAuthentication_email_available_for_OAuth_users') && isOAuthUser(user)) { + return false; + } + if (!user.services?.email2fa?.enabled) { return false; } diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 1fbe658e5682..b05157416e31 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -45,14 +45,10 @@ function getAvailableMethodNames(user: IUser): string[] { export async function getUserForCheck(userId: string): Promise { return Users.findOneById(userId, { projection: { - 'emails': 1, - 'language': 1, - 'createdAt': 1, - 'services.totp': 1, - 'services.email2fa': 1, - 'services.emailCode': 1, - 'services.password': 1, - 'services.resume.loginTokens': 1, + emails: 1, + language: 1, + createdAt: 1, + services: 1, }, }); } diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index dd4da47bff05..8348b8429e4e 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import type { IUser } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IUser } from '@rocket.chat/core-typings'; import { Settings, Users } from '@rocket.chat/models'; import { isShieldSvgProps, @@ -26,7 +26,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { passwordPolicy } from '../../../lib/server'; import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; -import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; +import { getBaseUserFields } from '../../../utils/server/functions/getBaseUserFields'; import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; @@ -176,15 +176,19 @@ API.v1.addRoute( { authRequired: true }, { async get() { - const fields = getDefaultUserFields(); - const { services, ...user } = (await Users.findOneById(this.userId, { projection: fields })) as IUser; + const userFields = { ...getBaseUserFields(), services: 1 }; + const { services, ...user } = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; return API.v1.success( await getUserInfo({ ...user, + isOAuthUser: isOAuthUser({ ...user, services }), ...(services && { services: { - ...services, + ...(services.github && { github: services.github }), + ...(services.gitlab && { gitlab: services.gitlab }), + ...(services.email2fa?.enabled && { email2fa: { enabled: services.email2fa.enabled } }), + ...(services.totp?.enabled && { totp: { enabled: services.totp.enabled } }), password: { // The password hash shouldn't be leaked but the client may need to know if it exists. exists: Boolean(services?.password?.bcrypt), diff --git a/apps/meteor/app/utils/server/functions/getBaseUserFields.ts b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts new file mode 100644 index 000000000000..5e2a3bf2b4d7 --- /dev/null +++ b/apps/meteor/app/utils/server/functions/getBaseUserFields.ts @@ -0,0 +1,34 @@ +type UserFields = { + [k: string]: number; +}; + +export const getBaseUserFields = (): UserFields => ({ + 'name': 1, + 'username': 1, + 'nickname': 1, + 'emails': 1, + 'status': 1, + 'statusDefault': 1, + 'statusText': 1, + 'statusConnection': 1, + 'bio': 1, + 'avatarOrigin': 1, + 'utcOffset': 1, + 'language': 1, + 'settings': 1, + 'enableAutoAway': 1, + 'idleTimeLimit': 1, + 'roles': 1, + 'active': 1, + 'defaultRoom': 1, + 'customFields': 1, + 'requirePasswordChange': 1, + 'requirePasswordChangeReason': 1, + 'statusLivechat': 1, + 'banners': 1, + 'oauth.authorizedClients': 1, + '_updatedAt': 1, + 'avatarETag': 1, + 'extension': 1, + 'openBusinessHours': 1, +}); diff --git a/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts b/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts index 03d0cae77ab9..293eb8607342 100644 --- a/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts +++ b/apps/meteor/app/utils/server/functions/getDefaultUserFields.ts @@ -1,39 +1,14 @@ -type DefaultUserFields = { +import { getBaseUserFields } from './getBaseUserFields'; + +type UserFields = { [k: string]: number; }; -export const getDefaultUserFields = (): DefaultUserFields => ({ - 'name': 1, - 'username': 1, - 'nickname': 1, - 'emails': 1, - 'status': 1, - 'statusDefault': 1, - 'statusText': 1, - 'statusConnection': 1, - 'bio': 1, - 'avatarOrigin': 1, - 'utcOffset': 1, - 'language': 1, - 'settings': 1, - 'enableAutoAway': 1, - 'idleTimeLimit': 1, - 'roles': 1, - 'active': 1, - 'defaultRoom': 1, - 'customFields': 1, - 'requirePasswordChange': 1, - 'requirePasswordChangeReason': 1, +export const getDefaultUserFields = (): UserFields => ({ + ...getBaseUserFields(), 'services.github': 1, 'services.gitlab': 1, 'services.password.bcrypt': 1, 'services.totp.enabled': 1, 'services.email2fa.enabled': 1, - 'statusLivechat': 1, - 'banners': 1, - 'oauth.authorizedClients': 1, - '_updatedAt': 1, - 'avatarETag': 1, - 'extension': 1, - 'openBusinessHours': 1, }); diff --git a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx index 536ba8a04ef7..06619f0618f5 100644 --- a/apps/meteor/client/views/account/security/AccountSecurityPage.tsx +++ b/apps/meteor/client/views/account/security/AccountSecurityPage.tsx @@ -1,6 +1,6 @@ import { Box, Accordion, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -15,6 +15,11 @@ const passwordDefaultValues = { password: '', confirmationPassword: '' }; const AccountSecurityPage = (): ReactElement => { const t = useTranslation(); + const user = useUser(); + + const isEmail2FAAvailableForOAuth = useSetting('Accounts_twoFactorAuthentication_email_available_for_OAuth_users'); + const isOAuthUser = user?.isOAuthUser; + const isEmail2FAAllowed = !isOAuthUser || isEmail2FAAvailableForOAuth; const methods = useForm({ defaultValues: passwordDefaultValues, @@ -30,6 +35,7 @@ const AccountSecurityPage = (): ReactElement => { const twoFactorByEmailEnabled = useSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled'); const e2eEnabled = useSetting('E2E_Enable'); const allowPasswordChange = useSetting('Accounts_AllowPasswordChange'); + const showEmailTwoFactor = twoFactorByEmailEnabled && isEmail2FAAllowed; const passwordFormId = useUniqueId(); @@ -48,10 +54,10 @@ const AccountSecurityPage = (): ReactElement => { )} - {(twoFactorTOTP || twoFactorByEmailEnabled) && twoFactorEnabled && ( + {(twoFactorTOTP || showEmailTwoFactor) && twoFactorEnabled && ( {twoFactorTOTP && } - {twoFactorByEmailEnabled && } + {showEmailTwoFactor && } )} {e2eEnabled && ( diff --git a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx index c890dc61e658..3654d17b5dec 100644 --- a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx @@ -1,11 +1,11 @@ import { Box, Button, Margins } from '@rocket.chat/fuselage'; import { useUser, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, ReactElement } from 'react'; +import type { ComponentProps } from 'react'; import React, { useCallback } from 'react'; import { useEndpointAction } from '../../../hooks/useEndpointAction'; -const TwoFactorEmail = (props: ComponentProps): ReactElement => { +const TwoFactorEmail = (props: ComponentProps) => { const t = useTranslation(); const user = useUser(); diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index a744c47b2a41..b4da1cd913e9 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -31,6 +31,18 @@ export const createAccountSettings = () => public: true, }); + await this.add('Accounts_twoFactorAuthentication_email_available_for_OAuth_users', true, { + type: 'boolean', + enableQuery: [ + enable2FA, + { + _id: 'Accounts_TwoFactorAuthentication_By_Email_Enabled', + value: true, + }, + ], + public: true, + }); + await this.add('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In', true, { type: 'boolean', enableQuery: [ diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index fa411c6f7e47..d6854bef7243 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -45,7 +45,35 @@ export type ILoginUsername = }; export type LoginUsername = string | ILoginUsername; -export interface IUserServices { +export interface IOAuthUserServices { + google?: any; + facebook?: any; + github?: any; + linkedin?: any; + twitter?: any; + gitlab?: any; + saml?: { + inResponseTo?: string; + provider?: string; + idp?: string; + idpSession?: string; + nameID?: string; + }; + ldap?: { + id: string; + idAttribute?: string; + }; + nextcloud?: { + accessToken: string; + refreshToken: string; + serverURL: string; + }; + dolphin?: { + NickName?: string; + }; +} + +export interface IUserServices extends IOAuthUserServices { password?: { exists?: boolean; bcrypt?: string; @@ -62,12 +90,6 @@ export interface IUserServices { refreshToken: string; expiresAt: Date; }; - google?: any; - facebook?: any; - github?: any; - linkedin?: any; - twitter?: any; - gitlab?: any; totp?: { enabled: boolean; hashedBackup: string[]; @@ -79,27 +101,37 @@ export interface IUserServices { changedAt: Date; }; emailCode?: IUserEmailCode; - saml?: { - inResponseTo?: string; - provider?: string; - idp?: string; - idpSession?: string; - nameID?: string; - }; - ldap?: { - id: string; - idAttribute?: string; - }; - nextcloud?: { - accessToken: string; - refreshToken: string; - serverURL: string; - }; - dolphin?: { - NickName?: string; - }; } +type IUserService = keyof IUserServices; +type IOAuthService = keyof IOAuthUserServices; + +const defaultOAuthKeys = [ + 'google', + 'dolphin', + 'facebook', + 'github', + 'gitlab', + 'google', + 'ldap', + 'linkedin', + 'nextcloud', + 'saml', + 'twitter', +] as IOAuthService[]; +const userServiceKeys = ['emailCode', 'email2fa', 'totp', 'resume', 'password', 'passwordHistory', 'cloud', 'email'] as IUserService[]; + +export const isUserServiceKey = (key: string): key is IUserService => + userServiceKeys.includes(key as IUserService) || defaultOAuthKeys.includes(key as IOAuthService); + +export const isDefaultOAuthUser = (user: IUser): boolean => + !!user.services && Object.keys(user.services).some((key) => defaultOAuthKeys.includes(key as IOAuthService)); + +export const isCustomOAuthUser = (user: IUser): boolean => + !!user.services && Object.keys(user.services).some((key) => !isUserServiceKey(key)); + +export const isOAuthUser = (user: IUser): boolean => isDefaultOAuthUser(user) || isCustomOAuthUser(user); + export interface IUserEmail { address: string; verified?: boolean; @@ -183,6 +215,7 @@ export interface IUser extends IRocketChatRecord { _pendingAvatarUrl?: string; requirePasswordChange?: boolean; requirePasswordChangeReason?: string; + isOAuthUser?: boolean; // client only field } export interface IRegisterUser extends IUser { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index d3324c6e749a..e6867528ad4c 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -282,6 +282,8 @@ "Accounts_TwoFactorAuthentication_By_Email_Code_Expiration": "Time to expire the code sent via email in seconds", "Accounts_TwoFactorAuthentication_By_Email_Enabled": "Enable Two Factor Authentication via Email", "Accounts_TwoFactorAuthentication_By_Email_Enabled_Description": "Users with email verified and the option enabled in their profile page will receive an email with a temporary code to authorize certain actions like login, save the profile, etc.", + "Accounts_twoFactorAuthentication_email_available_for_OAuth_users": "Make two factor via email available for oAuth users", + "Accounts_twoFactorAuthentication_email_available_for_OAuth_users_Description": "People that use oAuth will receive an email with a temporary code to authorize actions like login, save profile, etc.", "Accounts_TwoFactorAuthentication_Enabled": "Enable Two Factor Authentication", "Accounts_TwoFactorAuthentication_Enabled_Description": "If deactivated, this setting will deactivate all Two Factor Authentication. \nTo force users to use Two Factor Authentication, the admin has to configure the 'user' role to enforce it.", "Accounts_TwoFactorAuthentication_Enforce_Password_Fallback": "Enforce password fallback",