From 2b7ba6fdf100ef78b60358648d773e2f200847b8 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 23 Aug 2023 22:59:16 -0400 Subject: [PATCH] feat(core): Add MFA (#4767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://linear.app/n8n/issue/ADO-947/sync-branch-with-master-and-fix-fe-e2e-tets --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .gitignore | 1 + .../e2e/27-two-factor-authentication.cy.ts | 70 +++ cypress/pages/index.ts | 1 + cypress/pages/mfa-login.ts | 77 ++++ cypress/pages/modals/mfa-setup-modal.ts | 11 + cypress/pages/settings-personal.ts | 22 + cypress/pages/sidebar/main-sidebar.ts | 4 +- cypress/pages/signin.ts | 41 ++ cypress/support/commands.ts | 20 +- cypress/support/index.ts | 1 + package.json | 1 + packages/cli/package.json | 1 + packages/cli/src/Interfaces.ts | 1 + packages/cli/src/Mfa/constants.ts | 1 + packages/cli/src/Mfa/helpers.ts | 21 + packages/cli/src/Mfa/mfa.service.ts | 79 ++++ packages/cli/src/Mfa/totp.service.ts | 36 ++ packages/cli/src/ResponseHelper.ts | 4 +- packages/cli/src/Server.ts | 41 +- .../UserManagement/UserManagementHelper.ts | 7 +- packages/cli/src/commands/mfa/disable.ts | 55 +++ packages/cli/src/config/schema.ts | 9 + .../cli/src/controllers/auth.controller.ts | 60 ++- .../cli/src/controllers/e2e.controller.ts | 26 +- packages/cli/src/controllers/index.ts | 1 + .../cli/src/controllers/mfa.controller.ts | 96 +++++ .../controllers/passwordReset.controller.ts | 24 +- .../cli/src/controllers/users.controller.ts | 6 +- packages/cli/src/databases/entities/User.ts | 9 + .../common/1690000000040-AddMfaColumns.ts | 35 ++ .../src/databases/migrations/mysqldb/index.ts | 2 + .../databases/migrations/postgresdb/index.ts | 2 + .../src/databases/migrations/sqlite/index.ts | 2 + packages/cli/src/requests.ts | 25 +- packages/cli/src/services/user.service.ts | 3 +- .../cli/test/integration/mfa/mfa.api.test.ts | 405 ++++++++++++++++++ .../cli/test/integration/shared/testDb.ts | 53 ++- packages/cli/test/integration/shared/types.ts | 1 + .../integration/shared/utils/testServer.ts | 20 +- packages/editor-ui/package.json | 1 + packages/editor-ui/src/Interface.ts | 8 +- packages/editor-ui/src/api/mfa.ts | 23 + packages/editor-ui/src/api/users.ts | 4 +- .../src/components/ChangePasswordModal.vue | 8 +- .../src/components/MfaSetupModal.vue | 359 ++++++++++++++++ packages/editor-ui/src/components/Modals.vue | 8 + packages/editor-ui/src/constants.ts | 12 +- packages/editor-ui/src/event-bus/index.ts | 1 + packages/editor-ui/src/event-bus/mfa.ts | 3 + .../editor-ui/src/mixins/genericHelpers.ts | 11 + .../src/plugins/i18n/locales/en.json | 60 ++- packages/editor-ui/src/plugins/icons/index.ts | 2 + .../editor-ui/src/stores/settings.store.ts | 8 + packages/editor-ui/src/stores/ui.store.ts | 4 + packages/editor-ui/src/stores/users.store.ts | 43 +- .../src/views/ChangePasswordView.vue | 38 +- packages/editor-ui/src/views/MfaView.vue | 249 +++++++++++ .../src/views/SettingsPersonalView.vue | 94 +++- packages/editor-ui/src/views/SigninView.vue | 124 +++++- packages/workflow/src/Interfaces.ts | 3 + pnpm-lock.yaml | 69 +++ 61 files changed, 2301 insertions(+), 105 deletions(-) create mode 100644 cypress/e2e/27-two-factor-authentication.cy.ts create mode 100644 cypress/pages/mfa-login.ts create mode 100644 cypress/pages/modals/mfa-setup-modal.ts create mode 100644 cypress/pages/signin.ts create mode 100644 packages/cli/src/Mfa/constants.ts create mode 100644 packages/cli/src/Mfa/helpers.ts create mode 100644 packages/cli/src/Mfa/mfa.service.ts create mode 100644 packages/cli/src/Mfa/totp.service.ts create mode 100644 packages/cli/src/commands/mfa/disable.ts create mode 100644 packages/cli/src/controllers/mfa.controller.ts create mode 100644 packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts create mode 100644 packages/cli/test/integration/mfa/mfa.api.test.ts create mode 100644 packages/editor-ui/src/api/mfa.ts create mode 100644 packages/editor-ui/src/components/MfaSetupModal.vue create mode 100644 packages/editor-ui/src/event-bus/mfa.ts create mode 100644 packages/editor-ui/src/views/MfaView.vue diff --git a/.gitignore b/.gitignore index 3060b870c1..0c3174e84e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,6 @@ packages/**/.turbo *.tsbuildinfo cypress/videos/* cypress/screenshots/* +cypress/downloads/* *.swp CHANGELOG-*.md diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts new file mode 100644 index 0000000000..47dcc02fcc --- /dev/null +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -0,0 +1,70 @@ +import { MainSidebar } from './../pages/sidebar/main-sidebar'; +import { INSTANCE_OWNER, BACKEND_BASE_URL } from '../constants'; +import { SigninPage } from '../pages'; +import { PersonalSettingsPage } from '../pages/settings-personal'; +import { MfaLoginPage } from '../pages/mfa-login'; + +const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; + +const RECOVERY_CODE = 'd04ea17f-e8b2-4afa-a9aa-57a2c735b30e'; + +const user = { + email: INSTANCE_OWNER.email, + password: INSTANCE_OWNER.password, + firstName: 'User', + lastName: 'A', + mfaEnabled: false, + mfaSecret: MFA_SECRET, + mfaRecoveryCodes: [RECOVERY_CODE], +}; + +const mfaLoginPage = new MfaLoginPage(); +const signinPage = new SigninPage(); +const personalSettingsPage = new PersonalSettingsPage(); +const mainSidebar = new MainSidebar(); + +describe('Two-factor authentication', () => { + beforeEach(() => { + Cypress.session.clearAllSavedSessions(); + cy.request('POST', `${BACKEND_BASE_URL}/rest/e2e/reset`, { + owner: user, + members: [], + }); + cy.on('uncaught:exception', (err, runnable) => { + expect(err.message).to.include('Not logged in'); + return false; + }); + }); + + it('Should be able to login with MFA token', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + cy.generateToken(user.mfaSecret).then((token) => { + mfaLoginPage.actions.loginWithMfaToken(email, password, token); + mainSidebar.actions.signout(); + }); + }); + + it('Should be able to login with recovery code', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + mfaLoginPage.actions.loginWithRecoveryCode(email, password, user.mfaRecoveryCodes[0]); + mainSidebar.actions.signout(); + }); + + it('Should be able to disable MFA in account', () => { + const { email, password } = user; + signinPage.actions.loginWithEmailAndPassword(email, password); + personalSettingsPage.actions.enableMfa(); + mainSidebar.actions.signout(); + cy.generateToken(user.mfaSecret).then((token) => { + mfaLoginPage.actions.loginWithMfaToken(email, password, token); + personalSettingsPage.actions.disableMfa(); + mainSidebar.actions.signout(); + }); + }); +}); diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index 4d95611a12..895ae9b3f7 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -8,3 +8,4 @@ export * from './settings-log-streaming'; export * from './sidebar'; export * from './ndv'; export * from './bannerStack'; +export * from './signin'; diff --git a/cypress/pages/mfa-login.ts b/cypress/pages/mfa-login.ts new file mode 100644 index 0000000000..50ca5adab7 --- /dev/null +++ b/cypress/pages/mfa-login.ts @@ -0,0 +1,77 @@ +import { N8N_AUTH_COOKIE } from '../constants'; +import { BasePage } from './base'; +import { SigninPage } from './signin'; +import { WorkflowsPage } from './workflows'; + +export class MfaLoginPage extends BasePage { + url = '/mfa'; + getters = { + form: () => cy.getByTestId('mfa-login-form'), + token: () => cy.getByTestId('token'), + recoveryCode: () => cy.getByTestId('recoveryCode'), + enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'), + }; + + actions = { + loginWithMfaToken: (email: string, password: string, mfaToken: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [mfaToken], + () => { + cy.visit(signinPage.url); + + signinPage.getters.form().within(() => { + signinPage.getters.email().type(email); + signinPage.getters.password().type(password); + signinPage.getters.submit().click(); + }); + + this.getters.form().within(() => { + this.getters.token().type(mfaToken); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + loginWithRecoveryCode: (email: string, password: string, recoveryCode: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [recoveryCode], + () => { + cy.visit(signinPage.url); + + signinPage.getters.form().within(() => { + signinPage.getters.email().type(email); + signinPage.getters.password().type(password); + signinPage.getters.submit().click(); + }); + + this.getters.enterRecoveryCodeButton().click(); + + this.getters.form().within(() => { + this.getters.recoveryCode().type(recoveryCode); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + }; +} diff --git a/cypress/pages/modals/mfa-setup-modal.ts b/cypress/pages/modals/mfa-setup-modal.ts new file mode 100644 index 0000000000..d127731be2 --- /dev/null +++ b/cypress/pages/modals/mfa-setup-modal.ts @@ -0,0 +1,11 @@ +import { BasePage } from './../base'; + +export class MfaSetupModal extends BasePage { + getters = { + modalContainer: () => cy.getByTestId('changePassword-modal').last(), + tokenInput: () => cy.getByTestId('mfa-token-input'), + copySecretToClipboardButton: () => cy.getByTestId('mfa-secret-button'), + downloadRecoveryCodesButton: () => cy.getByTestId('mfa-recovery-codes-button'), + saveButton: () => cy.getByTestId('mfa-save-button'), + }; +} diff --git a/cypress/pages/settings-personal.ts b/cypress/pages/settings-personal.ts index 554455ef73..bb738119ca 100644 --- a/cypress/pages/settings-personal.ts +++ b/cypress/pages/settings-personal.ts @@ -1,10 +1,14 @@ import { ChangePasswordModal } from './modals/change-password-modal'; +import { MfaSetupModal } from './modals/mfa-setup-modal'; import { BasePage } from './base'; const changePasswordModal = new ChangePasswordModal(); +const mfaSetupModal = new MfaSetupModal(); export class PersonalSettingsPage extends BasePage { url = '/settings/personal'; + secret = ''; + getters = { currentUserName: () => cy.getByTestId('current-user-name'), firstNameInput: () => cy.getByTestId('firstName').find('input').first(), @@ -13,6 +17,8 @@ export class PersonalSettingsPage extends BasePage { emailInput: () => cy.getByTestId('email').find('input').first(), changePasswordLink: () => cy.getByTestId('change-password-link').first(), saveSettingsButton: () => cy.getByTestId('save-settings-button'), + enableMfaButton: () => cy.getByTestId('enable-mfa-button'), + disableMfaButton: () => cy.getByTestId('disable-mfa-button'), }; actions = { loginAndVisit: (email: string, password: string) => { @@ -50,5 +56,21 @@ export class PersonalSettingsPage extends BasePage { this.actions.loginAndVisit(email, password); cy.url().should('match', new RegExp(this.url)); }, + enableMfa: () => { + cy.visit(this.url); + this.getters.enableMfaButton().click(); + mfaSetupModal.getters.copySecretToClipboardButton().realClick(); + cy.readClipboard().then((secret) => { + cy.generateToken(secret).then((token) => { + mfaSetupModal.getters.tokenInput().type(token); + mfaSetupModal.getters.downloadRecoveryCodesButton().click(); + mfaSetupModal.getters.saveButton().click(); + }); + }); + }, + disableMfa: () => { + cy.visit(this.url); + this.getters.disableMfaButton().click(); + }, }; } diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 789d63545f..1559d3da65 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -1,6 +1,8 @@ import { BasePage } from '../base'; import { WorkflowsPage } from '../workflows'; +const workflowsPage = new WorkflowsPage(); + export class MainSidebar extends BasePage { getters = { menuItem: (menuLabel: string) => @@ -25,7 +27,7 @@ export class MainSidebar extends BasePage { this.getters.credentials().click(); }, openUserMenu: () => { - this.getters.userMenu().find('[role="button"]').last().click(); + this.getters.userMenu().click(); }, openUserMenu: () => { this.getters.userMenu().click(); diff --git a/cypress/pages/signin.ts b/cypress/pages/signin.ts new file mode 100644 index 0000000000..1b2b35c22f --- /dev/null +++ b/cypress/pages/signin.ts @@ -0,0 +1,41 @@ +import { N8N_AUTH_COOKIE } from '../constants'; +import { BasePage } from './base'; +import { WorkflowsPage } from './workflows'; + +export class SigninPage extends BasePage { + url = '/signin'; + getters = { + form: () => cy.getByTestId('auth-form'), + email: () => cy.getByTestId('email'), + password: () => cy.getByTestId('password'), + submit: () => cy.get('button'), + }; + + actions = { + loginWithEmailAndPassword: (email: string, password: string) => { + const signinPage = new SigninPage(); + const workflowsPage = new WorkflowsPage(); + + cy.session( + [email, password], + () => { + cy.visit(signinPage.url); + + this.getters.form().within(() => { + this.getters.email().type(email); + this.getters.password().type(password); + this.getters.submit().click(); + }); + + // we should be redirected to /workflows + cy.url().should('include', workflowsPage.url); + }, + { + validate() { + cy.getCookie(N8N_AUTH_COOKIE).should('exist'); + }, + }, + ); + }, + }; +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c215bdf3f7..48f2515011 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,6 +1,7 @@ import 'cypress-real-events'; import { WorkflowPage } from '../pages'; import { BACKEND_BASE_URL, N8N_AUTH_COOKIE } from '../constants'; +import generateOTPToken from 'cypress-otp'; Cypress.Commands.add('getByTestId', (selector, ...args) => { return cy.get(`[data-test-id="${selector}"]`, ...args); @@ -41,14 +42,13 @@ Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => { Cypress.Commands.add('signin', ({ email, password }) => { Cypress.session.clearAllSavedSessions(); - cy.session( - [email, password], - () => cy.request('POST', `${BACKEND_BASE_URL}/rest/login`, { email, password }), - { - validate() { - cy.getCookie(N8N_AUTH_COOKIE).should('exist'); - }, - }, + cy.session([email, password], () => + cy.request({ + method: 'POST', + url: `${BACKEND_BASE_URL}/rest/login`, + body: { email, password }, + failOnStatusCode: false, + }), ); }); @@ -162,3 +162,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => { } }); }); + +Cypress.Commands.add('generateToken', (secret: string) => { + return generateOTPToken(secret); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 37140351ff..bd74453bf8 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -37,6 +37,7 @@ declare global { options?: { abs?: boolean; index?: number; realMouse?: boolean }, ): void; draganddrop(draggableSelector: string, droppableSelector: string): void; + generateToken(mfaSecret: string): Chainable; } } } diff --git a/package.json b/package.json index da405a61e6..748f6a951d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/supertest": "^2.0.12", "@vitest/coverage-v8": "^0.33.0", "cross-env": "^7.0.3", + "cypress-otp": "^1.0.3", "cypress": "^12.17.2", "cypress-real-events": "^1.9.1", "jest": "^29.6.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 80e6a29656..2cc9396c4b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -159,6 +159,7 @@ "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "openapi-types": "^10.0.0", + "otpauth": "^9.1.1", "p-cancelable": "^2.0.0", "p-lazy": "^3.1.0", "passport": "^0.6.0", diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 41ca4088f1..4de5209a0f 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -758,6 +758,7 @@ export interface PublicUser { passwordResetToken?: string; createdAt: Date; isPending: boolean; + hasRecoveryCodesLeft: boolean; globalRole?: Role; signInType: AuthProviderType; disabled: boolean; diff --git a/packages/cli/src/Mfa/constants.ts b/packages/cli/src/Mfa/constants.ts new file mode 100644 index 0000000000..bf5e34c345 --- /dev/null +++ b/packages/cli/src/Mfa/constants.ts @@ -0,0 +1 @@ +export const MFA_FEATURE_ENABLED = 'mfa.enabled'; diff --git a/packages/cli/src/Mfa/helpers.ts b/packages/cli/src/Mfa/helpers.ts new file mode 100644 index 0000000000..8484572533 --- /dev/null +++ b/packages/cli/src/Mfa/helpers.ts @@ -0,0 +1,21 @@ +import config from '@/config'; +import * as Db from '@/Db'; +import { MFA_FEATURE_ENABLED } from './constants'; + +export const isMfaFeatureEnabled = () => config.get(MFA_FEATURE_ENABLED); + +const isMfaFeatureDisabled = () => !isMfaFeatureEnabled(); + +const getUsersWithMfaEnabled = async () => + Db.collections.User.count({ where: { mfaEnabled: true } }); + +export const handleMfaDisable = async () => { + if (isMfaFeatureDisabled()) { + // check for users with MFA enabled, and if there are + // users, then keep the feature enabled + const users = await getUsersWithMfaEnabled(); + if (users) { + config.set(MFA_FEATURE_ENABLED, true); + } + } +}; diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts new file mode 100644 index 0000000000..50b0d29f89 --- /dev/null +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -0,0 +1,79 @@ +import { v4 as uuid } from 'uuid'; +import { AES, enc } from 'crypto-js'; +import { TOTPService } from './totp.service'; +import { Service } from 'typedi'; +import { UserRepository } from '@/databases/repositories'; + +@Service() +export class MfaService { + constructor( + private userRepository: UserRepository, + public totp: TOTPService, + private encryptionKey: string, + ) {} + + public generateRecoveryCodes(n = 10) { + return Array.from(Array(n)).map(() => uuid()); + } + + public generateEncryptedRecoveryCodes() { + return this.generateRecoveryCodes().map((code) => + AES.encrypt(code, this.encryptionKey).toString(), + ); + } + + public async saveSecretAndRecoveryCodes(userId: string, secret: string, recoveryCodes: string[]) { + const { encryptedSecret, encryptedRecoveryCodes } = this.encryptSecretAndRecoveryCodes( + secret, + recoveryCodes, + ); + return this.userRepository.update(userId, { + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }); + } + + public encryptSecretAndRecoveryCodes(rawSecret: string, rawRecoveryCodes: string[]) { + const encryptedSecret = AES.encrypt(rawSecret, this.encryptionKey).toString(), + encryptedRecoveryCodes = rawRecoveryCodes.map((code) => + AES.encrypt(code, this.encryptionKey).toString(), + ); + return { + encryptedRecoveryCodes, + encryptedSecret, + }; + } + + private decryptSecretAndRecoveryCodes(mfaSecret: string, mfaRecoveryCodes: string[]) { + return { + decryptedSecret: AES.decrypt(mfaSecret, this.encryptionKey).toString(enc.Utf8), + decryptedRecoveryCodes: mfaRecoveryCodes.map((code) => + AES.decrypt(code, this.encryptionKey).toString(enc.Utf8), + ), + }; + } + + public async getSecretAndRecoveryCodes(userId: string) { + const { mfaSecret, mfaRecoveryCodes } = await this.userRepository.findOneOrFail({ + where: { id: userId }, + select: ['id', 'mfaSecret', 'mfaRecoveryCodes'], + }); + return this.decryptSecretAndRecoveryCodes(mfaSecret ?? '', mfaRecoveryCodes ?? []); + } + + public async enableMfa(userId: string) { + await this.userRepository.update(userId, { mfaEnabled: true }); + } + + public encryptRecoveryCodes(mfaRecoveryCodes: string[]) { + return mfaRecoveryCodes.map((code) => AES.encrypt(code, this.encryptionKey).toString()); + } + + public async disableMfa(userId: string) { + await this.userRepository.update(userId, { + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + }); + } +} diff --git a/packages/cli/src/Mfa/totp.service.ts b/packages/cli/src/Mfa/totp.service.ts new file mode 100644 index 0000000000..941dcc91e3 --- /dev/null +++ b/packages/cli/src/Mfa/totp.service.ts @@ -0,0 +1,36 @@ +import OTPAuth from 'otpauth'; +export class TOTPService { + generateSecret(): string { + return new OTPAuth.Secret()?.base32; + } + + generateTOTPUri({ + issuer = 'n8n', + secret, + label, + }: { + secret: string; + label: string; + issuer?: string; + }) { + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + issuer, + label, + }).toString(); + } + + verifySecret({ secret, token, window = 1 }: { secret: string; token: string; window?: number }) { + return new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + }).validate({ token, window }) === null + ? false + : true; + } + + generateTOTP(secret: string) { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + }); + } +} diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 968b637fe2..0d87b77f64 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -45,8 +45,8 @@ export class BadRequestError extends ResponseError { } export class AuthError extends ResponseError { - constructor(message: string) { - super(message, 401); + constructor(message: string, errorCode?: number) { + super(message, 401, errorCode); } } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index dd627551a0..372071ac19 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -88,6 +88,7 @@ import { AuthController, LdapController, MeController, + MFAController, NodesController, NodeTypesController, OwnerController, @@ -167,6 +168,9 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; import { ExecutionRepository } from '@db/repositories'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; +import { TOTPService } from './Mfa/totp.service'; +import { MfaService } from './Mfa/mfa.service'; +import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; const exec = promisify(callbackExec); @@ -313,6 +317,9 @@ export class Server extends AbstractServer { showNonProdBanner: false, debugInEditor: false, }, + mfa: { + enabled: false, + }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', @@ -471,6 +478,9 @@ export class Server extends AbstractServer { if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } + + this.frontendSettings.mfa.enabled = isMfaFeatureEnabled(); + return this.frontendSettings; } @@ -479,31 +489,19 @@ export class Server extends AbstractServer { const repositories = Db.collections; setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint); + const encryptionKey = await UserSettings.getEncryptionKey(); + const logger = LoggerProxy; const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); const postHog = this.postHog; + const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); const controllers: object[] = [ new EventBusController(), - new AuthController({ - config, - internalHooks, - repositories, - logger, - postHog, - }), - new OwnerController({ - config, - internalHooks, - repositories, - logger, - }), - new MeController({ - externalHooks, - internalHooks, - logger, - }), + new AuthController({ config, internalHooks, logger, postHog, mfaService }), + new OwnerController({ config, internalHooks, repositories, logger, postHog }), + new MeController({ externalHooks, internalHooks, logger }), new NodeTypesController({ config, nodeTypes }), new PasswordResetController({ config, @@ -511,6 +509,7 @@ export class Server extends AbstractServer { internalHooks, mailer, logger, + mfaService, }), Container.get(TagsController), new TranslationController(config, this.credentialTypes), @@ -546,6 +545,10 @@ export class Server extends AbstractServer { controllers.push(Container.get(E2EController)); } + if (isMfaFeatureEnabled()) { + controllers.push(new MFAController(mfaService)); + } + controllers.forEach((controller) => registerController(app, config, controller)); } @@ -623,6 +626,8 @@ export class Server extends AbstractServer { await handleLdapInit(); + await handleMfaDisable(); + await this.registerControllers(ignoredEndpoints); this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 5c119df0de..23e4b21ace 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -88,21 +88,26 @@ export function validatePassword(password?: string): string { * Remove sensitive properties from the user to return to the client. */ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { - const { password, updatedAt, apiKey, authIdentities, ...rest } = user; + const { password, updatedAt, apiKey, authIdentities, mfaSecret, mfaRecoveryCodes, ...rest } = + user; if (withoutKeys) { withoutKeys.forEach((key) => { // @ts-ignore delete rest[key]; }); } + const sanitizedUser: PublicUser = { ...rest, signInType: 'email', + hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length, }; + const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap'); if (ldapIdentity) { sanitizedUser.signInType = 'ldap'; } + return sanitizedUser; } diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts new file mode 100644 index 0000000000..6c5eb2fb9c --- /dev/null +++ b/packages/cli/src/commands/mfa/disable.ts @@ -0,0 +1,55 @@ +import { flags } from '@oclif/command'; +import * as Db from '@/Db'; +import { BaseCommand } from '../BaseCommand'; + +export class DisableMFACommand extends BaseCommand { + static description = 'Disable MFA authentication for a user'; + + static examples = ['$ n8n mfa:disable --email=johndoe@example.com']; + + static flags = { + help: flags.help({ char: 'h' }), + email: flags.string({ + description: 'The email of the user to disable the MFA authentication', + }), + }; + + async init() { + await super.init(); + } + + async run(): Promise { + // eslint-disable-next-line @typescript-eslint/no-shadow + const { flags } = this.parse(DisableMFACommand); + + if (!flags.email) { + this.logger.info('An email with --email must be provided'); + return; + } + + const updateOperationResult = await Db.collections.User.update( + { email: flags.email }, + { mfaSecret: null, mfaRecoveryCodes: [], mfaEnabled: false }, + ); + + if (!updateOperationResult.affected) { + this.reportUserDoesNotExistError(flags.email); + return; + } + + this.reportSuccess(flags.email); + } + + async catch(error: Error) { + this.logger.error('An error occurred while disabling MFA in account'); + this.logger.error(error.message); + } + + private reportSuccess(email: string) { + this.logger.info(`Successfully disabled MFA for user with email: ${email}`); + } + + private reportUserDoesNotExistError(email: string) { + this.logger.info(`User with email: ${email} does not exist`); + } +} diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 567777a39b..742407853c 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -929,6 +929,15 @@ export const schema = { }, }, + mfa: { + enabled: { + format: Boolean, + default: true, + doc: 'Whether to enable MFA feature in instance.', + env: 'N8N_MFA_ENABLED', + }, + }, + sso: { justInTimeProvisioning: { format: Boolean, diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 0ee765760b..820e4f269d 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -16,12 +16,7 @@ import type { ILogger } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { LoginRequest, UserRequest } from '@/requests'; import type { Config } from '@/config'; -import type { - PublicUser, - IDatabaseCollections, - IInternalHooksClass, - CurrentUser, -} from '@/Interfaces'; +import type { PublicUser, IInternalHooksClass, CurrentUser } from '@/Interfaces'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; import type { PostHogClient } from '@/posthog'; import { @@ -32,6 +27,7 @@ import { import { InternalHooks } from '../InternalHooks'; import { License } from '@/License'; import { UserService } from '@/services/user.service'; +import type { MfaService } from '@/Mfa/mfa.service'; @RestController() export class AuthController { @@ -45,23 +41,27 @@ export class AuthController { private readonly postHog?: PostHogClient; + private readonly mfaService: MfaService; + constructor({ config, logger, internalHooks, postHog, + mfaService, }: { config: Config; logger: ILogger; internalHooks: IInternalHooksClass; - repositories: Pick; postHog?: PostHogClient; + mfaService: MfaService; }) { this.config = config; this.logger = logger; this.internalHooks = internalHooks; this.postHog = postHog; this.userService = Container.get(UserService); + this.mfaService = mfaService; } /** @@ -69,7 +69,7 @@ export class AuthController { */ @Post('/login') async login(req: LoginRequest, res: Response): Promise { - const { email, password } = req.body; + const { email, password, mfaToken, mfaRecoveryCode } = req.body; if (!email) throw new Error('Email is required to log in'); if (!password) throw new Error('Password is required to log in'); @@ -94,7 +94,28 @@ export class AuthController { } else { user = await handleEmailLogin(email, password); } + if (user) { + if (user.mfaEnabled) { + if (!mfaToken && !mfaRecoveryCode) { + throw new AuthError('MFA Error', 998); + } + + const { decryptedRecoveryCodes, decryptedSecret } = + await this.mfaService.getSecretAndRecoveryCodes(user.id); + + user.mfaSecret = decryptedSecret; + user.mfaRecoveryCodes = decryptedRecoveryCodes; + + const isMFATokenValid = + (await this.validateMfaToken(user, mfaToken)) || + (await this.validateMfaRecoveryCode(user, mfaRecoveryCode)); + + if (!isMFATokenValid) { + throw new AuthError('Invalid mfa token or recovery code'); + } + } + await issueCookie(res, user); void Container.get(InternalHooks).onUserLoginSuccess({ user, @@ -229,4 +250,27 @@ export class AuthController { res.clearCookie(AUTH_COOKIE_NAME); return { loggedOut: true }; } + + private async validateMfaToken(user: User, token?: string) { + if (!!!token) return false; + return this.mfaService.totp.verifySecret({ + secret: user.mfaSecret ?? '', + token, + }); + } + + private async validateMfaRecoveryCode(user: User, mfaRecoveryCode?: string) { + if (!!!mfaRecoveryCode) return false; + const index = user.mfaRecoveryCodes.indexOf(mfaRecoveryCode); + if (index === -1) return false; + + // remove used recovery code + user.mfaRecoveryCodes.splice(index, 1); + + await this.userService.update(user.id, { + mfaRecoveryCodes: this.mfaService.encryptRecoveryCodes(user.mfaRecoveryCodes), + }); + + return true; + } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index d2497eddca..d58e21f4f4 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -12,6 +12,9 @@ import { LICENSE_FEATURES, inE2ETests } from '@/constants'; import { NoAuthRequired, Patch, Post, RestController } from '@/decorators'; import type { UserSetupPayload } from '@/requests'; import type { BooleanLicenseFeature } from '@/Interfaces'; +import { UserSettings } from 'n8n-core'; +import { MfaService } from '@/Mfa/mfa.service'; +import { TOTPService } from '@/Mfa/totp.service'; if (!inE2ETests) { console.error('E2E endpoints only allowed during E2E tests'); @@ -136,13 +139,30 @@ export class E2EController { roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })), ); - const users = []; - users.push({ + const encryptionKey = await UserSettings.getEncryptionKey(); + + const mfaService = new MfaService(this.userRepo, new TOTPService(), encryptionKey); + + const instanceOwner = { id: uuid(), ...owner, password: await hashPassword(owner.password), globalRoleId: globalOwnerRoleId, - }); + }; + + if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) { + const { encryptedRecoveryCodes, encryptedSecret } = mfaService.encryptSecretAndRecoveryCodes( + owner.mfaSecret, + owner.mfaRecoveryCodes, + ); + instanceOwner.mfaSecret = encryptedSecret; + instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes; + } + + const users = []; + + users.push(instanceOwner); + for (const { password, ...payload } of members) { users.push( this.userRepo.create({ diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index c3e742924e..2091b95a30 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -1,6 +1,7 @@ export { AuthController } from './auth.controller'; export { LdapController } from './ldap.controller'; export { MeController } from './me.controller'; +export { MFAController } from './mfa.controller'; export { NodesController } from './nodes.controller'; export { NodeTypesController } from './nodeTypes.controller'; export { OwnerController } from './owner.controller'; diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts new file mode 100644 index 0000000000..70e3444851 --- /dev/null +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -0,0 +1,96 @@ +import { Authorized, Delete, Get, Post, RestController } from '@/decorators'; +import { AuthenticatedRequest, MFA } from '@/requests'; +import { BadRequestError } from '@/ResponseHelper'; +import { MfaService } from '@/Mfa/mfa.service'; +@Authorized() +@RestController('/mfa') +export class MFAController { + constructor(private mfaService: MfaService) {} + + @Get('/qr') + async getQRCode(req: AuthenticatedRequest) { + const { email, id, mfaEnabled } = req.user; + + if (mfaEnabled) + throw new BadRequestError( + 'MFA already enabled. Disable it to generate new secret and recovery codes', + ); + + const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = + await this.mfaService.getSecretAndRecoveryCodes(id); + + if (secret && recoveryCodes.length) { + const qrCode = this.mfaService.totp.generateTOTPUri({ + secret, + label: email, + }); + + return { + secret, + recoveryCodes, + qrCode, + }; + } + + const newRecoveryCodes = this.mfaService.generateRecoveryCodes(); + + const newSecret = this.mfaService.totp.generateSecret(); + + const qrCode = this.mfaService.totp.generateTOTPUri({ secret: newSecret, label: email }); + + await this.mfaService.saveSecretAndRecoveryCodes(id, newSecret, newRecoveryCodes); + + return { + secret: newSecret, + qrCode, + recoveryCodes: newRecoveryCodes, + }; + } + + @Post('/enable') + async activateMFA(req: MFA.Activate) { + const { token = null } = req.body; + const { id, mfaEnabled } = req.user; + + const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } = + await this.mfaService.getSecretAndRecoveryCodes(id); + + if (!token) throw new BadRequestError('Token is required to enable MFA feature'); + + if (mfaEnabled) throw new BadRequestError('MFA already enabled'); + + if (!secret || !recoveryCodes.length) { + throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes'); + } + + const verified = this.mfaService.totp.verifySecret({ secret, token, window: 10 }); + + if (!verified) + throw new BadRequestError('MFA token expired. Close the modal and enable MFA again', 997); + + await this.mfaService.enableMfa(id); + } + + @Delete('/disable') + async disableMFA(req: AuthenticatedRequest) { + const { id } = req.user; + + await this.mfaService.disableMfa(id); + } + + @Post('/verify') + async verifyMFA(req: MFA.Verify) { + const { id } = req.user; + const { token } = req.body; + + const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id); + + if (!token) throw new BadRequestError('Token is required to enable MFA feature'); + + if (!secret) throw new BadRequestError('No MFA secret se for this user'); + + const verified = this.mfaService.totp.verifySecret({ secret, token }); + + if (!verified) throw new BadRequestError('MFA secret could not be verified'); + } +} diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index e012f0409a..a0e34e8d28 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -30,6 +30,7 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { TokenExpiredError } from 'jsonwebtoken'; import type { JwtPayload } from '@/services/jwt.service'; import { JwtService } from '@/services/jwt.service'; +import type { MfaService } from '@/Mfa/mfa.service'; @RestController() export class PasswordResetController { @@ -47,18 +48,22 @@ export class PasswordResetController { private readonly userService: UserService; + private readonly mfaService: MfaService; + constructor({ config, logger, externalHooks, internalHooks, mailer, + mfaService, }: { config: Config; logger: ILogger; externalHooks: IExternalHooksClass; internalHooks: IInternalHooksClass; mailer: UserManagementMailer; + mfaService: MfaService; }) { this.config = config; this.logger = logger; @@ -67,6 +72,7 @@ export class PasswordResetController { this.mailer = mailer; this.jwtService = Container.get(JwtService); this.userService = Container.get(UserService); + this.mfaService = mfaService; } /** @@ -150,7 +156,11 @@ export class PasswordResetController { }, ); - const url = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken); + const url = this.userService.generatePasswordResetUrl( + baseUrl, + resetPasswordToken, + user.mfaEnabled, + ); try { await this.mailer.passwordReset({ @@ -233,7 +243,7 @@ export class PasswordResetController { */ @Post('/change-password') async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { - const { token: resetPasswordToken, password } = req.body; + const { token: resetPasswordToken, password, mfaToken } = req.body; if (!resetPasswordToken || !password) { this.logger.debug( @@ -264,6 +274,16 @@ export class PasswordResetController { throw new NotFoundError(''); } + if (user.mfaEnabled) { + if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.'); + + const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id); + + const validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken }); + + if (!validToken) throw new BadRequestError('Invalid MFA token.'); + } + const passwordHash = await hashPassword(validPassword); await this.userService.update(user.id, { password: passwordHash }); diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 93c1a61ff4..1cbf352557 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -389,7 +389,11 @@ export class UsersController { const baseUrl = getInstanceBaseUrl(); - const link = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken); + const link = this.userService.generatePasswordResetUrl( + baseUrl, + resetPasswordToken, + user.mfaEnabled, + ); return { link, }; diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index 15370aade6..a32c9f2dc0 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -96,6 +96,15 @@ export class User extends WithTimestamps implements IUser { @Index({ unique: true }) apiKey?: string | null; + @Column({ type: Boolean, default: false }) + mfaEnabled: boolean; + + @Column({ type: String, nullable: true, select: false }) + mfaSecret?: string | null; + + @Column({ type: 'simple-array', default: '', select: false }) + mfaRecoveryCodes: string[]; + /** * Whether the user is pending setup completion. */ diff --git a/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts b/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts new file mode 100644 index 0000000000..2c044f6dce --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1690000000040-AddMfaColumns.ts @@ -0,0 +1,35 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +import { TableColumn } from 'typeorm'; + +export class AddMfaColumns1690000000030 implements ReversibleMigration { + async up({ queryRunner, tablePrefix }: MigrationContext) { + await queryRunner.addColumns(`${tablePrefix}user`, [ + new TableColumn({ + name: 'mfaEnabled', + type: 'boolean', + isNullable: false, + default: false, + }), + new TableColumn({ + name: 'mfaSecret', + type: 'text', + isNullable: true, + default: null, + }), + new TableColumn({ + name: 'mfaRecoveryCodes', + type: 'text', + isNullable: true, + default: null, + }), + ]); + } + + async down({ queryRunner, tablePrefix }: MigrationContext) { + await queryRunner.dropColumns(`${tablePrefix}user`, [ + 'mfaEnabled', + 'mfaSecret', + 'mfaRecoveryCodes', + ]); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index ca606867ae..3e88fe923b 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -44,6 +44,7 @@ import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionD import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -91,4 +92,5 @@ export const mysqlMigrations: Migration[] = [ RemoveSkipOwnerSetup1681134145997, RemoveResetPasswordColumns1690000000030, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 554bb13114..2ceb7281ae 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -42,6 +42,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; import { AddMissingPrimaryKeyOnExecutionData1690787606731 } from './1690787606731-AddMissingPrimaryKeyOnExecutionData'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -87,4 +88,5 @@ export const postgresMigrations: Migration[] = [ RemoveResetPasswordColumns1690000000030, AddMissingPrimaryKeyOnExecutionData1690787606731, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index eeb0cc4995..ea5c970cfc 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -41,6 +41,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration'; import { RemoveResetPasswordColumns1690000000030 } from './1690000000030-RemoveResetPasswordColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -85,6 +86,7 @@ const sqliteMigrations: Migration[] = [ FixMissingIndicesFromStringIdMigration1690000000020, RemoveResetPasswordColumns1690000000030, CreateWorkflowNameIndex1691088862123, + AddMfaColumns1690000000030, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7fc0bb94f6..ecee712f22 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -227,7 +227,7 @@ export declare namespace MeRequest { export type Password = AuthenticatedRequest< {}, {}, - { currentPassword: string; newPassword: string } + { currentPassword: string; newPassword: string; token?: string } >; export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record | {}>; } @@ -237,6 +237,9 @@ export interface UserSetupPayload { password: string; firstName: string; lastName: string; + mfaEnabled?: boolean; + mfaSecret?: string; + mfaRecoveryCodes?: string[]; } // ---------------------------------- @@ -261,7 +264,7 @@ export declare namespace PasswordResetRequest { export type NewPassword = AuthlessRequest< {}, {}, - Pick & { token?: string; userId?: string } + Pick & { token?: string; userId?: string; mfaToken?: string } >; } @@ -332,9 +335,27 @@ export type LoginRequest = AuthlessRequest< { email: string; password: string; + mfaToken?: string; + mfaRecoveryCode?: string; } >; +// ---------------------------------- +// MFA endpoints +// ---------------------------------- + +export declare namespace MFA { + type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; + type ValidateRecoveryCode = AuthenticatedRequest< + {}, + {}, + { recoveryCode: { enabled: boolean } }, + {} + >; +} + // ---------------------------------- // oauth endpoints // ---------------------------------- diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 678482a340..5e01ef6a85 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -51,10 +51,11 @@ export class UserService { return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); } - generatePasswordResetUrl(instanceBaseUrl: string, token: string) { + generatePasswordResetUrl(instanceBaseUrl: string, token: string, mfaEnabled: boolean) { const url = new URL(`${instanceBaseUrl}/change-password`); url.searchParams.append('token', token); + url.searchParams.append('mfaEnabled', mfaEnabled.toString()); return url.toString(); } diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts new file mode 100644 index 0000000000..3914a42843 --- /dev/null +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -0,0 +1,405 @@ +import config from '@/config'; +import * as Db from '@/Db'; +import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import * as testDb from './../shared/testDb'; +import * as utils from '../shared/utils'; +import { randomPassword } from '@/Ldap/helpers'; +import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random'; +import { TOTPService } from '@/Mfa/totp.service'; +import Container from 'typedi'; +import { JwtService } from '@/services/jwt.service'; + +jest.mock('@/telemetry'); + +let globalOwnerRole: Role; +let owner: User; + +const testServer = utils.setupTestServer({ + endpointGroups: ['mfa', 'auth', 'me', 'passwordReset'], +}); + +beforeEach(async () => { + await testDb.truncate(['User']); + + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + config.set('userManagement.disabled', false); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('Enable MFA setup', () => { + describe('Step one', () => { + test('GET /qr should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.get('/mfa/qr'); + + expect(response.statusCode).toBe(401); + }); + + test('GET /qr should reuse secret and recovery codes until setup is complete', async () => { + const firstCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const secondCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(firstCall.body.data.secret).toBe(secondCall.body.data.secret); + expect(firstCall.body.data.recoveryCodes.join('')).toBe( + secondCall.body.data.recoveryCodes.join(''), + ); + + await testServer.authAgentFor(owner).delete('/mfa/disable'); + + const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(firstCall.body.data.secret).not.toBe(thirdCall.body.data.secret); + expect(firstCall.body.data.recoveryCodes.join('')).not.toBe( + thirdCall.body.data.recoveryCodes.join(''), + ); + }); + + test('GET /qr should return qr, secret and recovery codes', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + expect(response.statusCode).toBe(200); + + const { data } = response.body; + + expect(data.secret).toBeDefined(); + expect(data.qrCode).toBeDefined(); + expect(data.recoveryCodes).toBeDefined(); + expect(data.recoveryCodes).not.toBeEmptyArray(); + expect(data.recoveryCodes.length).toBe(10); + }); + }); + + describe('Step two', () => { + test('POST /verify should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.post('/mfa/verify'); + + expect(response.statusCode).toBe(401); + }); + + test('POST /verify should fail due to invalid MFA token', async () => { + const response = await testServer + .authAgentFor(owner) + .post('/mfa/verify') + .send({ token: '123' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /verify should fail due to missing token parameter', async () => { + await testServer.authAgentFor(owner).get('/mfa/qr'); + + const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /verify should validate MFA token', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const { secret } = response.body.data; + + const token = new TOTPService().generateTOTP(secret); + + const { statusCode } = await testServer + .authAgentFor(owner) + .post('/mfa/verify') + .send({ token }); + + expect(statusCode).toBe(200); + }); + }); + + describe('Step three', () => { + test('POST /enable should fail due to unauthenticated user', async () => { + const response = await testServer.authlessAgent.post('/mfa/enable'); + + expect(response.statusCode).toBe(401); + }); + + test('POST /verify should fail due to missing token parameter', async () => { + const response = await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should fail due to invalid MFA token', async () => { + await testServer.authAgentFor(owner).get('/mfa/qr'); + + const response = await testServer + .authAgentFor(owner) + .post('/mfa/enable') + .send({ token: '123' }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should fail due to empty secret and recovery codes', async () => { + const response = await testServer.authAgentFor(owner).post('/mfa/enable'); + + expect(response.statusCode).toBe(400); + }); + + test('POST /enable should enable MFA in account', async () => { + const response = await testServer.authAgentFor(owner).get('/mfa/qr'); + + const { secret } = response.body.data; + + const token = new TOTPService().generateTOTP(secret); + + await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }); + + const { statusCode } = await testServer + .authAgentFor(owner) + .post('/mfa/enable') + .send({ token }); + + expect(statusCode).toBe(200); + + const user = await Db.collections.User.findOneOrFail({ + where: {}, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + expect(user.mfaEnabled).toBe(true); + expect(user.mfaRecoveryCodes).toBeDefined(); + expect(user.mfaSecret).toBeDefined(); + }); + }); +}); + +describe('Disable MFA setup', () => { + test('POST /disable should disable login with MFA', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authAgentFor(user).delete('/mfa/disable'); + + expect(response.statusCode).toBe(200); + + const dbUser = await Db.collections.User.findOneOrFail({ + where: { id: user.id }, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + expect(dbUser.mfaEnabled).toBe(false); + expect(dbUser.mfaSecret).toBe(null); + expect(dbUser.mfaRecoveryCodes.length).toBe(0); + }); +}); + +describe('Change password with MFA enabled', () => { + test('PATCH /me/password should fail due to missing MFA token', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomPassword(); + + const response = await testServer + .authAgentFor(user) + .patch('/me/password') + .send({ currentPassword: rawPassword, newPassword }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should fail due to missing MFA token', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + const resetPasswordToken = uniqueId(); + + const response = await testServer.authlessAgent + .post('/change-password') + .send({ password: newPassword, token: resetPasswordToken }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should fail due to invalid MFA token', async () => { + const { user } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + const resetPasswordToken = uniqueId(); + + const response = await testServer.authlessAgent.post('/change-password').send({ + password: newPassword, + token: resetPasswordToken, + mfaToken: randomDigit(), + }); + + expect(response.statusCode).toBe(400); + }); + + test('POST /change-password should update password', async () => { + const { user, rawSecret } = await testDb.createUserWithMfaEnabled(); + + const newPassword = randomValidPassword(); + + config.set('userManagement.jwtSecret', randomString(5, 10)); + + const jwtService = Container.get(JwtService); + + const resetPasswordToken = jwtService.signData({ sub: user.id }); + + const mfaToken = new TOTPService().generateTOTP(rawSecret); + + const response = await testServer.authlessAgent.post('/change-password').send({ + password: newPassword, + token: resetPasswordToken, + mfaToken, + }); + + expect(response.statusCode).toBe(200); + + const loginResponse = await testServer + .authAgentFor(user) + .post('/login') + .send({ + email: user.email, + password: newPassword, + mfaToken: new TOTPService().generateTOTP(rawSecret), + }); + + expect(loginResponse.statusCode).toBe(200); + expect(loginResponse.body).toHaveProperty('data'); + }); +}); + +describe('Login', () => { + test('POST /login with email/password should succeed when mfa is disabled', async () => { + const password = randomPassword(); + + const user = await testDb.createUser({ password }); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password }); + + expect(response.statusCode).toBe(200); + }); + + test('GET /login should include hasRecoveryCodesLeft property in response', async () => { + const response = await testServer.authAgentFor(owner).get('/login'); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + + expect(data.hasRecoveryCodesLeft).toBeDefined(); + }); + + test('GET /login should not include mfaSecret and mfaRecoveryCodes property in response', async () => { + const response = await testServer.authAgentFor(owner).get('/login'); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + + expect(data.recoveryCodes).not.toBeDefined(); + expect(data.mfaSecret).not.toBeDefined(); + }); + + test('POST /login with email/password should fail when mfa is enabled', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword }); + + expect(response.statusCode).toBe(401); + }); + + describe('Login with MFA token', () => { + test('POST /login should fail due to invalid MFA token', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' }); + + expect(response.statusCode).toBe(401); + }); + + test('POST /login should fail due two MFA step needed', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword }); + + expect(response.statusCode).toBe(401); + expect(response.body.code).toBe(998); + }); + + test('POST /login should succeed with MFA token', async () => { + const { user, rawSecret, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const token = new TOTPService().generateTOTP(rawSecret); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaToken: token }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + }); + }); + + describe('Login with recovery code', () => { + test('POST /login should fail due to invalid MFA recovery code', async () => { + const { user, rawPassword } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: 'wrongvalue' }); + + expect(response.statusCode).toBe(401); + }); + + test('POST /login should succeed with MFA recovery code', async () => { + const { user, rawPassword, rawRecoveryCodes } = await testDb.createUserWithMfaEnabled(); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + expect(data.hasRecoveryCodesLeft).toBe(true); + + const dbUser = await Db.collections.User.findOneOrFail({ + where: { id: user.id }, + select: ['mfaEnabled', 'mfaRecoveryCodes', 'mfaSecret'], + }); + + // Make sure the recovery code used was removed + expect(dbUser.mfaRecoveryCodes.length).toBe(rawRecoveryCodes.length - 1); + expect(dbUser.mfaRecoveryCodes.includes(rawRecoveryCodes[0])).toBe(false); + }); + + test('POST /login with MFA recovery code should update hasRecoveryCodesLeft property', async () => { + const { user, rawPassword, rawRecoveryCodes } = await testDb.createUserWithMfaEnabled({ + numberOfRecoveryCodes: 1, + }); + + const response = await testServer.authlessAgent + .post('/login') + .send({ email: user.email, password: rawPassword, mfaRecoveryCode: rawRecoveryCodes[0] }); + + const data = response.body.data; + + expect(response.statusCode).toBe(200); + expect(data.mfaEnabled).toBe(true); + expect(data.hasRecoveryCodesLeft).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 58ae9cef60..ff8f598eca 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -21,7 +21,6 @@ import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { ICredentialsDb } from '@/Interfaces'; - import { DB_INITIALIZATION_TIMEOUT } from './constants'; import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random'; import type { @@ -38,6 +37,10 @@ import { VariablesService } from '@/environments/variables/variables.service'; import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories'; import { separate } from '@/utils'; +import { randomPassword } from '@/Ldap/helpers'; +import { TOTPService } from '@/Mfa/totp.service'; +import { MfaService } from '@/Mfa/mfa.service'; + export type TestDBType = 'postgres' | 'mysql'; export const testDbPrefix = 'n8n_test_'; @@ -204,6 +207,41 @@ export async function createLdapUser(attributes: Partial, ldapId: string): return user; } +export async function createUserWithMfaEnabled( + data: { numberOfRecoveryCodes: number } = { numberOfRecoveryCodes: 10 }, +) { + const encryptionKey = await UserSettings.getEncryptionKey(); + + const email = randomEmail(); + const password = randomPassword(); + + const toptService = new TOTPService(); + + const secret = toptService.generateSecret(); + + const mfaService = new MfaService(Db.collections.User, toptService, encryptionKey); + + const recoveryCodes = mfaService.generateRecoveryCodes(data.numberOfRecoveryCodes); + + const { encryptedSecret, encryptedRecoveryCodes } = mfaService.encryptSecretAndRecoveryCodes( + secret, + recoveryCodes, + ); + + return { + user: await createUser({ + mfaEnabled: true, + password, + email, + mfaSecret: encryptedSecret, + mfaRecoveryCodes: encryptedRecoveryCodes, + }), + rawPassword: password, + rawSecret: secret, + rawRecoveryCodes: recoveryCodes, + }; +} + export async function createOwner() { return createUser({ globalRole: await getGlobalOwnerRole() }); } @@ -592,13 +630,12 @@ const baseOptions = (type: TestDBType) => ({ /** * Generate options for a bootstrap DB connection, to create and drop test databases. */ -export const getBootstrapDBOptions = (type: TestDBType) => - ({ - type, - name: type, - database: type, - ...baseOptions(type), - }) as const; +export const getBootstrapDBOptions = (type: TestDBType) => ({ + type, + name: type, + database: type, + ...baseOptions(type), +}); const getDBOptions = (type: TestDBType, name: string) => ({ type, diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 2b483d4032..b1fa62a11e 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -26,6 +26,7 @@ export type EndpointGroup = | 'license' | 'variables' | 'tags' + | 'mfa' | 'metrics'; export interface SetupProps { diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index ab098e5c64..c6ed5426c9 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -23,6 +23,7 @@ import { registerController } from '@/decorators'; import { AuthController, LdapController, + MFAController, MeController, NodesController, OwnerController, @@ -49,7 +50,9 @@ import * as testDb from '../../shared/testDb'; import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import type { EndpointGroup, SetupProps, TestServer } from '../types'; import { mockInstance } from './mocking'; -import { JwtService } from '@/services/jwt.service'; +import { MfaService } from '@/Mfa/mfa.service'; +import { TOTPService } from '@/Mfa/totp.service'; +import { UserSettings } from 'n8n-core'; import { MetricsService } from '@/services/metrics.service'; /** @@ -179,11 +182,12 @@ export const setupTestServer = ({ } if (functionEndpoints.length) { + const encryptionKey = await UserSettings.getEncryptionKey(); + const repositories = Db.collections; const externalHooks = Container.get(ExternalHooks); const internalHooks = Container.get(InternalHooks); const mailer = Container.get(UserManagementMailer); - const jwtService = Container.get(JwtService); - const repositories = Db.collections; + const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey); for (const group of functionEndpoints) { switch (group) { @@ -197,14 +201,11 @@ export const setupTestServer = ({ registerController( app, config, - new AuthController({ - config, - logger, - internalHooks, - repositories, - }), + new AuthController({ config, logger, internalHooks, repositories, mfaService }), ); break; + case 'mfa': + registerController(app, config, new MFAController(mfaService)); case 'ldap': Container.get(License).isLdapEnabled = () => true; await handleLdapInit(); @@ -250,6 +251,7 @@ export const setupTestServer = ({ externalHooks, internalHooks, mailer, + mfaService, }), ); break; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 5e9d3a06ac..86a31895f2 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -67,6 +67,7 @@ "pinia": "^2.1.6", "prettier": "^3.0.0", "stream-browserify": "^3.0.0", + "qrcode.vue": "^3.3.4", "timeago.js": "^4.0.2", "uuid": "^8.3.2", "v3-infinite-loading": "^1.2.2", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 02814e992f..e1a8b33a5e 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -30,10 +30,10 @@ import type { FeatureFlags, ExecutionStatus, ITelemetryTrackProperties, - IN8nUISettings, IUserManagementSettings, WorkflowSettings, IUserSettings, + IN8nUISettings, BannerName, } from 'n8n-workflow'; import type { SignInType } from './constants'; @@ -583,9 +583,12 @@ export interface CurrentUserResponse extends IUserResponse { export interface IUser extends IUserResponse { isDefaultUser: boolean; isPendingUser: boolean; + hasRecoveryCodesLeft: boolean; isOwner: boolean; inviteAcceptUrl?: string; fullName?: string; + createdAt?: string; + mfaEnabled: boolean; } export interface IVersionNotificationSettings { @@ -1142,6 +1145,9 @@ export interface ISettingsState { loginLabel: string; loginEnabled: boolean; }; + mfa: { + enabled: boolean; + }; onboardingCallPromptEnabled: boolean; saveDataErrorExecution: string; saveDataSuccessExecution: string; diff --git a/packages/editor-ui/src/api/mfa.ts b/packages/editor-ui/src/api/mfa.ts new file mode 100644 index 0000000000..5909a6ceb8 --- /dev/null +++ b/packages/editor-ui/src/api/mfa.ts @@ -0,0 +1,23 @@ +import type { IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils/apiUtils'; + +export async function getMfaQR( + context: IRestApiContext, +): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> { + return makeRestApiRequest(context, 'GET', '/mfa/qr'); +} + +export async function enableMfa(context: IRestApiContext, data: { token: string }): Promise { + return makeRestApiRequest(context, 'POST', '/mfa/enable', data); +} + +export async function verifyMfaToken( + context: IRestApiContext, + data: { token: string }, +): Promise { + return makeRestApiRequest(context, 'POST', '/mfa/verify', data); +} + +export async function disableMfa(context: IRestApiContext): Promise { + return makeRestApiRequest(context, 'DELETE', '/mfa/disable'); +} diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 6f6d9bd867..5ce56d26dd 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -16,7 +16,7 @@ export async function loginCurrentUser( export async function login( context: IRestApiContext, - params: { email: string; password: string }, + params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string }, ): Promise { return makeRestApiRequest(context, 'POST', '/login', params); } @@ -74,7 +74,7 @@ export async function validatePasswordToken( export async function changePassword( context: IRestApiContext, - params: { token: string; password: string }, + params: { token: string; password: string; mfaToken?: string }, ): Promise { await makeRestApiRequest(context, 'POST', '/change-password', params); } diff --git a/packages/editor-ui/src/components/ChangePasswordModal.vue b/packages/editor-ui/src/components/ChangePasswordModal.vue index 9337bb4b05..601c7aa5e3 100644 --- a/packages/editor-ui/src/components/ChangePasswordModal.vue +++ b/packages/editor-ui/src/components/ChangePasswordModal.vue @@ -31,10 +31,10 @@ + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index d39a3c2c9c..7dfb380655 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -65,6 +65,10 @@ + + + +