Skip to content

Commit

Permalink
feat(core): Add MFA (#4767)
Browse files Browse the repository at this point in the history
https://linear.app/n8n/issue/ADO-947/sync-branch-with-master-and-fix-fe-e2e-tets

---------

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
  • Loading branch information
RicardoE105 and netroy authored Aug 24, 2023
1 parent a01c3fb commit 2b7ba6f
Show file tree
Hide file tree
Showing 61 changed files with 2,301 additions and 105 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ packages/**/.turbo
*.tsbuildinfo
cypress/videos/*
cypress/screenshots/*
cypress/downloads/*
*.swp
CHANGELOG-*.md
70 changes: 70 additions & 0 deletions cypress/e2e/27-two-factor-authentication.cy.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
1 change: 1 addition & 0 deletions cypress/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './settings-log-streaming';
export * from './sidebar';
export * from './ndv';
export * from './bannerStack';
export * from './signin';
77 changes: 77 additions & 0 deletions cypress/pages/mfa-login.ts
Original file line number Diff line number Diff line change
@@ -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');
},
},
);
},
};
}
11 changes: 11 additions & 0 deletions cypress/pages/modals/mfa-setup-modal.ts
Original file line number Diff line number Diff line change
@@ -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'),
};
}
22 changes: 22 additions & 0 deletions cypress/pages/settings-personal.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
},
};
}
4 changes: 3 additions & 1 deletion cypress/pages/sidebar/main-sidebar.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -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();
Expand Down
41 changes: 41 additions & 0 deletions cypress/pages/signin.ts
Original file line number Diff line number Diff line change
@@ -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');
},
},
);
},
};
}
20 changes: 12 additions & 8 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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,
}),
);
});

Expand Down Expand Up @@ -162,3 +162,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
}
});
});

Cypress.Commands.add('generateToken', (secret: string) => {
return generateOTPToken(secret);
});
1 change: 1 addition & 0 deletions cypress/support/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ declare global {
options?: { abs?: boolean; index?: number; realMouse?: boolean },
): void;
draganddrop(draggableSelector: string, droppableSelector: string): void;
generateToken(mfaSecret: string): Chainable<string>;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ export interface PublicUser {
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
hasRecoveryCodesLeft: boolean;
globalRole?: Role;
signInType: AuthProviderType;
disabled: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/Mfa/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MFA_FEATURE_ENABLED = 'mfa.enabled';
21 changes: 21 additions & 0 deletions packages/cli/src/Mfa/helpers.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
};
Loading

0 comments on commit 2b7ba6f

Please sign in to comment.