diff --git a/app/2fa/server/code/PasswordCheckFallback.ts b/app/2fa/server/code/PasswordCheckFallback.ts index aef70ba6442..ad11f027116 100644 --- a/app/2fa/server/code/PasswordCheckFallback.ts +++ b/app/2fa/server/code/PasswordCheckFallback.ts @@ -1,5 +1,6 @@ import { Accounts } from 'meteor/accounts-base'; +import { settings } from '../../../settings/server'; import { ICodeCheck, IProcessInvalidCodeResult } from './ICodeCheck'; import { IUser } from '../../../../definition/IUser'; @@ -7,10 +8,12 @@ export class PasswordCheckFallback implements ICodeCheck { public readonly name = 'password'; public isEnabled(user: IUser): boolean { - // TODO: Uncomment for version 4.0 forcing the + // TODO: Remove this setting for version 4.0 forcing the // password fallback for who has password set. - // return user.services?.password?.bcrypt != null; - return !user; + if (settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + return user.services?.password?.bcrypt != null; + } + return false; } public verify(user: IUser, code: string): boolean { diff --git a/app/2fa/server/startup/settings.js b/app/2fa/server/startup/settings.js index 621c2f18669..6e70e7de1ed 100644 --- a/app/2fa/server/startup/settings.js +++ b/app/2fa/server/startup/settings.js @@ -37,8 +37,14 @@ settings.addGroup('Accounts', function() { }, }); - this.add('Accounts_TwoFactorAuthentication_RememberFor', 300, { + this.add('Accounts_TwoFactorAuthentication_RememberFor', 1800, { type: 'int', }); + + // TODO: Remove this setting for version 4.0 + this.add('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback', true, { + type: 'boolean', + public: true, + }); }); }); diff --git a/app/api/server/api.js b/app/api/server/api.js index daf43096029..d717396a498 100644 --- a/app/api/server/api.js +++ b/app/api/server/api.js @@ -331,8 +331,14 @@ export class APIClass extends Restivus { routes.forEach((route) => { // Note: This is required due to Restivus calling `addRoute` in the constructor of itself Object.keys(endpoints).forEach((method) => { + const _options = { ...options }; + if (typeof endpoints[method] === 'function') { endpoints[method] = { action: endpoints[method] }; + } else { + const extraOptions = { ...endpoints[method] }; + delete extraOptions.action; + Object.assign(_options, extraOptions); } // Add a try/catch for each endpoint const originalAction = endpoints[method].action; @@ -364,9 +370,9 @@ export class APIClass extends Restivus { try { api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId); - if (shouldVerifyPermissions && (!this.userId || !hasAllPermission(this.userId, options.permissionsRequired))) { + if (shouldVerifyPermissions && (!this.userId || !hasAllPermission(this.userId, _options.permissionsRequired))) { throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', { - permissions: options.permissionsRequired, + permissions: _options.permissionsRequired, }); } @@ -381,8 +387,8 @@ export class APIClass extends Restivus { }; Accounts._setAccountData(connection.id, 'loginToken', this.token); - if (options.twoFactorRequired) { - api.processTwoFactor({ userId: this.userId, request: this.request, invocation, options: options.twoFactorOptions, connection }); + if (_options.twoFactorRequired) { + api.processTwoFactor({ userId: this.userId, request: this.request, invocation, options: _options.twoFactorOptions, connection }); } result = DDP._CurrentInvocation.withValue(invocation, () => originalAction.apply(this)); diff --git a/app/api/server/v1/settings.js b/app/api/server/v1/settings.js index 6cad33dca4b..e323a635ddf 100644 --- a/app/api/server/v1/settings.js +++ b/app/api/server/v1/settings.js @@ -72,7 +72,7 @@ API.v1.addRoute('settings.oauth', { authRequired: false }, { }, }); -API.v1.addRoute('settings.addCustomOAuth', { authRequired: true }, { +API.v1.addRoute('settings.addCustomOAuth', { authRequired: true, twoFactorRequired: true }, { post() { if (!this.requestParams().name || !this.requestParams().name.trim()) { throw new Meteor.Error('error-name-param-not-provided', 'The parameter "name" is required'); @@ -121,33 +121,36 @@ API.v1.addRoute('settings/:_id', { authRequired: true }, { return API.v1.success(_.pick(Settings.findOneNotHiddenById(this.urlParams._id), '_id', 'value')); }, - post() { - if (!hasPermission(this.userId, 'edit-privileged-setting')) { - return API.v1.unauthorized(); - } - - // allow special handling of particular setting types - const setting = Settings.findOneNotHiddenById(this.urlParams._id); - if (setting.type === 'action' && this.bodyParams && this.bodyParams.execute) { - // execute the configured method - Meteor.call(setting.value); - return API.v1.success(); - } - - if (setting.type === 'color' && this.bodyParams && this.bodyParams.editor && this.bodyParams.value) { - Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); - Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); - return API.v1.success(); - } - - check(this.bodyParams, { - value: Match.Any, - }); - if (Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { - return API.v1.success(); - } + post: { + twoFactorRequired: true, + action() { + if (!hasPermission(this.userId, 'edit-privileged-setting')) { + return API.v1.unauthorized(); + } + + // allow special handling of particular setting types + const setting = Settings.findOneNotHiddenById(this.urlParams._id); + if (setting.type === 'action' && this.bodyParams && this.bodyParams.execute) { + // execute the configured method + Meteor.call(setting.value); + return API.v1.success(); + } + + if (setting.type === 'color' && this.bodyParams && this.bodyParams.editor && this.bodyParams.value) { + Settings.updateOptionsById(this.urlParams._id, { editor: this.bodyParams.editor }); + Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value); + return API.v1.success(); + } + + check(this.bodyParams, { + value: Match.Any, + }); + if (Settings.updateValueNotHiddenById(this.urlParams._id, this.bodyParams.value)) { + return API.v1.success(); + } - return API.v1.failure(); + return API.v1.failure(); + }, }, }); diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index c9c31fb006c..c41ff7e90d9 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -21,6 +21,7 @@ import { API } from '../api'; import { setStatusText } from '../../../lib/server'; import { findUsersToAutocomplete } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; +import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; API.v1.addRoute('users.create', { authRequired: true }, { post() { @@ -786,3 +787,33 @@ API.v1.addRoute('users.removeOtherTokens', { authRequired: true }, { API.v1.success(Meteor.call('removeOtherTokens')); }, }); + +API.v1.addRoute('users.resetE2EKey', { authRequired: true, twoFactorRequired: true }, { + post() { + // reset own keys + if (this.isUserFromParams()) { + resetUserE2EEncriptionKey(this.userId); + return API.v1.success(); + } + + // reset other user keys + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } + + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + if (!hasPermission(Meteor.userId(), 'edit-other-user-e2ee')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + + if (!resetUserE2EEncriptionKey(user._id)) { + return API.v1.failure(); + } + + return API.v1.success(); + }, +}); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index b32144f0114..9bc311170a6 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -42,6 +42,7 @@ Meteor.startup(function() { { _id: 'edit-other-user-info', roles: ['admin'] }, { _id: 'edit-other-user-password', roles: ['admin'] }, { _id: 'edit-other-user-avatar', roles: ['admin'] }, + { _id: 'edit-other-user-e2ee', roles: ['admin'] }, { _id: 'edit-privileged-setting', roles: ['admin'] }, { _id: 'edit-room', roles: ['admin', 'owner', 'moderator'] }, { _id: 'edit-room-avatar', roles: ['admin', 'owner', 'moderator'] }, diff --git a/app/e2e/server/methods/resetOwnE2EKey.js b/app/e2e/server/methods/resetOwnE2EKey.js index 8286d5ab275..9447df12bba 100644 --- a/app/e2e/server/methods/resetOwnE2EKey.js +++ b/app/e2e/server/methods/resetOwnE2EKey.js @@ -1,9 +1,10 @@ import { Meteor } from 'meteor/meteor'; -import { Users, Subscriptions } from '../../../models'; +import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; +import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; Meteor.methods({ - 'e2e.resetOwnE2EKey'() { + 'e2e.resetOwnE2EKey': twoFactorRequired(function() { const userId = Meteor.userId(); if (!userId) { @@ -12,11 +13,9 @@ Meteor.methods({ }); } - Users.resetE2EKey(userId); - Subscriptions.resetUserE2EKey(userId); - - // Force the user to logout, so that the keys can be generated again - Users.removeResumeService(userId); + if (!resetUserE2EEncriptionKey(userId)) { + return false; + } return true; - }, + }), }); diff --git a/app/lib/server/methods/saveSetting.js b/app/lib/server/methods/saveSetting.js index e388f277d4b..e8270d35835 100644 --- a/app/lib/server/methods/saveSetting.js +++ b/app/lib/server/methods/saveSetting.js @@ -5,9 +5,10 @@ import { hasPermission, hasAllPermission } from '../../../authorization/server'; import { getSettingPermissionId } from '../../../authorization/lib'; import { settings } from '../../../settings'; import { Settings } from '../../../models'; +import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; Meteor.methods({ - saveSetting(_id, value, editor) { + saveSetting: twoFactorRequired(function(_id, value, editor) { const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { @@ -46,5 +47,5 @@ Meteor.methods({ settings.updateById(_id, value, editor); return true; - }, + }), }); diff --git a/app/lib/server/methods/saveSettings.js b/app/lib/server/methods/saveSettings.js index 97c12bd85ca..d46bca64aac 100644 --- a/app/lib/server/methods/saveSettings.js +++ b/app/lib/server/methods/saveSettings.js @@ -5,9 +5,10 @@ import { hasPermission } from '../../../authorization'; import { settings } from '../../../settings'; import { Settings } from '../../../models'; import { getSettingPermissionId } from '../../../authorization/lib'; +import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; Meteor.methods({ - saveSettings(params = []) { + saveSettings: twoFactorRequired(function(params = []) { const uid = Meteor.userId(); const settingsNotAllowed = []; if (uid === null) { @@ -56,5 +57,5 @@ Meteor.methods({ params.forEach(({ _id, value, editor }) => settings.updateById(_id, value, editor)); return true; - }, + }), }); diff --git a/client/admin/users/UserInfoActions.js b/client/admin/users/UserInfoActions.js index 0070586a3ee..a6dce0a970c 100644 --- a/client/admin/users/UserInfoActions.js +++ b/client/admin/users/UserInfoActions.js @@ -12,7 +12,7 @@ import { useSetting } from '../../contexts/SettingsContext'; import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext'; import { useTranslation } from '../../contexts/TranslationContext'; -const DeleteWarningModal = ({ onDelete, onCancel, erasureType, ...props }) => { +const ConfirmWarningModal = ({ onConfirm, onCancel, confirmText, text, ...props }) => { const t = useTranslation(); return @@ -22,27 +22,27 @@ const DeleteWarningModal = ({ onDelete, onCancel, erasureType, ...props }) => { - {t(`Delete_User_Warning_${ erasureType }`)} + {text} - + ; }; -const SuccessModal = ({ onClose, ...props }) => { +const SuccessModal = ({ onClose, title, text, ...props }) => { const t = useTranslation(); return - {t('Deleted')} + {title} - {t('User_has_been_deleted')} + {text} @@ -63,9 +63,12 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) const canDirectMessage = usePermission('create-d'); const canEditOtherUserInfo = usePermission('edit-other-user-info'); const canAssignAdminRole = usePermission('assign-admin-role'); + const canResetE2EEKey = usePermission('edit-other-user-e2ee'); const canEditOtherUserActiveStatus = usePermission('edit-other-user-active-status'); const canDeleteUser = usePermission('delete-user'); + const enforcePassword = useSetting('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback'); + const confirmOwnerChanges = (action, modalProps = {}) => async () => { try { return await action(); @@ -100,7 +103,7 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) const result = await deleteUserEndpoint(deleteUserQuery); if (result.success) { - setModal( { setModal(); onChange(); }}/>); + setModal( { setModal(); onChange(); }}/>); } else { setModal(); } @@ -110,8 +113,8 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) }); const confirmDeleteUser = useCallback(() => { - setModal( setModal()} erasureType={erasureType}/>); - }, [deleteUser, erasureType, setModal]); + setModal( setModal()} text={t(`Delete_User_Warning_${ erasureType }`)} confirmText={t('Delete')} />); + }, [deleteUser, erasureType, setModal, t]); const setAdminStatus = useMethod('setAdminStatus'); const changeAdminStatus = useCallback(() => { @@ -125,6 +128,20 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) } }, [_id, dispatchToastMessage, isAdmin, onChange, setAdminStatus, t]); + const resetE2EEKeyRequest = useEndpoint('POST', 'users.resetE2EKey'); + const resetE2EEKey = useCallback(async () => { + setModal(); + const result = await resetE2EEKeyRequest({ userId: _id }); + + if (result) { + setModal( { setModal(); onChange(); }}/>); + } + }, [resetE2EEKeyRequest, onChange, setModal, t, _id]); + + const confirmResetE2EEKey = useCallback(() => { + setModal( setModal()} text={t('E2E_Reset_Other_Key_Warning')} confirmText={t('Reset')} />); + }, [resetE2EEKey, t, setModal]); + const activeStatusQuery = useMemo(() => ({ userId: _id, activeStatus: !isActive, @@ -175,6 +192,11 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) label: isAdmin ? t('Remove_Admin') : t('Make_Admin'), action: changeAdminStatus, } }, + ...canResetE2EEKey && enforcePassword && { resetE2EEKey: { + icon: 'key', + label: t('Reset_E2E_Key'), + action: confirmResetE2EEKey, + } }, ...canDeleteUser && { delete: { icon: 'trash', label: t('Delete'), @@ -199,6 +221,9 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange }) canEditOtherUserActiveStatus, isActive, changeActiveStatus, + enforcePassword, + canResetE2EEKey, + confirmResetE2EEKey, ]); const { actions: actionsDefinition, menu: menuOptions } = useUserInfoActionsSpread(options); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index b85c9514a8a..633efbc1874 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -236,6 +236,8 @@ "Accounts_TwoFactorAuthentication_By_Email_Code_Expiration": "Time to expire the code sent via email in seconds", "Accounts_TwoFactorAuthentication_Enabled": "Enable Two Factor Authentication via TOTP", "Accounts_TwoFactorAuthentication_Enabled_Description": "Users can setup their Two Factor Authentication using any TOTP app, like Google Authenticator or Authy", + "Accounts_TwoFactorAuthentication_Enforce_Password_Fallback": "Enforce password fallback", + "Accounts_TwoFactorAuthentication_Enforce_Password_Fallback_Description": "Users will be forced to enter their password, for important actions, if no other Two Factor Authentication method is enabled for that user and a password is set for him.", "Accounts_TwoFactorAuthentication_MaxDelta": "Maximum Delta", "Accounts_UserAddedEmail_Default": "

Welcome to [Site_Name]

Go to [Site_URL] and try the best open source chat solution available today!

You may login using your email: [email] and password: [password]. You may be required to change it after your first login.", "Accounts_TwoFactorAuthentication_MaxDelta_Description": "The Maximum Delta determines how many tokens are valid at any given time. Tokens are generated every 30 seconds, and are valid for (30 * Maximum Delta) seconds.
Example: With a Maximum Delta set to 10, each token can be used up to 300 seconds before or after it's timestamp. This is useful when the client's clock is not properly synced with the server.", @@ -1316,6 +1318,7 @@ "E2E_password_reveal_text": "You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted.

This is end to end encryption so the key to encode/decode your messages will not be saved on the server. For that reason you need to store this password somewhere safe. You will be required to enter it on other devices you wish to use e2e encryption on. Learn more here!

Your password is: %s

This is an auto generated password, you can setup a new password for your encryption key any time from any browser you have entered the existing password.
This password is only stored on this browser until you store the password and dismiss this message.", "E2E_password_request_text": "To access your encrypted private groups and direct messages, enter your encryption password.
You need to enter this password to encode/decode your messages on every client you use, since the key is not stored on the server.", "E2E_Reset_Key_Explanation": "This option will remove your current E2E key and log you out.
When you login again, Rocket.Chat will generate you a new key and restore your access to any encrypted room that has one or more members online.
Due to the nature of the E2E encryption, Rocket.Chat will not be able to restore access to any encrypted room that has no member online.", + "E2E_Reset_Other_Key_Warning": "Reset the current E2E key will log out the user. When the user login again, Rocket.Chat will generate a new key and restore the user access to any encrypted room that has one or more members online. Due to the nature of the E2E encryption, Rocket.Chat will not be able to restore access to any encrypted room that has no member online.", "Edit": "Edit", "Edit_User": "Edit User", "Edit_Invite": "Edit Invite", @@ -1330,6 +1333,8 @@ "edit-other-user-info_description": "Permission to change other user's name, username or email address.", "edit-other-user-password": "Edit Other User Password", "edit-other-user-password_description": "Permission to modify other user's passwords. Requires edit-other-user-info permission.", + "edit-other-user-e2ee": "Edit Other User E2E Encryption", + "edit-other-user-e2ee_description": "Permission to modify other user's E2E Encryption.", "edit-privileged-setting": "Edit privileged Setting", "edit-privileged-setting_description": "Permission to edit settings", "edit-room": "Edit Room", @@ -3792,6 +3797,7 @@ "Users_by_time_of_day": "Users by time of day", "Users_in_role": "Users in role", "Users must use Two Factor Authentication": "Users must use Two Factor Authentication", + "Users_key_has_been_reset": "User's key has been reset", "Leave_the_description_field_blank_if_you_dont_want_to_show_the_role": "Leave the description field blank if you don't want to show the role", "Uses": "Uses", "Uses_left": "Uses left", diff --git a/server/lib/resetUserE2EKey.ts b/server/lib/resetUserE2EKey.ts new file mode 100644 index 00000000000..879d9417b79 --- /dev/null +++ b/server/lib/resetUserE2EKey.ts @@ -0,0 +1,11 @@ +import { Users, Subscriptions } from '../../app/models/server'; + +export function resetUserE2EEncriptionKey(uid: string): boolean { + Users.resetE2EKey(uid); + Subscriptions.resetUserE2EKey(uid); + + // Force the user to logout, so that the keys can be generated again + Users.removeResumeService(uid); + + return true; +} diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 78f747f2173..8b7c280b2ae 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -201,4 +201,5 @@ import './v201'; import './v202'; import './v203'; import './v204'; +import './v205'; import './xrun'; diff --git a/server/startup/migrations/v205.js b/server/startup/migrations/v205.js new file mode 100644 index 00000000000..69143a62775 --- /dev/null +++ b/server/startup/migrations/v205.js @@ -0,0 +1,16 @@ +import { Migrations } from '../../../app/migrations'; +import { Settings } from '../../../app/models/server'; + +Migrations.add({ + version: 205, + up() { + // Disable this new enforcement setting for existent installations. + Settings.upsert({ + _id: 'Accounts_TwoFactorAuthentication_Enforce_Password_Fallback', + }, { + $set: { + value: false, + }, + }); + }, +});