[IMPROVE] 2FA password enforcement setting and 2FA protection when saving settings or resetting E2E encryption (#18640)

Co-authored-by: Diego Sampaio <chinello@gmail.com>
pull/18438/head
Rodrigo Nascimento 5 years ago committed by GitHub
parent 0f2b3c25b7
commit fe7fe3c462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      app/2fa/server/code/PasswordCheckFallback.ts
  2. 8
      app/2fa/server/startup/settings.js
  3. 14
      app/api/server/api.js
  4. 57
      app/api/server/v1/settings.js
  5. 31
      app/api/server/v1/users.js
  6. 1
      app/authorization/server/startup.js
  7. 15
      app/e2e/server/methods/resetOwnE2EKey.js
  8. 5
      app/lib/server/methods/saveSetting.js
  9. 5
      app/lib/server/methods/saveSettings.js
  10. 43
      client/admin/users/UserInfoActions.js
  11. 6
      packages/rocketchat-i18n/i18n/en.i18n.json
  12. 11
      server/lib/resetUserE2EKey.ts
  13. 1
      server/startup/migrations/index.js
  14. 16
      server/startup/migrations/v205.js

@ -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 {

@ -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,
});
});
});

@ -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));

@ -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();
},
},
});

@ -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();
},
});

@ -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'] },

@ -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;
},
}),
});

@ -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;
},
}),
});

@ -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;
},
}),
});

@ -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 <Modal {...props}>
@ -22,27 +22,27 @@ const DeleteWarningModal = ({ onDelete, onCancel, erasureType, ...props }) => {
<Modal.Close onClick={onCancel}/>
</Modal.Header>
<Modal.Content fontScale='p1'>
{t(`Delete_User_Warning_${ erasureType }`)}
{text}
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
<Button ghost onClick={onCancel}>{t('Cancel')}</Button>
<Button primary danger onClick={onDelete}>{t('Delete')}</Button>
<Button primary danger onClick={onConfirm}>{confirmText}</Button>
</ButtonGroup>
</Modal.Footer>
</Modal>;
};
const SuccessModal = ({ onClose, ...props }) => {
const SuccessModal = ({ onClose, title, text, ...props }) => {
const t = useTranslation();
return <Modal {...props}>
<Modal.Header>
<Icon color='success' name='checkmark-circled' size={20}/>
<Modal.Title>{t('Deleted')}</Modal.Title>
<Modal.Title>{title}</Modal.Title>
<Modal.Close onClick={onClose}/>
</Modal.Header>
<Modal.Content fontScale='p1'>
{t('User_has_been_deleted')}
{text}
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
@ -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(<SuccessModal onClose={() => { setModal(); onChange(); }}/>);
setModal(<SuccessModal title={t('Deleted')} text={t('User_has_been_deleted')} onClose={() => { setModal(); onChange(); }}/>);
} else {
setModal();
}
@ -110,8 +113,8 @@ export const UserInfoActions = ({ username, _id, isActive, isAdmin, onChange })
});
const confirmDeleteUser = useCallback(() => {
setModal(<DeleteWarningModal onDelete={deleteUser} onCancel={() => setModal()} erasureType={erasureType}/>);
}, [deleteUser, erasureType, setModal]);
setModal(<ConfirmWarningModal onConfirm={deleteUser} onCancel={() => 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(<SuccessModal title={t('Success')} text={t('Users_key_has_been_reset')} onClose={() => { setModal(); onChange(); }}/>);
}
}, [resetE2EEKeyRequest, onChange, setModal, t, _id]);
const confirmResetE2EEKey = useCallback(() => {
setModal(<ConfirmWarningModal onConfirm={resetE2EEKey} onCancel={() => 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);

@ -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": "<h1>Welcome to <strong>[Site_Name]</strong></h1><p>Go to <a href=\"[Site_URL]\">[Site_URL]</a> and try the best open source chat solution available today!</p><p>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. <br/>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.<br/><br/>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. <a href=\"https://rocket.chat/docs/user-guides/end-to-end-encryption/\" target=\"_blank\">Learn more here!</a><br/><br/>Your password is: <span style=\"font-weight: bold;\">%s</span><br/><br/>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.<br/>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. <br/>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. <BR/>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.<BR/>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",

@ -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;
}

@ -201,4 +201,5 @@ import './v201';
import './v202';
import './v203';
import './v204';
import './v205';
import './xrun';

@ -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,
},
});
},
});
Loading…
Cancel
Save