Skip to content

Commit

Permalink
feat: Run mfa.beforeSetup hook before enabling MFA (#11116)
Browse files Browse the repository at this point in the history
  • Loading branch information
RicardoE105 authored Oct 17, 2024
1 parent 44f9516 commit 25c1c32
Show file tree
Hide file tree
Showing 9 changed files with 103 additions and 14 deletions.
14 changes: 13 additions & 1 deletion packages/cli/src/controllers/mfa.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { Get, Post, RestController } from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ExternalHooks } from '@/external-hooks';
import { MfaService } from '@/mfa/mfa.service';
import { AuthenticatedRequest, MFA } from '@/requests';

@RestController('/mfa')
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')
async getQRCode(req: AuthenticatedRequest) {
Expand Down Expand Up @@ -52,6 +62,8 @@ export class MFAController {
const { token = null } = req.body;
const { id, mfaEnabled } = req.user;

await this.externalHooks.run('mfa.beforeSetup', [req.user]);

const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
await this.mfaService.getSecretAndRecoveryCodes(id);

Expand Down
54 changes: 52 additions & 2 deletions packages/cli/test/integration/mfa/mfa.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import type { User } from '@/databases/entities/user';
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 { 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 * as testDb from '../shared/test-db';
import * as utils from '../shared/utils';
Expand All @@ -16,14 +19,18 @@ jest.mock('@/telemetry');

let owner: User;

const externalHooks = mockInstance(ExternalHooks);

const testServer = utils.setupTestServer({
endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'],
});

beforeEach(async () => {
await testDb.truncate(['User']);

owner = await createUser({ role: 'global:owner' });
owner = await createOwner();

externalHooks.run.mockReset();

config.set('userManagement.disabled', false);
});
Expand Down Expand Up @@ -131,6 +138,27 @@ describe('Enable MFA setup', () => {
expect(user.mfaRecoveryCodes).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);
});
});
});

Expand Down Expand Up @@ -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', () => {
test('POST /login with email/password should succeed when mfa is disabled', async () => {
const password = randomString(8);
Expand Down
2 changes: 1 addition & 1 deletion packages/editor-ui/src/api/cloudPlans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function getCloudUserInfo(context: IRestApiContext): Promise<Cloud.
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');
}

Expand Down
4 changes: 4 additions & 0 deletions packages/editor-ui/src/api/mfa.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';

export async function canEnableMFA(context: IRestApiContext) {
return await makeRestApiRequest(context, 'POST', '/mfa/can-enable');
}

export async function getMfaQR(
context: IRestApiContext,
): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
Expand Down
10 changes: 6 additions & 4 deletions packages/editor-ui/src/components/__tests__/BannersStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('BannerStack', () => {
},
}),
});
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail');
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'sendConfirmationEmail');
getByTestId('confirm-email-button').click();
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
await waitFor(() => {
Expand All @@ -125,9 +125,11 @@ describe('BannerStack', () => {
},
}),
});
const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail').mockImplementation(() => {
throw new Error(ERROR_MESSAGE);
});
const confirmEmailSpy = vi
.spyOn(useUsersStore(), 'sendConfirmationEmail')
.mockImplementation(() => {
throw new Error(ERROR_MESSAGE);
});
getByTestId('confirm-email-button').click();
await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled());
await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const userEmail = computed(() => {
async function onConfirmEmailClick() {
try {
await useUsersStore().confirmEmail();
await useUsersStore().sendConfirmationEmail();
toast.showMessage({
type: 'success',
title: locale.baseText('banners.confirmEmail.toast.success.heading'),
Expand Down
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 @@ -2592,6 +2592,7 @@
"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.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.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",
Expand Down
11 changes: 8 additions & 3 deletions packages/editor-ui/src/stores/users.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,10 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
};

const canEnableMFA = async () => {
return await mfaApi.canEnableMFA(rootStore.restApiContext);
};

const enableMfa = async (data: { token: string }) => {
await mfaApi.enableMfa(rootStore.restApiContext, data);
if (currentUser.value) {
Expand Down Expand Up @@ -347,8 +351,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}
};

const confirmEmail = async () => {
await cloudApi.confirmEmail(rootStore.restApiContext);
const sendConfirmationEmail = async () => {
await cloudApi.sendConfirmationEmail(rootStore.restApiContext);
};

const updateGlobalRole = async ({ id, newRoleName }: UpdateGlobalRolePayload) => {
Expand Down Expand Up @@ -403,8 +407,9 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
verifyMfaToken,
enableMfa,
disableMfa,
canEnableMFA,
fetchUserCloudAccount,
confirmEmail,
sendConfirmationEmail,
updateGlobalRole,
reset,
};
Expand Down
19 changes: 17 additions & 2 deletions packages/editor-ui/src/views/SettingsPersonalView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,23 @@ function openPasswordModal() {
uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
}
function onMfaEnableClick() {
uiStore.openModal(MFA_SETUP_MODAL_KEY);
async function onMfaEnableClick() {
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']) {
Expand Down

0 comments on commit 25c1c32

Please sign in to comment.