Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): Require mfa code for password change if its enabled #10341

Merged
merged 1 commit into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/cli/src/Mfa/mfa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export class MfaService {
if (mfaToken) {
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
} else if (mfaRecoveryCode) {
}

if (mfaRecoveryCode) {
const validCodes = user.mfaRecoveryCodes.map((code) => this.cipher.decrypt(code));
const index = validCodes.indexOf(mfaRecoveryCode);
if (index === -1) return false;
Expand All @@ -70,6 +72,7 @@ export class MfaService {
await this.authUserRepository.save(user);
return true;
}

return false;
}

Expand Down
53 changes: 52 additions & 1 deletion packages/cli/src/controllers/__tests__/me.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { UserRepository } from '@/databases/repositories/user.repository';
import { EventService } from '@/events/event.service';
import { badPasswords } from '@test/testData';
import { mockInstance } from '@test/mocking';
import { AuthUserRepository } from '@/databases/repositories/authUser.repository';
import { MfaService } from '@/Mfa/mfa.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';

const browserId = 'test-browser-id';

Expand All @@ -25,6 +28,8 @@ describe('MeController', () => {
const eventService = mockInstance(EventService);
const userService = mockInstance(UserService);
const userRepository = mockInstance(UserRepository);
const mockMfaService = mockInstance(MfaService);
mockInstance(AuthUserRepository);
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
const controller = Container.get(MeController);

Expand Down Expand Up @@ -181,7 +186,7 @@ describe('MeController', () => {

it('should update the password in the DB, and issue a new cookie', async () => {
const req = mock<MeRequest.Password>({
user: mock({ password: passwordHash }),
user: mock({ password: passwordHash, mfaEnabled: false }),
body: { currentPassword: 'old_password', newPassword: 'NewPassword123' },
browserId,
});
Expand Down Expand Up @@ -214,6 +219,52 @@ describe('MeController', () => {
fieldsChanged: ['password'],
});
});

describe('mfa enabled', () => {
it('should throw BadRequestError if mfa code is missing', async () => {
const req = mock<MeRequest.Password>({
user: mock({ password: passwordHash, mfaEnabled: true }),
body: { currentPassword: 'old_password', newPassword: 'NewPassword123' },
});

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

it('should throw ForbiddenError if invalid mfa code is given', async () => {
const req = mock<MeRequest.Password>({
user: mock({ password: passwordHash, mfaEnabled: true }),
body: { currentPassword: 'old_password', newPassword: 'NewPassword123', mfaCode: '123' },
});
mockMfaService.validateMfa.mockResolvedValue(false);

await expect(controller.updatePassword(req, mock())).rejects.toThrowError(
new ForbiddenError('Invalid two-factor code.'),
);
});

it('should succeed when mfa code is correct', async () => {
const req = mock<MeRequest.Password>({
user: mock({ password: passwordHash, mfaEnabled: true }),
body: {
currentPassword: 'old_password',
newPassword: 'NewPassword123',
mfaCode: 'valid',
},
browserId,
});
const res = mock<Response>();
userRepository.save.calledWith(req.user).mockResolvedValue(req.user);
jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token');
mockMfaService.validateMfa.mockResolvedValue(true);

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

expect(result).toEqual({ success: true });
expect(req.user.password).not.toBe(passwordHash);
});
});
});

describe('storeSurveyAnswers', () => {
Expand Down
20 changes: 17 additions & 3 deletions packages/cli/src/controllers/me.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserRepository } from '@/databases/repositories/user.repository';
import { isApiEnabled } from '@/PublicApi';
import { EventService } from '@/events/event.service';
import { MfaService } from '@/Mfa/mfa.service';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';

export const API_KEY_PREFIX = 'n8n_api_';

Expand All @@ -44,6 +46,7 @@ export class MeController {
private readonly passwordUtility: PasswordUtility,
private readonly userRepository: UserRepository,
private readonly eventService: EventService,
private readonly mfaService: MfaService,
) {}

/**
Expand Down Expand Up @@ -115,12 +118,12 @@ export class MeController {
/**
* Update the logged-in user's password.
*/
@Patch('/password')
@Patch('/password', { rateLimit: true })
async updatePassword(req: MeRequest.Password, res: Response) {
const { user } = req;
const { currentPassword, newPassword } = req.body;
const { currentPassword, newPassword, mfaCode } = req.body;

// If SAML is enabled, we don't allow the user to change their email address
// If SAML is enabled, we don't allow the user to change their password
if (isSamlLicensedAndEnabled()) {
this.logger.debug('Attempted to change password for user, while SAML is enabled', {
userId: user.id,
Expand All @@ -145,6 +148,17 @@ export class MeController {

const validPassword = this.passwordUtility.validate(newPassword);

if (user.mfaEnabled) {
if (typeof mfaCode !== 'string') {
throw new BadRequestError('Two-factor code is required to change password.');
}

const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
if (!isMfaTokenValid) {
throw new ForbiddenError('Invalid two-factor code.');
}
}

user.password = await this.passwordUtility.hash(validPassword);

const updatedUser = await this.userRepository.save(user, { transaction: false });
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export declare namespace MeRequest {
export type Password = AuthenticatedRequest<
{},
{},
{ currentPassword: string; newPassword: string; token?: string }
{ currentPassword: string; newPassword: string; mfaCode?: string }
>;
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/editor-ui/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,15 @@ export async function updateOtherUserSettings(
return await makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings);
}

export type UpdateUserPasswordParams = {
newPassword: string;
currentPassword: string;
mfaCode?: string;
};

export async function updateCurrentUserPassword(
context: IRestApiContext,
params: { newPassword: string; currentPassword: string },
params: UpdateUserPasswordParams,
): Promise<void> {
return await makeRestApiRequest(context, 'PATCH', '/me/password', params);
}
Expand Down
39 changes: 31 additions & 8 deletions packages/editor-ui/src/components/ChangePasswordModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { CHANGE_PASSWORD_MODAL_KEY } from '../constants';
import Modal from '@/components/Modal.vue';
import { useUsersStore } from '@/stores/users.store';
import { createEventBus } from 'n8n-design-system/utils';
import type { IFormInputs } from '@/Interface';
import type { IFormInputs, IFormInput } from '@/Interface';
import { useI18n } from '@/composables/useI18n';

const config = ref<IFormInputs | null>(null);
Expand Down Expand Up @@ -68,10 +68,18 @@ const onInput = (e: { name: string; value: string }) => {
}
};

const onSubmit = async (values: { currentPassword: string; password: string }) => {
const onSubmit = async (values: {
currentPassword: string;
password: string;
mfaCode?: string;
}) => {
try {
loading.value = true;
await usersStore.updateCurrentUserPassword(values);
await usersStore.updateCurrentUserPassword({
currentPassword: values.currentPassword,
newPassword: values.password,
mfaCode: values.mfaCode,
});

showMessage({
type: 'success',
Expand All @@ -92,8 +100,8 @@ const onSubmitClick = () => {
};

onMounted(() => {
const form: IFormInputs = [
{
const inputs: Record<string, IFormInput> = {
currentPassword: {
name: 'currentPassword',
properties: {
label: i18n.baseText('auth.changePassword.currentPassword'),
Expand All @@ -104,7 +112,16 @@ onMounted(() => {
focusInitially: true,
},
},
{
mfaCode: {
name: 'mfaCode',
properties: {
label: i18n.baseText('auth.changePassword.mfaCode'),
type: 'text',
required: true,
capitalize: true,
},
},
newPassword: {
name: 'password',
properties: {
label: i18n.baseText('auth.newPassword'),
Expand All @@ -116,7 +133,7 @@ onMounted(() => {
capitalize: true,
},
},
{
newPasswordAgain: {
name: 'password2',
properties: {
label: i18n.baseText('auth.changePassword.reenterNewPassword'),
Expand All @@ -132,7 +149,13 @@ onMounted(() => {
capitalize: true,
},
},
];
};

const { currentUser } = usersStore;

const form: IFormInputs = currentUser?.mfaEnabled
? [inputs.currentPassword, inputs.mfaCode, inputs.newPassword, inputs.newPasswordAgain]
: [inputs.currentPassword, inputs.newPassword, inputs.newPasswordAgain];

config.value = form;
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createTestingPinia } from '@pinia/testing';
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
import type { createPinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';

const renderComponent = createComponentRenderer(ChangePasswordModal);

describe('ChangePasswordModal', () => {
let pinia: ReturnType<typeof createPinia>;

beforeEach(() => {
pinia = createTestingPinia({});
});

it('should render correctly', () => {
const wrapper = renderComponent({ pinia });

expect(wrapper.html()).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`ChangePasswordModal > should render correctly 1`] = `
"<!--teleport start-->
<!--teleport end-->"
`;
Comment on lines +3 to +6
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snapshot is not very useful, and I couldn't get the test to render the actual component. I still left it so we test that rendering at least works. I can also remove it if better that way

1 change: 1 addition & 0 deletions packages/editor-ui/src/plugins/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
"auth.changePassword": "Change password",
"auth.changePassword.currentPassword": "Current password",
"auth.changePassword.mfaCode": "Two-factor code",
"auth.changePassword.error": "Problem changing the password",
"auth.changePassword.missingTokenError": "Missing token",
"auth.changePassword.missingUserIdError": "Missing user ID",
Expand Down
13 changes: 2 additions & 11 deletions packages/editor-ui/src/stores/users.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,17 +258,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
addUsers([usersById.value[userId]]);
};

const updateCurrentUserPassword = async ({
password,
currentPassword,
}: {
password: string;
currentPassword: string;
}) => {
await usersApi.updateCurrentUserPassword(rootStore.restApiContext, {
newPassword: password,
currentPassword,
});
const updateCurrentUserPassword = async (params: usersApi.UpdateUserPasswordParams) => {
await usersApi.updateCurrentUserPassword(rootStore.restApiContext, params);
};

const deleteUser = async (params: { id: string; transferId?: string }) => {
Expand Down
Loading