mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
feat: Run mfa.beforeSetup
hook before enabling MFA (#11116)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
This commit is contained in:
parent
44f95160fb
commit
25c1c3218c
|
@ -1,11 +1,21 @@
|
||||||
import { Get, Post, RestController } from '@/decorators';
|
import { Get, Post, RestController } from '@/decorators';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
import { MfaService } from '@/mfa/mfa.service';
|
import { MfaService } from '@/mfa/mfa.service';
|
||||||
import { AuthenticatedRequest, MFA } from '@/requests';
|
import { AuthenticatedRequest, MFA } from '@/requests';
|
||||||
|
|
||||||
@RestController('/mfa')
|
@RestController('/mfa')
|
||||||
export class MFAController {
|
export class MFAController {
|
||||||
constructor(private mfaService: MfaService) {}
|
constructor(
|
||||||
|
private mfaService: MfaService,
|
||||||
|
private externalHooks: ExternalHooks,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('/can-enable')
|
||||||
|
async canEnableMFA(req: AuthenticatedRequest) {
|
||||||
|
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/qr')
|
@Get('/qr')
|
||||||
async getQRCode(req: AuthenticatedRequest) {
|
async getQRCode(req: AuthenticatedRequest) {
|
||||||
|
@ -52,6 +62,8 @@ export class MFAController {
|
||||||
const { token = null } = req.body;
|
const { token = null } = req.body;
|
||||||
const { id, mfaEnabled } = req.user;
|
const { id, mfaEnabled } = req.user;
|
||||||
|
|
||||||
|
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||||
|
|
||||||
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
||||||
await this.mfaService.getSecretAndRecoveryCodes(id);
|
await this.mfaService.getSecretAndRecoveryCodes(id);
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,12 @@ import { AuthService } from '@/auth/auth.service';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
||||||
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
import { TOTPService } from '@/mfa/totp.service';
|
import { TOTPService } from '@/mfa/totp.service';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
import { createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
import { createOwner, createUser, createUserWithMfaEnabled } from '../shared/db/users';
|
||||||
import { randomValidPassword, uniqueId } from '../shared/random';
|
import { randomValidPassword, uniqueId } from '../shared/random';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import * as utils from '../shared/utils';
|
import * as utils from '../shared/utils';
|
||||||
|
@ -16,6 +19,8 @@ jest.mock('@/telemetry');
|
||||||
|
|
||||||
let owner: User;
|
let owner: User;
|
||||||
|
|
||||||
|
const externalHooks = mockInstance(ExternalHooks);
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({
|
const testServer = utils.setupTestServer({
|
||||||
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
|
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
|
||||||
});
|
});
|
||||||
|
@ -23,7 +28,9 @@ const testServer = utils.setupTestServer({
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['User']);
|
await testDb.truncate(['User']);
|
||||||
|
|
||||||
owner = await createUser({ role: 'global:owner' });
|
owner = await createOwner();
|
||||||
|
|
||||||
|
externalHooks.run.mockReset();
|
||||||
|
|
||||||
config.set('userManagement.disabled', false);
|
config.set('userManagement.disabled', false);
|
||||||
});
|
});
|
||||||
|
@ -131,6 +138,27 @@ describe('Enable MFA setup', () => {
|
||||||
expect(user.mfaRecoveryCodes).toBeDefined();
|
expect(user.mfaRecoveryCodes).toBeDefined();
|
||||||
expect(user.mfaSecret).toBeDefined();
|
expect(user.mfaSecret).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('POST /enable should not enable MFA if pre check fails', async () => {
|
||||||
|
// This test is to make sure owners verify their email before enabling MFA in cloud
|
||||||
|
|
||||||
|
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
|
|
||||||
|
const { secret } = response.body.data;
|
||||||
|
const token = new TOTPService().generateTOTP(secret);
|
||||||
|
|
||||||
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
|
||||||
|
|
||||||
|
externalHooks.run.mockRejectedValue(new BadRequestError('Error message'));
|
||||||
|
|
||||||
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(400);
|
||||||
|
|
||||||
|
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
||||||
|
where: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.mfaEnabled).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -232,6 +260,28 @@ describe('Change password with MFA enabled', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MFA before enable checks', () => {
|
||||||
|
test('POST /can-enable should throw error if mfa.beforeSetup returns error', async () => {
|
||||||
|
externalHooks.run.mockRejectedValue(new BadRequestError('Error message'));
|
||||||
|
|
||||||
|
await testServer.authAgentFor(owner).post('/mfa/can-enable').expect(400);
|
||||||
|
|
||||||
|
expect(externalHooks.run).toHaveBeenCalledWith('mfa.beforeSetup', [
|
||||||
|
expect.objectContaining(owner),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /can-enable should not throw error if mfa.beforeSetup does not exist', async () => {
|
||||||
|
externalHooks.run.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await testServer.authAgentFor(owner).post('/mfa/can-enable').expect(200);
|
||||||
|
|
||||||
|
expect(externalHooks.run).toHaveBeenCalledWith('mfa.beforeSetup', [
|
||||||
|
expect.objectContaining(owner),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Login', () => {
|
describe('Login', () => {
|
||||||
test('POST /login with email/password should succeed when mfa is disabled', async () => {
|
test('POST /login with email/password should succeed when mfa is disabled', async () => {
|
||||||
const password = randomString(8);
|
const password = randomString(8);
|
||||||
|
|
|
@ -13,7 +13,7 @@ export async function getCloudUserInfo(context: IRestApiContext): Promise<Cloud.
|
||||||
return await get(context.baseUrl, '/cloud/proxy/user/me');
|
return await get(context.baseUrl, '/cloud/proxy/user/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmEmail(context: IRestApiContext): Promise<Cloud.UserAccount> {
|
export async function sendConfirmationEmail(context: IRestApiContext): Promise<Cloud.UserAccount> {
|
||||||
return await post(context.baseUrl, '/cloud/proxy/user/resend-confirmation-email');
|
return await post(context.baseUrl, '/cloud/proxy/user/resend-confirmation-email');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import type { IRestApiContext } from '@/Interface';
|
import type { IRestApiContext } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
|
||||||
|
export async function canEnableMFA(context: IRestApiContext) {
|
||||||
|
return await makeRestApiRequest(context, 'POST', '/mfa/can-enable');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getMfaQR(
|
export async function getMfaQR(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
|
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
|
||||||
|
|
|
@ -105,7 +105,7 @@ describe('BannerStack', () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail');
|
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'sendConfirmationEmail');
|
||||||
getByTestId('confirm-email-button').click();
|
getByTestId('confirm-email-button').click();
|
||||||
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
@ -125,9 +125,11 @@ describe('BannerStack', () => {
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail').mockImplementation(() => {
|
const confirmEmailSpy = vi
|
||||||
throw new Error(ERROR_MESSAGE);
|
.spyOn(useUsersStore(), 'sendConfirmationEmail')
|
||||||
});
|
.mockImplementation(() => {
|
||||||
|
throw new Error(ERROR_MESSAGE);
|
||||||
|
});
|
||||||
getByTestId('confirm-email-button').click();
|
getByTestId('confirm-email-button').click();
|
||||||
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
@ -14,7 +14,7 @@ const userEmail = computed(() => {
|
||||||
|
|
||||||
async function onConfirmEmailClick() {
|
async function onConfirmEmailClick() {
|
||||||
try {
|
try {
|
||||||
await useUsersStore().confirmEmail();
|
await useUsersStore().sendConfirmationEmail();
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: locale.baseText('banners.confirmEmail.toast.success.heading'),
|
title: locale.baseText('banners.confirmEmail.toast.success.heading'),
|
||||||
|
|
|
@ -2592,6 +2592,7 @@
|
||||||
"settings.personal.mfa.toast.disabledMfa.title": "Two-factor authentication disabled",
|
"settings.personal.mfa.toast.disabledMfa.title": "Two-factor authentication disabled",
|
||||||
"settings.personal.mfa.toast.disabledMfa.message": "You will no longer need your authenticator app when signing in",
|
"settings.personal.mfa.toast.disabledMfa.message": "You will no longer need your authenticator app when signing in",
|
||||||
"settings.personal.mfa.toast.disabledMfa.error.message": "Error disabling two-factor authentication",
|
"settings.personal.mfa.toast.disabledMfa.error.message": "Error disabling two-factor authentication",
|
||||||
|
"settings.personal.mfa.toast.canEnableMfa.title": "MFA pre-requisite failed",
|
||||||
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
|
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
|
||||||
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
|
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
|
||||||
"sso.login.divider": "or",
|
"sso.login.divider": "or",
|
||||||
|
|
|
@ -320,6 +320,10 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
|
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canEnableMFA = async () => {
|
||||||
|
return await mfaApi.canEnableMFA(rootStore.restApiContext);
|
||||||
|
};
|
||||||
|
|
||||||
const enableMfa = async (data: { token: string }) => {
|
const enableMfa = async (data: { token: string }) => {
|
||||||
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
|
@ -347,8 +351,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmEmail = async () => {
|
const sendConfirmationEmail = async () => {
|
||||||
await cloudApi.confirmEmail(rootStore.restApiContext);
|
await cloudApi.sendConfirmationEmail(rootStore.restApiContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateGlobalRole = async ({ id, newRoleName }: UpdateGlobalRolePayload) => {
|
const updateGlobalRole = async ({ id, newRoleName }: UpdateGlobalRolePayload) => {
|
||||||
|
@ -403,8 +407,9 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
verifyMfaToken,
|
verifyMfaToken,
|
||||||
enableMfa,
|
enableMfa,
|
||||||
disableMfa,
|
disableMfa,
|
||||||
|
canEnableMFA,
|
||||||
fetchUserCloudAccount,
|
fetchUserCloudAccount,
|
||||||
confirmEmail,
|
sendConfirmationEmail,
|
||||||
updateGlobalRole,
|
updateGlobalRole,
|
||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
|
|
|
@ -200,8 +200,23 @@ function openPasswordModal() {
|
||||||
uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
|
uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMfaEnableClick() {
|
async function onMfaEnableClick() {
|
||||||
uiStore.openModal(MFA_SETUP_MODAL_KEY);
|
if (!settingsStore.isCloudDeployment || !usersStore.isInstanceOwner) {
|
||||||
|
uiStore.openModal(MFA_SETUP_MODAL_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usersStore.canEnableMFA();
|
||||||
|
uiStore.openModal(MFA_SETUP_MODAL_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
showToast({
|
||||||
|
title: i18n.baseText('settings.personal.mfa.toast.canEnableMfa.title'),
|
||||||
|
message: e.message,
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
await usersStore.sendConfirmationEmail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disableMfa(payload: MfaModalEvents['closed']) {
|
async function disableMfa(payload: MfaModalEvents['closed']) {
|
||||||
|
|
Loading…
Reference in a new issue