Skip to content

Commit

Permalink
fix: Require mfa code to change email (n8n-io#10354)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomi authored Aug 15, 2024
1 parent 483e324 commit 39c8e50
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 41 deletions.
96 changes: 86 additions & 10 deletions packages/cli/src/controllers/__tests__/me.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ describe('MeController', () => {
password: 'password',
authIdentities: [],
role: 'global:owner',
mfaEnabled: false,
});
const reqBody = { email: '[email protected]', firstName: 'John', lastName: 'Potato' };
const req = mock<MeRequest.UserUpdate>({ user, body: reqBody, browserId });
const req = mock<MeRequest.UserUpdate>({ user, browserId });
req.body = {
email: '[email protected]',
firstName: 'John',
lastName: 'Potato',
};
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userRepository.findOneOrFail.mockResolvedValue(user);
Expand All @@ -67,7 +72,7 @@ describe('MeController', () => {
expect(externalHooks.run).toHaveBeenCalledWith('user.profile.beforeUpdate', [
user.id,
user.email,
reqBody,
req.body,
]);

expect(userService.update).toHaveBeenCalled();
Expand Down Expand Up @@ -98,25 +103,25 @@ describe('MeController', () => {
password: 'password',
authIdentities: [],
role: 'global:member',
mfaEnabled: false,
});
const reqBody = { email: '[email protected]', firstName: 'John', lastName: 'Potato' };
const req = mock<MeRequest.UserUpdate>({ user, browserId });
req.body = reqBody;
req.body = { email: '[email protected]', firstName: 'John', lastName: 'Potato' };
const res = mock<Response>();
userRepository.findOneOrFail.mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');

// Add invalid data to the request payload
Object.assign(reqBody, { id: '0', role: 'global:owner' });
Object.assign(req.body, { id: '0', role: 'global:owner' });

await controller.updateCurrentUser(req, res);

expect(userService.update).toHaveBeenCalled();

const updatePayload = userService.update.mock.calls[0][1];
expect(updatePayload.email).toBe(reqBody.email);
expect(updatePayload.firstName).toBe(reqBody.firstName);
expect(updatePayload.lastName).toBe(reqBody.lastName);
expect(updatePayload.email).toBe(req.body.email);
expect(updatePayload.firstName).toBe(req.body.firstName);
expect(updatePayload.lastName).toBe(req.body.lastName);
expect(updatePayload.id).toBeUndefined();
expect(updatePayload.role).toBeUndefined();
});
Expand All @@ -127,10 +132,11 @@ describe('MeController', () => {
password: 'password',
authIdentities: [],
role: 'global:owner',
mfaEnabled: false,
});
const reqBody = { email: '[email protected]', firstName: 'John', lastName: 'Potato' };
const req = mock<MeRequest.UserUpdate>({ user, body: reqBody });
// userService.findOneOrFail.mockResolvedValue(user);
req.body = reqBody; // We don't want the body to be a mock object

externalHooks.run.mockImplementationOnce(async (hookName) => {
if (hookName === 'user.profile.beforeUpdate') {
Expand All @@ -142,6 +148,76 @@ describe('MeController', () => {
new BadRequestError('Invalid email address'),
);
});

describe('when mfa is enabled', () => {
it('should throw BadRequestError if mfa code is missing', async () => {
const user = mock<User>({
id: '123',
email: '[email protected]',
password: 'password',
authIdentities: [],
role: 'global:owner',
mfaEnabled: true,
});
const req = mock<MeRequest.UserUpdate>({ user, browserId });
req.body = { email: '[email protected]', firstName: 'John', lastName: 'Potato' };

await expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError(
new BadRequestError('Two-factor code is required to change email'),
);
});

it('should throw InvalidMfaCodeError if mfa code is invalid', async () => {
const user = mock<User>({
id: '123',
email: '[email protected]',
password: 'password',
authIdentities: [],
role: 'global:owner',
mfaEnabled: true,
});
const req = mock<MeRequest.UserUpdate>({ user, browserId });
req.body = {
email: '[email protected]',
firstName: 'John',
lastName: 'Potato',
mfaCode: 'invalid',
};
mockMfaService.validateMfa.mockResolvedValue(false);

await expect(controller.updateCurrentUser(req, mock())).rejects.toThrow(
InvalidMfaCodeError,
);
});

it("should update the user's email if mfa code is valid", async () => {
const user = mock<User>({
id: '123',
email: '[email protected]',
password: 'password',
authIdentities: [],
role: 'global:owner',
mfaEnabled: true,
});
const req = mock<MeRequest.UserUpdate>({ user, browserId });
req.body = {
email: '[email protected]',
firstName: 'John',
lastName: 'Potato',
mfaCode: '123456',
};
const res = mock<Response>();
userRepository.findOneByOrFail.mockResolvedValue(user);
userRepository.findOneOrFail.mockResolvedValue(user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
userService.toPublic.mockResolvedValue({} as unknown as PublicUser);
mockMfaService.validateMfa.mockResolvedValue(true);

const result = await controller.updateCurrentUser(req, res);

expect(result).toEqual({});
});
});
});

describe('updatePassword', () => {
Expand Down
39 changes: 26 additions & 13 deletions packages/cli/src/controllers/me.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export class MeController {
*/
@Patch('/')
async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise<PublicUser> {
const { id: userId, email: currentEmail } = req.user;
const { id: userId, email: currentEmail, mfaEnabled } = req.user;

const payload = plainToInstance(UserUpdatePayload, req.body, { excludeExtraneousValues: true });

const { email } = payload;
Expand All @@ -76,17 +77,28 @@ export class MeController {

await validateEntity(payload);

const isEmailBeingChanged = email !== currentEmail;

// If SAML is enabled, we don't allow the user to change their email address
if (isSamlLicensedAndEnabled()) {
if (email !== currentEmail) {
this.logger.debug(
'Request to update user failed because SAML user may not change their email',
{
userId,
payload,
},
);
throw new BadRequestError('SAML user may not change their email');
if (isSamlLicensedAndEnabled() && isEmailBeingChanged) {
this.logger.debug(
'Request to update user failed because SAML user may not change their email',
{
userId,
payload,
},
);
throw new BadRequestError('SAML user may not change their email');
}

if (mfaEnabled && isEmailBeingChanged) {
if (!payload.mfaCode) {
throw new BadRequestError('Two-factor code is required to change email');
}

const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
if (!isMfaTokenValid) {
throw new InvalidMfaCodeError();
}
}

Expand All @@ -102,8 +114,9 @@ export class MeController {

this.authService.issueCookie(res, user, req.browserId);

const fieldsChanged = (Object.keys(payload) as Array<keyof UserUpdatePayload>).filter(
(key) => payload[key] !== preUpdateUser[key],
const changeableFields = ['email', 'firstName', 'lastName'] as const;
const fieldsChanged = changeableFields.filter(
(key) => key in payload && payload[key] !== preUpdateUser[key],
);

this.eventService.emit('user-updated', { user, fieldsChanged });
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'la
@IsString({ message: 'Last name must be of type string.' })
@Length(1, 32, { message: 'Last name must be $constraint1 to $constraint2 characters long.' })
lastName: string;

@IsOptional()
@Expose()
@IsString({ message: 'Two factor code must be a string.' })
mfaCode?: string;
}

export class UserSettingsUpdatePayload {
Expand Down
15 changes: 9 additions & 6 deletions packages/editor-ui/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,17 @@ export async function changePassword(
await makeRestApiRequest(context, 'POST', '/change-password', params);
}

export type UpdateCurrentUserParams = {
id?: string;
firstName?: string;
lastName?: string;
email: string;
mfaCode?: string;
};

export async function updateCurrentUser(
context: IRestApiContext,
params: {
id?: string;
firstName?: string;
lastName?: string;
email: string;
},
params: UpdateCurrentUserParams,
): Promise<IUserResponse> {
return await makeRestApiRequest(context, 'PATCH', '/me', params);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/editor-ui/src/event-bus/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ export interface MfaModalClosedEventPayload {
}

export interface MfaModalEvents {
/** Command to request closing of the modal */
close: MfaModalClosedEventPayload | undefined;

/** Event that the modal has been closed */
closed: MfaModalClosedEventPayload | undefined;
}

Expand Down
7 changes: 1 addition & 6 deletions packages/editor-ui/src/stores/users.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
await usersApi.changePassword(rootStore.restApiContext, params);
};

const updateUser = async (params: {
id: string;
firstName: string;
lastName: string;
email: string;
}) => {
const updateUser = async (params: usersApi.UpdateCurrentUserParams) => {
const user = await usersApi.updateCurrentUser(rootStore.restApiContext, params);
addUsers([user]);
};
Expand Down
54 changes: 48 additions & 6 deletions packages/editor-ui/src/views/SettingsPersonalView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ import { createFormEventBus } from 'n8n-design-system/utils';
import type { MfaModalEvents } from '@/event-bus/mfa';
import { promptMfaCodeBus } from '@/event-bus/mfa';
type UserBasicDetailsForm = {
firstName: string;
lastName: string;
email: string;
};
type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
mfaCode?: string;
};
const i18n = useI18n();
const { showToast, showError } = useToast();
Expand Down Expand Up @@ -114,12 +124,17 @@ onMounted(() => {
function onInput() {
hasAnyBasicInfoChanges.value = true;
}
function onReadyToSubmit(ready: boolean) {
readyToSubmit.value = ready;
}
async function onSubmit(form: { firstName: string; lastName: string; email: string }) {
/** Saves users basic info and personalization settings */
async function saveUserSettings(params: UserBasicDetailsWithMfa) {
try {
await Promise.all([updateUserBasicInfo(form), updatePersonalisationSettings()]);
// The MFA code might be invalid so we update the user's basic info first
await updateUserBasicInfo(params);
await updatePersonalisationSettings();
showToast({
title: i18n.baseText('settings.personal.personalSettingsUpdated'),
Expand All @@ -130,32 +145,59 @@ async function onSubmit(form: { firstName: string; lastName: string; email: stri
showError(e, i18n.baseText('settings.personal.personalSettingsUpdatedError'));
}
}
async function updateUserBasicInfo(form: { firstName: string; lastName: string; email: string }) {
async function onSubmit(form: UserBasicDetailsForm) {
if (!usersStore.currentUser?.mfaEnabled) {
await saveUserSettings(form);
return;
}
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
promptMfaCodeBus.once('closed', async (payload: MfaModalEvents['closed']) => {
if (!payload) {
// User closed the modal without submitting the form
return;
}
await saveUserSettings({
...form,
mfaCode: payload.mfaCode,
});
});
}
async function updateUserBasicInfo(userBasicInfo: UserBasicDetailsWithMfa) {
if (!hasAnyBasicInfoChanges.value || !usersStore.currentUserId) {
return;
}
await usersStore.updateUser({
id: usersStore.currentUserId,
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
firstName: userBasicInfo.firstName,
lastName: userBasicInfo.lastName,
email: userBasicInfo.email,
mfaCode: userBasicInfo.mfaCode,
});
hasAnyBasicInfoChanges.value = false;
}
async function updatePersonalisationSettings() {
if (!hasAnyPersonalisationChanges.value) {
return;
}
uiStore.setTheme(currentSelectedTheme.value);
}
function onSaveClick() {
formBus.emit('submit');
}
function openPasswordModal() {
uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
}
function onMfaEnableClick() {
uiStore.openModal(MFA_SETUP_MODAL_KEY);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe('SettingsPersonalView', () => {
});

it('should commit the theme change after clicking save', async () => {
vi.spyOn(usersStore, 'updateUser').mockReturnValue(Promise.resolve());
const { getByPlaceholderText, findByText, getByTestId } = renderComponent({ pinia });
await waitAllPromises();

Expand All @@ -102,6 +103,9 @@ describe('SettingsPersonalView', () => {
await waitAllPromises();

getByTestId('save-settings-button').click();

await waitAllPromises();

expect(uiStore.theme).toBe('dark');
});
});
Expand Down

0 comments on commit 39c8e50

Please sign in to comment.