mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
fix: Require mfa code for password change if its enabled (#10341)
This commit is contained in:
parent
2580a5e19e
commit
9d7caacc69
|
@ -60,7 +60,9 @@ export class MfaService {
|
||||||
if (mfaToken) {
|
if (mfaToken) {
|
||||||
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
||||||
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
|
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
|
||||||
} else if (mfaRecoveryCode) {
|
}
|
||||||
|
|
||||||
|
if (mfaRecoveryCode) {
|
||||||
const validCodes = user.mfaRecoveryCodes.map((code) => this.cipher.decrypt(code));
|
const validCodes = user.mfaRecoveryCodes.map((code) => this.cipher.decrypt(code));
|
||||||
const index = validCodes.indexOf(mfaRecoveryCode);
|
const index = validCodes.indexOf(mfaRecoveryCode);
|
||||||
if (index === -1) return false;
|
if (index === -1) return false;
|
||||||
|
@ -70,6 +72,7 @@ export class MfaService {
|
||||||
await this.authUserRepository.save(user);
|
await this.authUserRepository.save(user);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,9 @@ import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { badPasswords } from '@test/testData';
|
import { badPasswords } from '@test/testData';
|
||||||
import { mockInstance } from '@test/mocking';
|
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';
|
const browserId = 'test-browser-id';
|
||||||
|
|
||||||
|
@ -23,6 +26,8 @@ describe('MeController', () => {
|
||||||
const eventService = mockInstance(EventService);
|
const eventService = mockInstance(EventService);
|
||||||
const userService = mockInstance(UserService);
|
const userService = mockInstance(UserService);
|
||||||
const userRepository = mockInstance(UserRepository);
|
const userRepository = mockInstance(UserRepository);
|
||||||
|
const mockMfaService = mockInstance(MfaService);
|
||||||
|
mockInstance(AuthUserRepository);
|
||||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||||
const controller = Container.get(MeController);
|
const controller = Container.get(MeController);
|
||||||
|
|
||||||
|
@ -179,7 +184,7 @@ describe('MeController', () => {
|
||||||
|
|
||||||
it('should update the password in the DB, and issue a new cookie', async () => {
|
it('should update the password in the DB, and issue a new cookie', async () => {
|
||||||
const req = mock<MeRequest.Password>({
|
const req = mock<MeRequest.Password>({
|
||||||
user: mock({ password: passwordHash }),
|
user: mock({ password: passwordHash, mfaEnabled: false }),
|
||||||
body: { currentPassword: 'old_password', newPassword: 'NewPassword123' },
|
body: { currentPassword: 'old_password', newPassword: 'NewPassword123' },
|
||||||
browserId,
|
browserId,
|
||||||
});
|
});
|
||||||
|
@ -212,6 +217,52 @@ describe('MeController', () => {
|
||||||
fieldsChanged: ['password'],
|
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', () => {
|
describe('storeSurveyAnswers', () => {
|
||||||
|
|
|
@ -23,6 +23,8 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
import { isApiEnabled } from '@/PublicApi';
|
import { isApiEnabled } from '@/PublicApi';
|
||||||
import { EventService } from '@/events/event.service';
|
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_';
|
export const API_KEY_PREFIX = 'n8n_api_';
|
||||||
|
|
||||||
|
@ -44,6 +46,7 @@ export class MeController {
|
||||||
private readonly passwordUtility: PasswordUtility,
|
private readonly passwordUtility: PasswordUtility,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,12 +118,12 @@ export class MeController {
|
||||||
/**
|
/**
|
||||||
* Update the logged-in user's password.
|
* Update the logged-in user's password.
|
||||||
*/
|
*/
|
||||||
@Patch('/password')
|
@Patch('/password', { rateLimit: true })
|
||||||
async updatePassword(req: MeRequest.Password, res: Response) {
|
async updatePassword(req: MeRequest.Password, res: Response) {
|
||||||
const { user } = req;
|
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()) {
|
if (isSamlLicensedAndEnabled()) {
|
||||||
this.logger.debug('Attempted to change password for user, while SAML is enabled', {
|
this.logger.debug('Attempted to change password for user, while SAML is enabled', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -145,6 +148,17 @@ export class MeController {
|
||||||
|
|
||||||
const validPassword = this.passwordUtility.validate(newPassword);
|
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);
|
user.password = await this.passwordUtility.hash(validPassword);
|
||||||
|
|
||||||
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
const updatedUser = await this.userRepository.save(user, { transaction: false });
|
||||||
|
|
|
@ -224,7 +224,7 @@ export declare namespace MeRequest {
|
||||||
export type Password = AuthenticatedRequest<
|
export type Password = AuthenticatedRequest<
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{ currentPassword: string; newPassword: string; token?: string }
|
{ currentPassword: string; newPassword: string; mfaCode?: string }
|
||||||
>;
|
>;
|
||||||
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
|
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,9 +116,15 @@ export async function updateOtherUserSettings(
|
||||||
return await makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings);
|
return await makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateUserPasswordParams = {
|
||||||
|
newPassword: string;
|
||||||
|
currentPassword: string;
|
||||||
|
mfaCode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function updateCurrentUserPassword(
|
export async function updateCurrentUserPassword(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: { newPassword: string; currentPassword: string },
|
params: UpdateUserPasswordParams,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await makeRestApiRequest(context, 'PATCH', '/me/password', params);
|
return await makeRestApiRequest(context, 'PATCH', '/me/password', params);
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ import { CHANGE_PASSWORD_MODAL_KEY } from '../constants';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import type { IFormInputs } from '@/Interface';
|
import type { IFormInputs, IFormInput } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
const config = ref<IFormInputs | null>(null);
|
const config = ref<IFormInputs | null>(null);
|
||||||
|
@ -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 {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await usersStore.updateCurrentUserPassword(values);
|
await usersStore.updateCurrentUserPassword({
|
||||||
|
currentPassword: values.currentPassword,
|
||||||
|
newPassword: values.password,
|
||||||
|
mfaCode: values.mfaCode,
|
||||||
|
});
|
||||||
|
|
||||||
showMessage({
|
showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
@ -92,8 +100,8 @@ const onSubmitClick = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const form: IFormInputs = [
|
const inputs: Record<string, IFormInput> = {
|
||||||
{
|
currentPassword: {
|
||||||
name: 'currentPassword',
|
name: 'currentPassword',
|
||||||
properties: {
|
properties: {
|
||||||
label: i18n.baseText('auth.changePassword.currentPassword'),
|
label: i18n.baseText('auth.changePassword.currentPassword'),
|
||||||
|
@ -104,7 +112,16 @@ onMounted(() => {
|
||||||
focusInitially: true,
|
focusInitially: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
mfaCode: {
|
||||||
|
name: 'mfaCode',
|
||||||
|
properties: {
|
||||||
|
label: i18n.baseText('auth.changePassword.mfaCode'),
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
capitalize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
newPassword: {
|
||||||
name: 'password',
|
name: 'password',
|
||||||
properties: {
|
properties: {
|
||||||
label: i18n.baseText('auth.newPassword'),
|
label: i18n.baseText('auth.newPassword'),
|
||||||
|
@ -116,7 +133,7 @@ onMounted(() => {
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
newPasswordAgain: {
|
||||||
name: 'password2',
|
name: 'password2',
|
||||||
properties: {
|
properties: {
|
||||||
label: i18n.baseText('auth.changePassword.reenterNewPassword'),
|
label: i18n.baseText('auth.changePassword.reenterNewPassword'),
|
||||||
|
@ -132,7 +149,13 @@ onMounted(() => {
|
||||||
capitalize: true,
|
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;
|
config.value = form;
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ChangePasswordModal > should render correctly 1`] = `
|
||||||
|
"<!--teleport start-->
|
||||||
|
<!--teleport end-->"
|
||||||
|
`;
|
|
@ -98,6 +98,7 @@
|
||||||
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
||||||
"auth.changePassword": "Change password",
|
"auth.changePassword": "Change password",
|
||||||
"auth.changePassword.currentPassword": "Current password",
|
"auth.changePassword.currentPassword": "Current password",
|
||||||
|
"auth.changePassword.mfaCode": "Two-factor code",
|
||||||
"auth.changePassword.error": "Problem changing the password",
|
"auth.changePassword.error": "Problem changing the password",
|
||||||
"auth.changePassword.missingTokenError": "Missing token",
|
"auth.changePassword.missingTokenError": "Missing token",
|
||||||
"auth.changePassword.missingUserIdError": "Missing user ID",
|
"auth.changePassword.missingUserIdError": "Missing user ID",
|
||||||
|
|
|
@ -258,17 +258,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
addUsers([usersById.value[userId]]);
|
addUsers([usersById.value[userId]]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCurrentUserPassword = async ({
|
const updateCurrentUserPassword = async (params: usersApi.UpdateUserPasswordParams) => {
|
||||||
password,
|
await usersApi.updateCurrentUserPassword(rootStore.restApiContext, params);
|
||||||
currentPassword,
|
|
||||||
}: {
|
|
||||||
password: string;
|
|
||||||
currentPassword: string;
|
|
||||||
}) => {
|
|
||||||
await usersApi.updateCurrentUserPassword(rootStore.restApiContext, {
|
|
||||||
newPassword: password,
|
|
||||||
currentPassword,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUser = async (params: { id: string; transferId?: string }) => {
|
const deleteUser = async (params: { id: string; transferId?: string }) => {
|
||||||
|
|
Loading…
Reference in a new issue