From cd853e2ffe803cc16cfc3285ce48d6768705578d Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 26 Nov 2024 17:06:50 -0500 Subject: [PATCH] Allow disabling MFA using recovery code --- .../e2e/27-two-factor-authentication.cy.ts | 22 ++- cypress/pages/settings-personal.ts | 13 +- .../cli/src/controllers/mfa.controller.ts | 8 +- .../response-errors/invalid-mfa-code.error.ts | 2 +- packages/cli/src/mfa/mfa.service.ts | 5 +- packages/cli/src/requests.ts | 2 +- packages/editor-ui/src/api/mfa.ts | 1 + .../PromptMfaCodeModal/PromptMfaCodeModal.vue | 132 +++++++++++++----- packages/editor-ui/src/event-bus/mfa.ts | 1 + .../src/plugins/i18n/locales/en.json | 4 +- packages/editor-ui/src/stores/users.store.ts | 3 +- .../src/views/SettingsPersonalView.vue | 2 +- 12 files changed, 143 insertions(+), 52 deletions(-) diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index dc62a0c58c..6622db2dcd 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -68,14 +68,28 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => { mainSidebar.actions.signout(); }); - it('Should be able to disable MFA in account', () => { + it('Should be able to disable MFA in account with MFA token ', () => { const { email, password } = user; signinPage.actions.loginWithEmailAndPassword(email, password); personalSettingsPage.actions.enableMfa(); mainSidebar.actions.signout(); - const token = generateOTPToken(user.mfaSecret); - mfaLoginPage.actions.loginWithMfaToken(email, password, token); - personalSettingsPage.actions.disableMfa(); + const loginToken = generateOTPToken(user.mfaSecret); + mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken); + const disableToken = generateOTPToken(user.mfaSecret); + personalSettingsPage.actions.disableMfaWithMfaToken(disableToken); + personalSettingsPage.getters.enableMfaButton().should('exist'); + mainSidebar.actions.signout(); + }); + + it('Should be able to disable MFA in account with recovery code', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + const loginToken = generateOTPToken(user.mfaSecret); + mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken); + personalSettingsPage.actions.disableMfaWitRecoveryCode(user.mfaRecoveryCodes[0]); + personalSettingsPage.getters.enableMfaButton().should('exist'); mainSidebar.actions.signout(); }); }); diff --git a/cypress/pages/settings-personal.ts b/cypress/pages/settings-personal.ts index 4574f95691..56a583b23c 100644 --- a/cypress/pages/settings-personal.ts +++ b/cypress/pages/settings-personal.ts @@ -22,6 +22,9 @@ export class PersonalSettingsPage extends BasePage { saveSettingsButton: () => cy.getByTestId('save-settings-button'), enableMfaButton: () => cy.getByTestId('enable-mfa-button'), disableMfaButton: () => cy.getByTestId('disable-mfa-button'), + mfaCodeInput: () => cy.getByTestId('mfa-code-input'), + recoveryCodeInput: () => cy.getByTestId('recovery-code-input'), + mfaSaveButton: () => cy.getByTestId('mfa-save-button'), themeSelector: () => cy.getByTestId('theme-select'), selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'), }; @@ -83,9 +86,17 @@ export class PersonalSettingsPage extends BasePage { mfaSetupModal.getters.saveButton().click(); }); }, - disableMfa: () => { + disableMfaWithMfaToken: (token: string) => { cy.visit(this.url); this.getters.disableMfaButton().click(); + this.getters.mfaCodeInput().type(token); + this.getters.mfaSaveButton().click(); + }, + disableMfaWitRecoveryCode: (recoveryCode: string) => { + cy.visit(this.url); + this.getters.disableMfaButton().click(); + this.getters.recoveryCodeInput().type(recoveryCode); + this.getters.mfaSaveButton().click(); }, }; } diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 694765761c..9422dd9adf 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -86,13 +86,13 @@ export class MFAController { @Post('/disable', { rateLimit: true }) async disableMFA(req: MFA.Disable) { const { id: userId } = req.user; - const { token = null } = req.body; + const { token = null, recoveryCode = null } = req.body; - if (typeof token !== 'string' || !token) { - throw new BadRequestError('Token is required to disable MFA feature'); + if (typeof token !== 'string' || typeof recoveryCode !== 'string') { + throw new BadRequestError('Token or recovery code should be strings'); } - await this.mfaService.disableMfa(userId, token); + await this.mfaService.disableMfa(userId, token, recoveryCode); } @Post('/verify', { rateLimit: true }) diff --git a/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts index cc00976a84..d3aac1b8b0 100644 --- a/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts +++ b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts @@ -2,6 +2,6 @@ import { ForbiddenError } from './forbidden.error'; export class InvalidMfaCodeError extends ForbiddenError { constructor(hint?: string) { - super('Invalid two-factor code.', hint); + super('Invalid two-factor code or recovery code.', hint); } } diff --git a/packages/cli/src/mfa/mfa.service.ts b/packages/cli/src/mfa/mfa.service.ts index 5f730b7bf1..11b8bb27b6 100644 --- a/packages/cli/src/mfa/mfa.service.ts +++ b/packages/cli/src/mfa/mfa.service.ts @@ -85,8 +85,9 @@ export class MfaService { return await this.authUserRepository.save(user); } - async disableMfa(userId: string, mfaToken: string) { - const isValidToken = await this.validateMfa(userId, mfaToken, undefined); + async disableMfa(userId: string, mfaToken: string, recoveryCode: string) { + const isValidToken = await this.validateMfa(userId, mfaToken, recoveryCode); + if (!isValidToken) { throw new InvalidMfaCodeError(); } diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index f233d7db46..b6c9f09040 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -318,7 +318,7 @@ export type LoginRequest = AuthlessRequest< export declare namespace MFA { type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>; - type Disable = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Disable = AuthenticatedRequest<{}, {}, { token?: string; recoveryCode?: string }, {}>; type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; type ValidateRecoveryCode = AuthenticatedRequest< {}, diff --git a/packages/editor-ui/src/api/mfa.ts b/packages/editor-ui/src/api/mfa.ts index 0cce31c96d..f146dd63fd 100644 --- a/packages/editor-ui/src/api/mfa.ts +++ b/packages/editor-ui/src/api/mfa.ts @@ -24,6 +24,7 @@ export async function verifyMfaToken( export type DisableMfaParams = { token: string; + recoveryCode: string; }; export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise { diff --git a/packages/editor-ui/src/components/PromptMfaCodeModal/PromptMfaCodeModal.vue b/packages/editor-ui/src/components/PromptMfaCodeModal/PromptMfaCodeModal.vue index 5fdfc205cd..a378dae19b 100644 --- a/packages/editor-ui/src/components/PromptMfaCodeModal/PromptMfaCodeModal.vue +++ b/packages/editor-ui/src/components/PromptMfaCodeModal/PromptMfaCodeModal.vue @@ -1,50 +1,89 @@