mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat(core): Add MFA (#4767)
https://linear.app/n8n/issue/ADO-947/sync-branch-with-master-and-fix-fe-e2e-tets --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
parent
a01c3fbc19
commit
2b7ba6fdf1
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -19,5 +19,6 @@ packages/**/.turbo
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
cypress/videos/*
|
cypress/videos/*
|
||||||
cypress/screenshots/*
|
cypress/screenshots/*
|
||||||
|
cypress/downloads/*
|
||||||
*.swp
|
*.swp
|
||||||
CHANGELOG-*.md
|
CHANGELOG-*.md
|
||||||
|
|
70
cypress/e2e/27-two-factor-authentication.cy.ts
Normal file
70
cypress/e2e/27-two-factor-authentication.cy.ts
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -8,3 +8,4 @@ export * from './settings-log-streaming';
|
||||||
export * from './sidebar';
|
export * from './sidebar';
|
||||||
export * from './ndv';
|
export * from './ndv';
|
||||||
export * from './bannerStack';
|
export * from './bannerStack';
|
||||||
|
export * from './signin';
|
||||||
|
|
77
cypress/pages/mfa-login.ts
Normal file
77
cypress/pages/mfa-login.ts
Normal file
|
@ -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
cypress/pages/modals/mfa-setup-modal.ts
Normal file
11
cypress/pages/modals/mfa-setup-modal.ts
Normal file
|
@ -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'),
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,10 +1,14 @@
|
||||||
import { ChangePasswordModal } from './modals/change-password-modal';
|
import { ChangePasswordModal } from './modals/change-password-modal';
|
||||||
|
import { MfaSetupModal } from './modals/mfa-setup-modal';
|
||||||
import { BasePage } from './base';
|
import { BasePage } from './base';
|
||||||
|
|
||||||
const changePasswordModal = new ChangePasswordModal();
|
const changePasswordModal = new ChangePasswordModal();
|
||||||
|
const mfaSetupModal = new MfaSetupModal();
|
||||||
|
|
||||||
export class PersonalSettingsPage extends BasePage {
|
export class PersonalSettingsPage extends BasePage {
|
||||||
url = '/settings/personal';
|
url = '/settings/personal';
|
||||||
|
secret = '';
|
||||||
|
|
||||||
getters = {
|
getters = {
|
||||||
currentUserName: () => cy.getByTestId('current-user-name'),
|
currentUserName: () => cy.getByTestId('current-user-name'),
|
||||||
firstNameInput: () => cy.getByTestId('firstName').find('input').first(),
|
firstNameInput: () => cy.getByTestId('firstName').find('input').first(),
|
||||||
|
@ -13,6 +17,8 @@ export class PersonalSettingsPage extends BasePage {
|
||||||
emailInput: () => cy.getByTestId('email').find('input').first(),
|
emailInput: () => cy.getByTestId('email').find('input').first(),
|
||||||
changePasswordLink: () => cy.getByTestId('change-password-link').first(),
|
changePasswordLink: () => cy.getByTestId('change-password-link').first(),
|
||||||
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
||||||
|
enableMfaButton: () => cy.getByTestId('enable-mfa-button'),
|
||||||
|
disableMfaButton: () => cy.getByTestId('disable-mfa-button'),
|
||||||
};
|
};
|
||||||
actions = {
|
actions = {
|
||||||
loginAndVisit: (email: string, password: string) => {
|
loginAndVisit: (email: string, password: string) => {
|
||||||
|
@ -50,5 +56,21 @@ export class PersonalSettingsPage extends BasePage {
|
||||||
this.actions.loginAndVisit(email, password);
|
this.actions.loginAndVisit(email, password);
|
||||||
cy.url().should('match', new RegExp(this.url));
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
import { WorkflowsPage } from '../workflows';
|
import { WorkflowsPage } from '../workflows';
|
||||||
|
|
||||||
|
const workflowsPage = new WorkflowsPage();
|
||||||
|
|
||||||
export class MainSidebar extends BasePage {
|
export class MainSidebar extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
menuItem: (menuLabel: string) =>
|
menuItem: (menuLabel: string) =>
|
||||||
|
@ -25,7 +27,7 @@ export class MainSidebar extends BasePage {
|
||||||
this.getters.credentials().click();
|
this.getters.credentials().click();
|
||||||
},
|
},
|
||||||
openUserMenu: () => {
|
openUserMenu: () => {
|
||||||
this.getters.userMenu().find('[role="button"]').last().click();
|
this.getters.userMenu().click();
|
||||||
},
|
},
|
||||||
openUserMenu: () => {
|
openUserMenu: () => {
|
||||||
this.getters.userMenu().click();
|
this.getters.userMenu().click();
|
||||||
|
|
41
cypress/pages/signin.ts
Normal file
41
cypress/pages/signin.ts
Normal file
|
@ -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');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import 'cypress-real-events';
|
import 'cypress-real-events';
|
||||||
import { WorkflowPage } from '../pages';
|
import { WorkflowPage } from '../pages';
|
||||||
import { BACKEND_BASE_URL, N8N_AUTH_COOKIE } from '../constants';
|
import { BACKEND_BASE_URL, N8N_AUTH_COOKIE } from '../constants';
|
||||||
|
import generateOTPToken from 'cypress-otp';
|
||||||
|
|
||||||
Cypress.Commands.add('getByTestId', (selector, ...args) => {
|
Cypress.Commands.add('getByTestId', (selector, ...args) => {
|
||||||
return cy.get(`[data-test-id="${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.Commands.add('signin', ({ email, password }) => {
|
||||||
Cypress.session.clearAllSavedSessions();
|
Cypress.session.clearAllSavedSessions();
|
||||||
cy.session(
|
cy.session([email, password], () =>
|
||||||
[email, password],
|
cy.request({
|
||||||
() => cy.request('POST', `${BACKEND_BASE_URL}/rest/login`, { email, password }),
|
method: 'POST',
|
||||||
{
|
url: `${BACKEND_BASE_URL}/rest/login`,
|
||||||
validate() {
|
body: { email, password },
|
||||||
cy.getCookie(N8N_AUTH_COOKIE).should('exist');
|
failOnStatusCode: false,
|
||||||
},
|
}),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -162,3 +162,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('generateToken', (secret: string) => {
|
||||||
|
return generateOTPToken(secret);
|
||||||
|
});
|
||||||
|
|
|
@ -37,6 +37,7 @@ declare global {
|
||||||
options?: { abs?: boolean; index?: number; realMouse?: boolean },
|
options?: { abs?: boolean; index?: number; realMouse?: boolean },
|
||||||
): void;
|
): void;
|
||||||
draganddrop(draggableSelector: string, droppableSelector: string): void;
|
draganddrop(draggableSelector: string, droppableSelector: string): void;
|
||||||
|
generateToken(mfaSecret: string): Chainable<string>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
"@vitest/coverage-v8": "^0.33.0",
|
"@vitest/coverage-v8": "^0.33.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"cypress-otp": "^1.0.3",
|
||||||
"cypress": "^12.17.2",
|
"cypress": "^12.17.2",
|
||||||
"cypress-real-events": "^1.9.1",
|
"cypress-real-events": "^1.9.1",
|
||||||
"jest": "^29.6.2",
|
"jest": "^29.6.2",
|
||||||
|
|
|
@ -159,6 +159,7 @@
|
||||||
"oauth-1.0a": "^2.2.6",
|
"oauth-1.0a": "^2.2.6",
|
||||||
"open": "^7.0.0",
|
"open": "^7.0.0",
|
||||||
"openapi-types": "^10.0.0",
|
"openapi-types": "^10.0.0",
|
||||||
|
"otpauth": "^9.1.1",
|
||||||
"p-cancelable": "^2.0.0",
|
"p-cancelable": "^2.0.0",
|
||||||
"p-lazy": "^3.1.0",
|
"p-lazy": "^3.1.0",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
|
|
|
@ -758,6 +758,7 @@ export interface PublicUser {
|
||||||
passwordResetToken?: string;
|
passwordResetToken?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
hasRecoveryCodesLeft: boolean;
|
||||||
globalRole?: Role;
|
globalRole?: Role;
|
||||||
signInType: AuthProviderType;
|
signInType: AuthProviderType;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
|
1
packages/cli/src/Mfa/constants.ts
Normal file
1
packages/cli/src/Mfa/constants.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const MFA_FEATURE_ENABLED = 'mfa.enabled';
|
21
packages/cli/src/Mfa/helpers.ts
Normal file
21
packages/cli/src/Mfa/helpers.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
79
packages/cli/src/Mfa/mfa.service.ts
Normal file
79
packages/cli/src/Mfa/mfa.service.ts
Normal file
|
@ -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: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
36
packages/cli/src/Mfa/totp.service.ts
Normal file
36
packages/cli/src/Mfa/totp.service.ts
Normal file
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,8 +45,8 @@ export class BadRequestError extends ResponseError {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthError extends ResponseError {
|
export class AuthError extends ResponseError {
|
||||||
constructor(message: string) {
|
constructor(message: string, errorCode?: number) {
|
||||||
super(message, 401);
|
super(message, 401, errorCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -88,6 +88,7 @@ import {
|
||||||
AuthController,
|
AuthController,
|
||||||
LdapController,
|
LdapController,
|
||||||
MeController,
|
MeController,
|
||||||
|
MFAController,
|
||||||
NodesController,
|
NodesController,
|
||||||
NodeTypesController,
|
NodeTypesController,
|
||||||
OwnerController,
|
OwnerController,
|
||||||
|
@ -167,6 +168,9 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl
|
||||||
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
|
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
|
||||||
import { ExecutionRepository } from '@db/repositories';
|
import { ExecutionRepository } from '@db/repositories';
|
||||||
import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
|
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);
|
const exec = promisify(callbackExec);
|
||||||
|
|
||||||
|
@ -313,6 +317,9 @@ export class Server extends AbstractServer {
|
||||||
showNonProdBanner: false,
|
showNonProdBanner: false,
|
||||||
debugInEditor: false,
|
debugInEditor: false,
|
||||||
},
|
},
|
||||||
|
mfa: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
hideUsagePage: config.getEnv('hideUsagePage'),
|
hideUsagePage: config.getEnv('hideUsagePage'),
|
||||||
license: {
|
license: {
|
||||||
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
|
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
|
||||||
|
@ -471,6 +478,9 @@ export class Server extends AbstractServer {
|
||||||
if (config.get('nodes.packagesMissing').length > 0) {
|
if (config.get('nodes.packagesMissing').length > 0) {
|
||||||
this.frontendSettings.missingPackages = true;
|
this.frontendSettings.missingPackages = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.frontendSettings.mfa.enabled = isMfaFeatureEnabled();
|
||||||
|
|
||||||
return this.frontendSettings;
|
return this.frontendSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -479,31 +489,19 @@ export class Server extends AbstractServer {
|
||||||
const repositories = Db.collections;
|
const repositories = Db.collections;
|
||||||
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
|
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
|
||||||
|
|
||||||
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
|
|
||||||
const logger = LoggerProxy;
|
const logger = LoggerProxy;
|
||||||
const internalHooks = Container.get(InternalHooks);
|
const internalHooks = Container.get(InternalHooks);
|
||||||
const mailer = Container.get(UserManagementMailer);
|
const mailer = Container.get(UserManagementMailer);
|
||||||
const postHog = this.postHog;
|
const postHog = this.postHog;
|
||||||
|
const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey);
|
||||||
|
|
||||||
const controllers: object[] = [
|
const controllers: object[] = [
|
||||||
new EventBusController(),
|
new EventBusController(),
|
||||||
new AuthController({
|
new AuthController({ config, internalHooks, logger, postHog, mfaService }),
|
||||||
config,
|
new OwnerController({ config, internalHooks, repositories, logger, postHog }),
|
||||||
internalHooks,
|
new MeController({ externalHooks, internalHooks, logger }),
|
||||||
repositories,
|
|
||||||
logger,
|
|
||||||
postHog,
|
|
||||||
}),
|
|
||||||
new OwnerController({
|
|
||||||
config,
|
|
||||||
internalHooks,
|
|
||||||
repositories,
|
|
||||||
logger,
|
|
||||||
}),
|
|
||||||
new MeController({
|
|
||||||
externalHooks,
|
|
||||||
internalHooks,
|
|
||||||
logger,
|
|
||||||
}),
|
|
||||||
new NodeTypesController({ config, nodeTypes }),
|
new NodeTypesController({ config, nodeTypes }),
|
||||||
new PasswordResetController({
|
new PasswordResetController({
|
||||||
config,
|
config,
|
||||||
|
@ -511,6 +509,7 @@ export class Server extends AbstractServer {
|
||||||
internalHooks,
|
internalHooks,
|
||||||
mailer,
|
mailer,
|
||||||
logger,
|
logger,
|
||||||
|
mfaService,
|
||||||
}),
|
}),
|
||||||
Container.get(TagsController),
|
Container.get(TagsController),
|
||||||
new TranslationController(config, this.credentialTypes),
|
new TranslationController(config, this.credentialTypes),
|
||||||
|
@ -546,6 +545,10 @@ export class Server extends AbstractServer {
|
||||||
controllers.push(Container.get(E2EController));
|
controllers.push(Container.get(E2EController));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMfaFeatureEnabled()) {
|
||||||
|
controllers.push(new MFAController(mfaService));
|
||||||
|
}
|
||||||
|
|
||||||
controllers.forEach((controller) => registerController(app, config, controller));
|
controllers.forEach((controller) => registerController(app, config, controller));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -623,6 +626,8 @@ export class Server extends AbstractServer {
|
||||||
|
|
||||||
await handleLdapInit();
|
await handleLdapInit();
|
||||||
|
|
||||||
|
await handleMfaDisable();
|
||||||
|
|
||||||
await this.registerControllers(ignoredEndpoints);
|
await this.registerControllers(ignoredEndpoints);
|
||||||
|
|
||||||
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
|
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);
|
||||||
|
|
|
@ -88,21 +88,26 @@ export function validatePassword(password?: string): string {
|
||||||
* Remove sensitive properties from the user to return to the client.
|
* Remove sensitive properties from the user to return to the client.
|
||||||
*/
|
*/
|
||||||
export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
|
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) {
|
if (withoutKeys) {
|
||||||
withoutKeys.forEach((key) => {
|
withoutKeys.forEach((key) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete rest[key];
|
delete rest[key];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedUser: PublicUser = {
|
const sanitizedUser: PublicUser = {
|
||||||
...rest,
|
...rest,
|
||||||
signInType: 'email',
|
signInType: 'email',
|
||||||
|
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
|
||||||
if (ldapIdentity) {
|
if (ldapIdentity) {
|
||||||
sanitizedUser.signInType = 'ldap';
|
sanitizedUser.signInType = 'ldap';
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizedUser;
|
return sanitizedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
55
packages/cli/src/commands/mfa/disable.ts
Normal file
55
packages/cli/src/commands/mfa/disable.ts
Normal file
|
@ -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<void> {
|
||||||
|
// 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`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: {
|
sso: {
|
||||||
justInTimeProvisioning: {
|
justInTimeProvisioning: {
|
||||||
format: Boolean,
|
format: Boolean,
|
||||||
|
|
|
@ -16,12 +16,7 @@ import type { ILogger } from 'n8n-workflow';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { LoginRequest, UserRequest } from '@/requests';
|
import { LoginRequest, UserRequest } from '@/requests';
|
||||||
import type { Config } from '@/config';
|
import type { Config } from '@/config';
|
||||||
import type {
|
import type { PublicUser, IInternalHooksClass, CurrentUser } from '@/Interfaces';
|
||||||
PublicUser,
|
|
||||||
IDatabaseCollections,
|
|
||||||
IInternalHooksClass,
|
|
||||||
CurrentUser,
|
|
||||||
} from '@/Interfaces';
|
|
||||||
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
import { handleEmailLogin, handleLdapLogin } from '@/auth';
|
||||||
import type { PostHogClient } from '@/posthog';
|
import type { PostHogClient } from '@/posthog';
|
||||||
import {
|
import {
|
||||||
|
@ -32,6 +27,7 @@ import {
|
||||||
import { InternalHooks } from '../InternalHooks';
|
import { InternalHooks } from '../InternalHooks';
|
||||||
import { License } from '@/License';
|
import { License } from '@/License';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
|
import type { MfaService } from '@/Mfa/mfa.service';
|
||||||
|
|
||||||
@RestController()
|
@RestController()
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
@ -45,23 +41,27 @@ export class AuthController {
|
||||||
|
|
||||||
private readonly postHog?: PostHogClient;
|
private readonly postHog?: PostHogClient;
|
||||||
|
|
||||||
|
private readonly mfaService: MfaService;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
internalHooks,
|
internalHooks,
|
||||||
postHog,
|
postHog,
|
||||||
|
mfaService,
|
||||||
}: {
|
}: {
|
||||||
config: Config;
|
config: Config;
|
||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
internalHooks: IInternalHooksClass;
|
internalHooks: IInternalHooksClass;
|
||||||
repositories: Pick<IDatabaseCollections, 'User'>;
|
|
||||||
postHog?: PostHogClient;
|
postHog?: PostHogClient;
|
||||||
|
mfaService: MfaService;
|
||||||
}) {
|
}) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.internalHooks = internalHooks;
|
this.internalHooks = internalHooks;
|
||||||
this.postHog = postHog;
|
this.postHog = postHog;
|
||||||
this.userService = Container.get(UserService);
|
this.userService = Container.get(UserService);
|
||||||
|
this.mfaService = mfaService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -69,7 +69,7 @@ export class AuthController {
|
||||||
*/
|
*/
|
||||||
@Post('/login')
|
@Post('/login')
|
||||||
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
||||||
const { email, password } = req.body;
|
const { email, password, mfaToken, mfaRecoveryCode } = req.body;
|
||||||
if (!email) throw new Error('Email is required to log in');
|
if (!email) throw new Error('Email is required to log in');
|
||||||
if (!password) throw new Error('Password is required to log in');
|
if (!password) throw new Error('Password is required to log in');
|
||||||
|
|
||||||
|
@ -94,7 +94,28 @@ export class AuthController {
|
||||||
} else {
|
} else {
|
||||||
user = await handleEmailLogin(email, password);
|
user = await handleEmailLogin(email, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
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);
|
await issueCookie(res, user);
|
||||||
void Container.get(InternalHooks).onUserLoginSuccess({
|
void Container.get(InternalHooks).onUserLoginSuccess({
|
||||||
user,
|
user,
|
||||||
|
@ -229,4 +250,27 @@ export class AuthController {
|
||||||
res.clearCookie(AUTH_COOKIE_NAME);
|
res.clearCookie(AUTH_COOKIE_NAME);
|
||||||
return { loggedOut: true };
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,9 @@ import { LICENSE_FEATURES, inE2ETests } from '@/constants';
|
||||||
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
|
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
|
||||||
import type { UserSetupPayload } from '@/requests';
|
import type { UserSetupPayload } from '@/requests';
|
||||||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||||
|
import { UserSettings } from 'n8n-core';
|
||||||
|
import { MfaService } from '@/Mfa/mfa.service';
|
||||||
|
import { TOTPService } from '@/Mfa/totp.service';
|
||||||
|
|
||||||
if (!inE2ETests) {
|
if (!inE2ETests) {
|
||||||
console.error('E2E endpoints only allowed during E2E tests');
|
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() })),
|
roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })),
|
||||||
);
|
);
|
||||||
|
|
||||||
const users = [];
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
users.push({
|
|
||||||
|
const mfaService = new MfaService(this.userRepo, new TOTPService(), encryptionKey);
|
||||||
|
|
||||||
|
const instanceOwner = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
...owner,
|
...owner,
|
||||||
password: await hashPassword(owner.password),
|
password: await hashPassword(owner.password),
|
||||||
globalRoleId: globalOwnerRoleId,
|
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) {
|
for (const { password, ...payload } of members) {
|
||||||
users.push(
|
users.push(
|
||||||
this.userRepo.create({
|
this.userRepo.create({
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
export { AuthController } from './auth.controller';
|
export { AuthController } from './auth.controller';
|
||||||
export { LdapController } from './ldap.controller';
|
export { LdapController } from './ldap.controller';
|
||||||
export { MeController } from './me.controller';
|
export { MeController } from './me.controller';
|
||||||
|
export { MFAController } from './mfa.controller';
|
||||||
export { NodesController } from './nodes.controller';
|
export { NodesController } from './nodes.controller';
|
||||||
export { NodeTypesController } from './nodeTypes.controller';
|
export { NodeTypesController } from './nodeTypes.controller';
|
||||||
export { OwnerController } from './owner.controller';
|
export { OwnerController } from './owner.controller';
|
||||||
|
|
96
packages/cli/src/controllers/mfa.controller.ts
Normal file
96
packages/cli/src/controllers/mfa.controller.ts
Normal file
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
|
||||||
import { TokenExpiredError } from 'jsonwebtoken';
|
import { TokenExpiredError } from 'jsonwebtoken';
|
||||||
import type { JwtPayload } from '@/services/jwt.service';
|
import type { JwtPayload } from '@/services/jwt.service';
|
||||||
import { JwtService } from '@/services/jwt.service';
|
import { JwtService } from '@/services/jwt.service';
|
||||||
|
import type { MfaService } from '@/Mfa/mfa.service';
|
||||||
|
|
||||||
@RestController()
|
@RestController()
|
||||||
export class PasswordResetController {
|
export class PasswordResetController {
|
||||||
|
@ -47,18 +48,22 @@ export class PasswordResetController {
|
||||||
|
|
||||||
private readonly userService: UserService;
|
private readonly userService: UserService;
|
||||||
|
|
||||||
|
private readonly mfaService: MfaService;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
externalHooks,
|
externalHooks,
|
||||||
internalHooks,
|
internalHooks,
|
||||||
mailer,
|
mailer,
|
||||||
|
mfaService,
|
||||||
}: {
|
}: {
|
||||||
config: Config;
|
config: Config;
|
||||||
logger: ILogger;
|
logger: ILogger;
|
||||||
externalHooks: IExternalHooksClass;
|
externalHooks: IExternalHooksClass;
|
||||||
internalHooks: IInternalHooksClass;
|
internalHooks: IInternalHooksClass;
|
||||||
mailer: UserManagementMailer;
|
mailer: UserManagementMailer;
|
||||||
|
mfaService: MfaService;
|
||||||
}) {
|
}) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
@ -67,6 +72,7 @@ export class PasswordResetController {
|
||||||
this.mailer = mailer;
|
this.mailer = mailer;
|
||||||
this.jwtService = Container.get(JwtService);
|
this.jwtService = Container.get(JwtService);
|
||||||
this.userService = Container.get(UserService);
|
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 {
|
try {
|
||||||
await this.mailer.passwordReset({
|
await this.mailer.passwordReset({
|
||||||
|
@ -233,7 +243,7 @@ export class PasswordResetController {
|
||||||
*/
|
*/
|
||||||
@Post('/change-password')
|
@Post('/change-password')
|
||||||
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
||||||
const { token: resetPasswordToken, password } = req.body;
|
const { token: resetPasswordToken, password, mfaToken } = req.body;
|
||||||
|
|
||||||
if (!resetPasswordToken || !password) {
|
if (!resetPasswordToken || !password) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
@ -264,6 +274,16 @@ export class PasswordResetController {
|
||||||
throw new NotFoundError('');
|
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);
|
const passwordHash = await hashPassword(validPassword);
|
||||||
|
|
||||||
await this.userService.update(user.id, { password: passwordHash });
|
await this.userService.update(user.id, { password: passwordHash });
|
||||||
|
|
|
@ -389,7 +389,11 @@ export class UsersController {
|
||||||
|
|
||||||
const baseUrl = getInstanceBaseUrl();
|
const baseUrl = getInstanceBaseUrl();
|
||||||
|
|
||||||
const link = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken);
|
const link = this.userService.generatePasswordResetUrl(
|
||||||
|
baseUrl,
|
||||||
|
resetPasswordToken,
|
||||||
|
user.mfaEnabled,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
link,
|
link,
|
||||||
};
|
};
|
||||||
|
|
|
@ -96,6 +96,15 @@ export class User extends WithTimestamps implements IUser {
|
||||||
@Index({ unique: true })
|
@Index({ unique: true })
|
||||||
apiKey?: string | null;
|
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.
|
* Whether the user is pending setup completion.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,6 +44,7 @@ import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionD
|
||||||
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
|
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
|
||||||
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
||||||
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
||||||
|
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -91,4 +92,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
RemoveSkipOwnerSetup1681134145997,
|
RemoveSkipOwnerSetup1681134145997,
|
||||||
RemoveResetPasswordColumns1690000000030,
|
RemoveResetPasswordColumns1690000000030,
|
||||||
CreateWorkflowNameIndex1691088862123,
|
CreateWorkflowNameIndex1691088862123,
|
||||||
|
AddMfaColumns1690000000030,
|
||||||
];
|
];
|
||||||
|
|
|
@ -42,6 +42,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn
|
||||||
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
|
||||||
import { AddMissingPrimaryKeyOnExecutionData1690787606731 } from './1690787606731-AddMissingPrimaryKeyOnExecutionData';
|
import { AddMissingPrimaryKeyOnExecutionData1690787606731 } from './1690787606731-AddMissingPrimaryKeyOnExecutionData';
|
||||||
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
||||||
|
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -87,4 +88,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
RemoveResetPasswordColumns1690000000030,
|
RemoveResetPasswordColumns1690000000030,
|
||||||
AddMissingPrimaryKeyOnExecutionData1690787606731,
|
AddMissingPrimaryKeyOnExecutionData1690787606731,
|
||||||
CreateWorkflowNameIndex1691088862123,
|
CreateWorkflowNameIndex1691088862123,
|
||||||
|
AddMfaColumns1690000000030,
|
||||||
];
|
];
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn
|
||||||
import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration';
|
import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration';
|
||||||
import { RemoveResetPasswordColumns1690000000030 } from './1690000000030-RemoveResetPasswordColumns';
|
import { RemoveResetPasswordColumns1690000000030 } from './1690000000030-RemoveResetPasswordColumns';
|
||||||
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
|
||||||
|
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -85,6 +86,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
FixMissingIndicesFromStringIdMigration1690000000020,
|
FixMissingIndicesFromStringIdMigration1690000000020,
|
||||||
RemoveResetPasswordColumns1690000000030,
|
RemoveResetPasswordColumns1690000000030,
|
||||||
CreateWorkflowNameIndex1691088862123,
|
CreateWorkflowNameIndex1691088862123,
|
||||||
|
AddMfaColumns1690000000030,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -227,7 +227,7 @@ export declare namespace MeRequest {
|
||||||
export type Password = AuthenticatedRequest<
|
export type Password = AuthenticatedRequest<
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
{ currentPassword: string; newPassword: string }
|
{ currentPassword: string; newPassword: string; token?: string }
|
||||||
>;
|
>;
|
||||||
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
|
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
|
||||||
}
|
}
|
||||||
|
@ -237,6 +237,9 @@ export interface UserSetupPayload {
|
||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
mfaEnabled?: boolean;
|
||||||
|
mfaSecret?: string;
|
||||||
|
mfaRecoveryCodes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
@ -261,7 +264,7 @@ export declare namespace PasswordResetRequest {
|
||||||
export type NewPassword = AuthlessRequest<
|
export type NewPassword = AuthlessRequest<
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
Pick<PublicUser, 'password'> & { token?: string; userId?: string }
|
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaToken?: string }
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,9 +335,27 @@ export type LoginRequest = AuthlessRequest<
|
||||||
{
|
{
|
||||||
email: string;
|
email: string;
|
||||||
password: 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
|
// oauth endpoints
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -51,10 +51,11 @@ export class UserService {
|
||||||
return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } });
|
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`);
|
const url = new URL(`${instanceBaseUrl}/change-password`);
|
||||||
|
|
||||||
url.searchParams.append('token', token);
|
url.searchParams.append('token', token);
|
||||||
|
url.searchParams.append('mfaEnabled', mfaEnabled.toString());
|
||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
405
packages/cli/test/integration/mfa/mfa.api.test.ts
Normal file
405
packages/cli/test/integration/mfa/mfa.api.test.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -21,7 +21,6 @@ import type { TagEntity } from '@db/entities/TagEntity';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { ICredentialsDb } from '@/Interfaces';
|
import type { ICredentialsDb } from '@/Interfaces';
|
||||||
|
|
||||||
import { DB_INITIALIZATION_TIMEOUT } from './constants';
|
import { DB_INITIALIZATION_TIMEOUT } from './constants';
|
||||||
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
|
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
|
||||||
import type {
|
import type {
|
||||||
|
@ -38,6 +37,10 @@ import { VariablesService } from '@/environments/variables/variables.service';
|
||||||
import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories';
|
import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories';
|
||||||
import { separate } from '@/utils';
|
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 type TestDBType = 'postgres' | 'mysql';
|
||||||
|
|
||||||
export const testDbPrefix = 'n8n_test_';
|
export const testDbPrefix = 'n8n_test_';
|
||||||
|
@ -204,6 +207,41 @@ export async function createLdapUser(attributes: Partial<User>, ldapId: string):
|
||||||
return user;
|
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() {
|
export async function createOwner() {
|
||||||
return createUser({ globalRole: await getGlobalOwnerRole() });
|
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.
|
* Generate options for a bootstrap DB connection, to create and drop test databases.
|
||||||
*/
|
*/
|
||||||
export const getBootstrapDBOptions = (type: TestDBType) =>
|
export const getBootstrapDBOptions = (type: TestDBType) => ({
|
||||||
({
|
|
||||||
type,
|
type,
|
||||||
name: type,
|
name: type,
|
||||||
database: type,
|
database: type,
|
||||||
...baseOptions(type),
|
...baseOptions(type),
|
||||||
}) as const;
|
});
|
||||||
|
|
||||||
const getDBOptions = (type: TestDBType, name: string) => ({
|
const getDBOptions = (type: TestDBType, name: string) => ({
|
||||||
type,
|
type,
|
||||||
|
|
|
@ -26,6 +26,7 @@ export type EndpointGroup =
|
||||||
| 'license'
|
| 'license'
|
||||||
| 'variables'
|
| 'variables'
|
||||||
| 'tags'
|
| 'tags'
|
||||||
|
| 'mfa'
|
||||||
| 'metrics';
|
| 'metrics';
|
||||||
|
|
||||||
export interface SetupProps {
|
export interface SetupProps {
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { registerController } from '@/decorators';
|
||||||
import {
|
import {
|
||||||
AuthController,
|
AuthController,
|
||||||
LdapController,
|
LdapController,
|
||||||
|
MFAController,
|
||||||
MeController,
|
MeController,
|
||||||
NodesController,
|
NodesController,
|
||||||
OwnerController,
|
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 { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
|
||||||
import type { EndpointGroup, SetupProps, TestServer } from '../types';
|
import type { EndpointGroup, SetupProps, TestServer } from '../types';
|
||||||
import { mockInstance } from './mocking';
|
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';
|
import { MetricsService } from '@/services/metrics.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -179,11 +182,12 @@ export const setupTestServer = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (functionEndpoints.length) {
|
if (functionEndpoints.length) {
|
||||||
|
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||||
|
const repositories = Db.collections;
|
||||||
const externalHooks = Container.get(ExternalHooks);
|
const externalHooks = Container.get(ExternalHooks);
|
||||||
const internalHooks = Container.get(InternalHooks);
|
const internalHooks = Container.get(InternalHooks);
|
||||||
const mailer = Container.get(UserManagementMailer);
|
const mailer = Container.get(UserManagementMailer);
|
||||||
const jwtService = Container.get(JwtService);
|
const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey);
|
||||||
const repositories = Db.collections;
|
|
||||||
|
|
||||||
for (const group of functionEndpoints) {
|
for (const group of functionEndpoints) {
|
||||||
switch (group) {
|
switch (group) {
|
||||||
|
@ -197,14 +201,11 @@ export const setupTestServer = ({
|
||||||
registerController(
|
registerController(
|
||||||
app,
|
app,
|
||||||
config,
|
config,
|
||||||
new AuthController({
|
new AuthController({ config, logger, internalHooks, repositories, mfaService }),
|
||||||
config,
|
|
||||||
logger,
|
|
||||||
internalHooks,
|
|
||||||
repositories,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'mfa':
|
||||||
|
registerController(app, config, new MFAController(mfaService));
|
||||||
case 'ldap':
|
case 'ldap':
|
||||||
Container.get(License).isLdapEnabled = () => true;
|
Container.get(License).isLdapEnabled = () => true;
|
||||||
await handleLdapInit();
|
await handleLdapInit();
|
||||||
|
@ -250,6 +251,7 @@ export const setupTestServer = ({
|
||||||
externalHooks,
|
externalHooks,
|
||||||
internalHooks,
|
internalHooks,
|
||||||
mailer,
|
mailer,
|
||||||
|
mfaService,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
"pinia": "^2.1.6",
|
"pinia": "^2.1.6",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
|
"qrcode.vue": "^3.3.4",
|
||||||
"timeago.js": "^4.0.2",
|
"timeago.js": "^4.0.2",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"v3-infinite-loading": "^1.2.2",
|
"v3-infinite-loading": "^1.2.2",
|
||||||
|
|
|
@ -30,10 +30,10 @@ import type {
|
||||||
FeatureFlags,
|
FeatureFlags,
|
||||||
ExecutionStatus,
|
ExecutionStatus,
|
||||||
ITelemetryTrackProperties,
|
ITelemetryTrackProperties,
|
||||||
IN8nUISettings,
|
|
||||||
IUserManagementSettings,
|
IUserManagementSettings,
|
||||||
WorkflowSettings,
|
WorkflowSettings,
|
||||||
IUserSettings,
|
IUserSettings,
|
||||||
|
IN8nUISettings,
|
||||||
BannerName,
|
BannerName,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { SignInType } from './constants';
|
import type { SignInType } from './constants';
|
||||||
|
@ -583,9 +583,12 @@ export interface CurrentUserResponse extends IUserResponse {
|
||||||
export interface IUser extends IUserResponse {
|
export interface IUser extends IUserResponse {
|
||||||
isDefaultUser: boolean;
|
isDefaultUser: boolean;
|
||||||
isPendingUser: boolean;
|
isPendingUser: boolean;
|
||||||
|
hasRecoveryCodesLeft: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
inviteAcceptUrl?: string;
|
inviteAcceptUrl?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
mfaEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
|
@ -1142,6 +1145,9 @@ export interface ISettingsState {
|
||||||
loginLabel: string;
|
loginLabel: string;
|
||||||
loginEnabled: boolean;
|
loginEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
mfa: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
onboardingCallPromptEnabled: boolean;
|
onboardingCallPromptEnabled: boolean;
|
||||||
saveDataErrorExecution: string;
|
saveDataErrorExecution: string;
|
||||||
saveDataSuccessExecution: string;
|
saveDataSuccessExecution: string;
|
||||||
|
|
23
packages/editor-ui/src/api/mfa.ts
Normal file
23
packages/editor-ui/src/api/mfa.ts
Normal file
|
@ -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<void> {
|
||||||
|
return makeRestApiRequest(context, 'POST', '/mfa/enable', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMfaToken(
|
||||||
|
context: IRestApiContext,
|
||||||
|
data: { token: string },
|
||||||
|
): Promise<void> {
|
||||||
|
return makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableMfa(context: IRestApiContext): Promise<void> {
|
||||||
|
return makeRestApiRequest(context, 'DELETE', '/mfa/disable');
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ export async function loginCurrentUser(
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: { email: string; password: string },
|
params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string },
|
||||||
): Promise<CurrentUserResponse> {
|
): Promise<CurrentUserResponse> {
|
||||||
return makeRestApiRequest(context, 'POST', '/login', params);
|
return makeRestApiRequest(context, 'POST', '/login', params);
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ export async function validatePasswordToken(
|
||||||
|
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: { token: string; password: string },
|
params: { token: string; password: string; mfaToken?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,10 +31,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
import { CHANGE_PASSWORD_MODAL_KEY } from '../constants';
|
||||||
import { useToast } from '@/composables';
|
import { useToast } from '@/composables';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import type { IFormInputs } from '@/Interface';
|
import type { IFormInputs } from '@/Interface';
|
||||||
import { CHANGE_PASSWORD_MODAL_KEY } from '@/constants';
|
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
@ -66,7 +66,7 @@ export default defineComponent({
|
||||||
...mapStores(useUsersStore),
|
...mapStores(useUsersStore),
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.config = [
|
const form: IFormInputs = [
|
||||||
{
|
{
|
||||||
name: 'currentPassword',
|
name: 'currentPassword',
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -107,6 +107,8 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.config = form;
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
passwordsMatch(value: string | number | boolean | null | undefined) {
|
passwordsMatch(value: string | number | boolean | null | undefined) {
|
||||||
|
@ -127,7 +129,7 @@ export default defineComponent({
|
||||||
this.password = e.value;
|
this.password = e.value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onSubmit(values: { [key: string]: string }) {
|
async onSubmit(values: { currentPassword: string; password: string }) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
await this.usersStore.updateCurrentUserPassword(values);
|
await this.usersStore.updateCurrentUserPassword(values);
|
||||||
|
|
359
packages/editor-ui/src/components/MfaSetupModal.vue
Normal file
359
packages/editor-ui/src/components/MfaSetupModal.vue
Normal file
|
@ -0,0 +1,359 @@
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
width="460px"
|
||||||
|
:title="
|
||||||
|
!showRecoveryCodes
|
||||||
|
? $locale.baseText('mfa.setup.step1.title')
|
||||||
|
: $locale.baseText('mfa.setup.step2.title')
|
||||||
|
"
|
||||||
|
:eventBus="modalBus"
|
||||||
|
:name="MFA_SETUP_MODAL_KEY"
|
||||||
|
:center="true"
|
||||||
|
:loading="loadingQrCode"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div v-if="!showRecoveryCodes" :class="[$style.container, $style.modalContent]">
|
||||||
|
<div :class="$style.textContainer">
|
||||||
|
<n8n-text size="large" color="text-dark" :bold="true">{{
|
||||||
|
$locale.baseText('mfa.setup.step1.instruction1.title')
|
||||||
|
}}</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<n8n-text size="medium" :bold="false">
|
||||||
|
<i18n-t keypath="mfa.setup.step1.instruction1.subtitle" tag="span">
|
||||||
|
<template #part1>
|
||||||
|
{{ $locale.baseText('mfa.setup.step1.instruction1.subtitle.part1') }}
|
||||||
|
</template>
|
||||||
|
<template #part2>
|
||||||
|
<a
|
||||||
|
:class="$style.secret"
|
||||||
|
@click="onCopySecretToClipboard"
|
||||||
|
data-test-id="mfa-secret-button"
|
||||||
|
>{{ $locale.baseText('mfa.setup.step1.instruction1.subtitle.part2') }}</a
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.qrContainer">
|
||||||
|
<qrcode-vue :value="qrCode" size="150" level="H" />
|
||||||
|
</div>
|
||||||
|
<div :class="$style.textContainer">
|
||||||
|
<n8n-text size="large" color="text-dark" :bold="true">{{
|
||||||
|
$locale.baseText('mfa.setup.step1.instruction2.title')
|
||||||
|
}}</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div :class="[$style.form, infoTextErrorMessage ? $style.error : '']">
|
||||||
|
<n8n-input-label
|
||||||
|
size="medium"
|
||||||
|
:bold="false"
|
||||||
|
:class="$style.labelTooltip"
|
||||||
|
:label="$locale.baseText('mfa.setup.step1.input.label')"
|
||||||
|
>
|
||||||
|
<n8n-input
|
||||||
|
v-model="authenticatorCode"
|
||||||
|
type="text"
|
||||||
|
:maxlength="6"
|
||||||
|
:placeholder="$locale.baseText('mfa.code.input.placeholder')"
|
||||||
|
@input="onInput"
|
||||||
|
:required="true"
|
||||||
|
data-test-id="mfa-token-input"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
<div :class="[$style.infoText, 'mt-4xs']">
|
||||||
|
<span size="small" v-text="infoTextErrorMessage"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else :class="$style.container">
|
||||||
|
<div>
|
||||||
|
<n8n-text size="medium" :bold="false">{{
|
||||||
|
$locale.baseText('mfa.setup.step2.description')
|
||||||
|
}}</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.recoveryCodesContainer">
|
||||||
|
<div v-for="recoveryCode in recoveryCodes" :key="recoveryCode">
|
||||||
|
<n8n-text size="medium">{{ recoveryCode }}</n8n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n8n-info-tip :bold="false" :class="$style['edit-mode-footer-infotip']">
|
||||||
|
<i18n-t keypath="mfa.setup.step2.infobox.description" tag="span">
|
||||||
|
<template #part1>
|
||||||
|
{{ $locale.baseText('mfa.setup.step2.infobox.description.part1') }}
|
||||||
|
</template>
|
||||||
|
<template #part2>
|
||||||
|
<n8n-text size="small" :bold="true" :class="$style.loseAccessText">
|
||||||
|
{{ $locale.baseText('mfa.setup.step2.infobox.description.part2') }}
|
||||||
|
</n8n-text>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</n8n-info-tip>
|
||||||
|
<div>
|
||||||
|
<n8n-button
|
||||||
|
type="primary"
|
||||||
|
icon="download"
|
||||||
|
float="right"
|
||||||
|
:label="$locale.baseText('mfa.setup.step2.button.download')"
|
||||||
|
data-test-id="mfa-recovery-codes-button"
|
||||||
|
@click="onDownloadClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div v-if="showRecoveryCodes">
|
||||||
|
<div>
|
||||||
|
<n8n-button
|
||||||
|
float="right"
|
||||||
|
:disabled="!recoveryCodesDownloaded"
|
||||||
|
:label="$locale.baseText('mfa.setup.step2.button.save')"
|
||||||
|
size="large"
|
||||||
|
data-test-id="mfa-save-button"
|
||||||
|
@click="onSetupClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div>
|
||||||
|
<n8n-button
|
||||||
|
float="right"
|
||||||
|
:label="$locale.baseText('mfa.setup.step1.button.continue')"
|
||||||
|
size="large"
|
||||||
|
:disabled="!readyToSubmit"
|
||||||
|
@click="onSaveClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import {
|
||||||
|
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||||
|
MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED,
|
||||||
|
MFA_SETUP_MODAL_KEY,
|
||||||
|
} from '../constants';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { mapStores } from 'pinia';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
|
import { mfaEventBus } from '@/event-bus';
|
||||||
|
import { useToast } from '@/composables';
|
||||||
|
//@ts-ignore
|
||||||
|
import QrcodeVue from 'qrcode.vue';
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MfaSetupModal',
|
||||||
|
mixins: [copyPaste],
|
||||||
|
components: {
|
||||||
|
Modal,
|
||||||
|
QrcodeVue,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
...useToast(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
modalBus: mfaEventBus,
|
||||||
|
MFA_SETUP_MODAL_KEY,
|
||||||
|
secret: '',
|
||||||
|
qrCode: '',
|
||||||
|
readyToSubmit: false,
|
||||||
|
formBus: mfaEventBus,
|
||||||
|
showRecoveryCodes: false,
|
||||||
|
recoveryCodes: [] as string[],
|
||||||
|
recoveryCodesDownloaded: false,
|
||||||
|
authenticatorCode: '',
|
||||||
|
infoTextErrorMessage: '',
|
||||||
|
loadingQrCode: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapStores(useNDVStore, useUIStore, useUsersStore),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeDialog(): void {
|
||||||
|
this.modalBus.emit('close');
|
||||||
|
},
|
||||||
|
onInput(value: string) {
|
||||||
|
if (value.length !== MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH) {
|
||||||
|
this.infoTextErrorMessage = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.usersStore
|
||||||
|
.verifyMfaToken({ token: value })
|
||||||
|
.then(() => {
|
||||||
|
this.showRecoveryCodes = true;
|
||||||
|
this.authenticatorCode = value;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.infoTextErrorMessage = this.$locale.baseText('mfa.setup.invalidCode');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCopySecretToClipboard() {
|
||||||
|
this.copyToClipboard(this.secret);
|
||||||
|
this.showToast({
|
||||||
|
title: this.$locale.baseText('mfa.setup.step1.toast.copyToClipboard.title'),
|
||||||
|
message: this.$locale.baseText('mfa.setup.step1.toast.copyToClipboard.message'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async onSubmit(form: { authenticatorCode: string }) {
|
||||||
|
try {
|
||||||
|
await this.usersStore.verifyMfaToken({ token: form.authenticatorCode });
|
||||||
|
this.showRecoveryCodes = true;
|
||||||
|
this.authenticatorCode = form.authenticatorCode;
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(error, this.$locale.baseText('settings.mfa.invalidAuthenticatorCode'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSaveClick() {
|
||||||
|
this.formBus.emit('submit');
|
||||||
|
},
|
||||||
|
onDownloadClick() {
|
||||||
|
const filename = 'n8n-recovery-codes.txt';
|
||||||
|
const temporalElement = document.createElement('a');
|
||||||
|
temporalElement.setAttribute(
|
||||||
|
'href',
|
||||||
|
'data:text/plain;charset=utf-8,' + encodeURIComponent(this.recoveryCodes.join('\n')),
|
||||||
|
);
|
||||||
|
temporalElement.setAttribute('download', filename);
|
||||||
|
temporalElement.style.display = 'none';
|
||||||
|
document.body.appendChild(temporalElement);
|
||||||
|
temporalElement.click();
|
||||||
|
document.body.removeChild(temporalElement);
|
||||||
|
this.recoveryCodesDownloaded = true;
|
||||||
|
},
|
||||||
|
async onSetupClick() {
|
||||||
|
try {
|
||||||
|
await this.usersStore.enableMfa({ token: this.authenticatorCode });
|
||||||
|
this.closeDialog();
|
||||||
|
this.showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: this.$locale.baseText('mfa.setup.step2.toast.setupFinished.message'),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.errorCode === MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED) {
|
||||||
|
this.showMessage({
|
||||||
|
type: 'error',
|
||||||
|
title: this.$locale.baseText('mfa.setup.step2.toast.tokenExpired.error.message'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showMessage({
|
||||||
|
type: 'error',
|
||||||
|
title: this.$locale.baseText('mfa.setup.step2.toast.setupFinished.error.message'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getMfaQR() {
|
||||||
|
try {
|
||||||
|
const { secret, qrCode, recoveryCodes } = await this.usersStore.getMfaQR();
|
||||||
|
this.qrCode = qrCode;
|
||||||
|
this.secret = secret;
|
||||||
|
this.recoveryCodes = recoveryCodes;
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(error, this.$locale.baseText('settings.api.view.error'));
|
||||||
|
} finally {
|
||||||
|
this.loadingQrCode = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.getMfaQR();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.container > * {
|
||||||
|
overflow: visible;
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textContainer {
|
||||||
|
text-align: left;
|
||||||
|
margin: 0px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formContainer {
|
||||||
|
padding-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContainer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.recoveryCodesContainer {
|
||||||
|
height: 140px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-background-base);
|
||||||
|
text-align: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: normal;
|
||||||
|
align-content: normal;
|
||||||
|
padding-top: var(--spacing-xs);
|
||||||
|
padding-bottom: var(--spacing-xs);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoveryCodesContainer span {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
line-height: var(--spacing-m);
|
||||||
|
color: #7d7d87;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form:first-child span {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
font-weight: var(--font-weight-regular);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
.form input {
|
||||||
|
width: 50%;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
.secret {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loseAccessText {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error input {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error > div > span {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
justify-content: space-between;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalContent {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -65,6 +65,10 @@
|
||||||
<ActivationModal />
|
<ActivationModal />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="MFA_SETUP_MODAL_KEY">
|
||||||
|
<MfaSetupModal />
|
||||||
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="WORKFLOW_SHARE_MODAL_KEY">
|
<ModalRoot :name="WORKFLOW_SHARE_MODAL_KEY">
|
||||||
<template #default="{ modalName, active, data }">
|
<template #default="{ modalName, active, data }">
|
||||||
<WorkflowShareModal :data="data" :isActive="active" :modalName="modalName" />
|
<WorkflowShareModal :data="data" :isActive="active" :modalName="modalName" />
|
||||||
|
@ -143,6 +147,7 @@ import {
|
||||||
LOG_STREAM_MODAL_KEY,
|
LOG_STREAM_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||||
|
MFA_SETUP_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from './AboutModal.vue';
|
import AboutModal from './AboutModal.vue';
|
||||||
|
@ -164,6 +169,7 @@ import WorkflowSettings from './WorkflowSettings.vue';
|
||||||
import DeleteUserModal from './DeleteUserModal.vue';
|
import DeleteUserModal from './DeleteUserModal.vue';
|
||||||
import ActivationModal from './ActivationModal.vue';
|
import ActivationModal from './ActivationModal.vue';
|
||||||
import ImportCurlModal from './ImportCurlModal.vue';
|
import ImportCurlModal from './ImportCurlModal.vue';
|
||||||
|
import MfaSetupModal from './MfaSetupModal.vue';
|
||||||
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||||
|
@ -195,6 +201,7 @@ export default defineComponent({
|
||||||
EventDestinationSettingsModal,
|
EventDestinationSettingsModal,
|
||||||
SourceControlPushModal,
|
SourceControlPushModal,
|
||||||
SourceControlPullModal,
|
SourceControlPullModal,
|
||||||
|
MfaSetupModal,
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
|
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
|
||||||
|
@ -219,6 +226,7 @@ export default defineComponent({
|
||||||
LOG_STREAM_MODAL_KEY,
|
LOG_STREAM_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||||
|
MFA_SETUP_MODAL_KEY,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -45,9 +45,9 @@ export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall';
|
||||||
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
|
export const COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY = 'communityPackageManageConfirm';
|
||||||
export const IMPORT_CURL_MODAL_KEY = 'importCurl';
|
export const IMPORT_CURL_MODAL_KEY = 'importCurl';
|
||||||
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
|
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
|
||||||
|
|
||||||
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
|
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
|
||||||
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
|
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
|
||||||
|
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
|
@ -67,6 +67,7 @@ export const BUILTIN_NODES_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/built
|
||||||
export const BUILTIN_CREDENTIALS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/builtin/credentials/`;
|
export const BUILTIN_CREDENTIALS_DOCS_URL = `https://${DOCS_DOMAIN}/integrations/builtin/credentials/`;
|
||||||
export const DATA_PINNING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-pinning/`;
|
export const DATA_PINNING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-pinning/`;
|
||||||
export const DATA_EDITING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-editing/`;
|
export const DATA_EDITING_DOCS_URL = `https://${DOCS_DOMAIN}/data/data-editing/`;
|
||||||
|
export const MFA_DOCS_URL = `https://${DOCS_DOMAIN}/user-management/two-factor-auth/`;
|
||||||
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/';
|
||||||
export const NPM_PACKAGE_DOCS_BASE_URL = 'https://www.npmjs.com/package/';
|
export const NPM_PACKAGE_DOCS_BASE_URL = 'https://www.npmjs.com/package/';
|
||||||
export const NPM_KEYWORD_SEARCH_URL =
|
export const NPM_KEYWORD_SEARCH_URL =
|
||||||
|
@ -375,6 +376,7 @@ export const enum VIEWS {
|
||||||
SAML_ONBOARDING = 'SamlOnboarding',
|
SAML_ONBOARDING = 'SamlOnboarding',
|
||||||
SOURCE_CONTROL = 'SourceControl',
|
SOURCE_CONTROL = 'SourceControl',
|
||||||
AUDIT_LOGS = 'AuditLogs',
|
AUDIT_LOGS = 'AuditLogs',
|
||||||
|
MFA_VIEW = 'MfaView',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum FAKE_DOOR_FEATURES {
|
export const enum FAKE_DOOR_FEATURES {
|
||||||
|
@ -532,6 +534,14 @@ export const ASK_AI_EXPERIMENT = {
|
||||||
|
|
||||||
export const EXPERIMENTS_TO_TRACK = [ASK_AI_EXPERIMENT.name];
|
export const EXPERIMENTS_TO_TRACK = [ASK_AI_EXPERIMENT.name];
|
||||||
|
|
||||||
|
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;
|
||||||
|
|
||||||
|
export const MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED = 997;
|
||||||
|
|
||||||
|
export const MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH = 6;
|
||||||
|
|
||||||
|
export const MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH = 36;
|
||||||
|
|
||||||
export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE];
|
export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE];
|
||||||
|
|
||||||
export const ALLOWED_HTML_ATTRIBUTES = ['href', 'name', 'target', 'title', 'class', 'id', 'style'];
|
export const ALLOWED_HTML_ATTRIBUTES = ['href', 'name', 'target', 'title', 'class', 'id', 'style'];
|
||||||
|
|
|
@ -3,3 +3,4 @@ export * from './data-pinning';
|
||||||
export * from './link-actions';
|
export * from './link-actions';
|
||||||
export * from './html-editor';
|
export * from './html-editor';
|
||||||
export * from './node-view';
|
export * from './node-view';
|
||||||
|
export * from './mfa';
|
||||||
|
|
3
packages/editor-ui/src/event-bus/mfa.ts
Normal file
3
packages/editor-ui/src/event-bus/mfa.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { createEventBus } from 'n8n-design-system';
|
||||||
|
|
||||||
|
export const mfaEventBus = createEventBus();
|
|
@ -80,5 +80,16 @@ export const genericHelpers = defineComponent({
|
||||||
this.loadingService = null;
|
this.loadingService = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isRedirectSafe() {
|
||||||
|
const redirect = this.getRedirectQueryParameter();
|
||||||
|
return redirect.startsWith('/');
|
||||||
|
},
|
||||||
|
getRedirectQueryParameter() {
|
||||||
|
let redirect = '';
|
||||||
|
if (typeof this.$route.query.redirect === 'string') {
|
||||||
|
redirect = decodeURIComponent(this.$route.query.redirect);
|
||||||
|
}
|
||||||
|
return redirect;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -82,22 +82,22 @@
|
||||||
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
|
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
|
||||||
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
|
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
|
||||||
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
"activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.",
|
||||||
"auth.changePassword": "Change Password",
|
"auth.changePassword": "Change password",
|
||||||
"auth.changePassword.currentPassword": "Current Password",
|
"auth.changePassword.currentPassword": "Current password",
|
||||||
"auth.changePassword.error": "Problem changing the password",
|
"auth.changePassword.error": "Problem changing the password",
|
||||||
"auth.changePassword.missingTokenError": "Missing token",
|
"auth.changePassword.missingTokenError": "Missing token",
|
||||||
"auth.changePassword.missingUserIdError": "Missing user ID",
|
"auth.changePassword.missingUserIdError": "Missing user ID",
|
||||||
"auth.changePassword.passwordUpdated": "Password updated",
|
"auth.changePassword.passwordUpdated": "Password updated",
|
||||||
"auth.changePassword.passwordUpdatedMessage": "You can now sign in with your new password",
|
"auth.changePassword.passwordUpdatedMessage": "You can now sign in with your new password",
|
||||||
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
|
"auth.changePassword.passwordsMustMatchError": "Passwords must match",
|
||||||
"auth.changePassword.reenterNewPassword": "Re-enter New Password",
|
"auth.changePassword.reenterNewPassword": "Re-enter new password",
|
||||||
"auth.changePassword.tokenValidationError": "Issue validating invite token",
|
"auth.changePassword.tokenValidationError": "Issue validating invite token",
|
||||||
"auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
|
"auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
|
||||||
"auth.validation.missingParameters": "Missing token or user id",
|
"auth.validation.missingParameters": "Missing token or user id",
|
||||||
"auth.email": "Email",
|
"auth.email": "Email",
|
||||||
"auth.firstName": "First Name",
|
"auth.firstName": "First Name",
|
||||||
"auth.lastName": "Last Name",
|
"auth.lastName": "Last Name",
|
||||||
"auth.newPassword": "New Password",
|
"auth.newPassword": "New password",
|
||||||
"auth.password": "Password",
|
"auth.password": "Password",
|
||||||
"auth.role": "Role",
|
"auth.role": "Role",
|
||||||
"auth.roles.member": "Member",
|
"auth.roles.member": "Member",
|
||||||
|
@ -1815,7 +1815,6 @@
|
||||||
"contextual.credentials.sharing.unavailable.button": "View plans",
|
"contextual.credentials.sharing.unavailable.button": "View plans",
|
||||||
"contextual.credentials.sharing.unavailable.button.cloud": "Upgrade now",
|
"contextual.credentials.sharing.unavailable.button.cloud": "Upgrade now",
|
||||||
"contextual.credentials.sharing.unavailable.button.desktop": "View plans",
|
"contextual.credentials.sharing.unavailable.button.desktop": "View plans",
|
||||||
|
|
||||||
"contextual.workflows.sharing.title": "Sharing",
|
"contextual.workflows.sharing.title": "Sharing",
|
||||||
"contextual.workflows.sharing.unavailable.title": "Sharing",
|
"contextual.workflows.sharing.unavailable.title": "Sharing",
|
||||||
"contextual.workflows.sharing.unavailable.title.cloud": "Upgrade to collaborate",
|
"contextual.workflows.sharing.unavailable.title.cloud": "Upgrade to collaborate",
|
||||||
|
@ -1968,6 +1967,57 @@
|
||||||
"settings.sso.actionBox.title": "Available on the Enterprise plan",
|
"settings.sso.actionBox.title": "Available on the Enterprise plan",
|
||||||
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
|
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
|
||||||
"settings.sso.actionBox.buttonText": "See plans",
|
"settings.sso.actionBox.buttonText": "See plans",
|
||||||
|
"settings.mfa.secret": "Secret {secret}",
|
||||||
|
"settings.mfa": "MFA",
|
||||||
|
"settings.mfa.title": "Multi-factor Authentication",
|
||||||
|
"settings.mfa.updateConfiguration": "MFA configuration updated",
|
||||||
|
"settings.mfa.invalidAuthenticatorCode": "Invalid authenticator code",
|
||||||
|
"mfa.setup.invalidAuthenticatorCode": "{code} is not a valid number",
|
||||||
|
"mfa.setup.invalidCode": "Two-factor code failed. Please try again.",
|
||||||
|
"mfa.code.modal.title": "Two-factor authentication",
|
||||||
|
"mfa.recovery.modal.title": "Two-factor recovery",
|
||||||
|
"mfa.code.input.info": "Don't have your auth device?",
|
||||||
|
"mfa.code.input.info.action": "Enter a recovery code",
|
||||||
|
"mfa.recovery.input.info.action": "enter a recovery code",
|
||||||
|
"mfa.code.button.continue": "Continue",
|
||||||
|
"mfa.recovery.button.verify": "Verify",
|
||||||
|
"mfa.button.back": "Back",
|
||||||
|
"mfa.code.input.label": "Two-factor code",
|
||||||
|
"mfa.code.input.placeholder": "e.g. 123456",
|
||||||
|
"mfa.recovery.input.label": "Recovery Code",
|
||||||
|
"mfa.recovery.input.placeholder": "e.g c79f9c02-7b2e-44...",
|
||||||
|
"mfa.code.invalid": "This code is invalid, try again or",
|
||||||
|
"mfa.recovery.invalid": "This code is invalid or was already used, try again",
|
||||||
|
"mfa.setup.step1.title": "Setup Authenticator app [1/2]",
|
||||||
|
"mfa.setup.step2.title": "Download your recovery codes [2/2]",
|
||||||
|
"mfa.setup.step1.instruction1.title": "1. Scan the QR code",
|
||||||
|
"mfa.setup.step1.instruction1.subtitle": "{part1} {part2}",
|
||||||
|
"mfa.setup.step1.instruction1.subtitle.part1": "Use an authenticator app from your phone to scan. If you can't scan the QR code, enter",
|
||||||
|
"mfa.setup.step1.instruction1.subtitle.part2": "this text code",
|
||||||
|
"mfa.setup.step1.instruction2.title": "2. Enter the code from the app",
|
||||||
|
"mfa.setup.step2.description": "You can use recovery codes as a second factor to authenticate in case you lose access to your device.",
|
||||||
|
"mfa.setup.step2.infobox.description": "{part1} {part2}",
|
||||||
|
"mfa.setup.step2.infobox.description.part1": "Keep your recovery codes somewhere safe. If you lose your device and your recovery codes, you will",
|
||||||
|
"mfa.setup.step2.infobox.description.part2": "lose access to your account.",
|
||||||
|
"mfa.setup.step2.button.download": "Download recovery codes",
|
||||||
|
"mfa.setup.step2.button.save": "I have downloaded my recovery codes",
|
||||||
|
"mfa.setup.step1.button.continue": "Continue",
|
||||||
|
"mfa.setup.step1.input.label": "Code from your authenticator app",
|
||||||
|
"mfa.setup.step1.toast.copyToClipboard.title": "Code copied to clipboard",
|
||||||
|
"mfa.setup.step1.toast.copyToClipboard.message": "Enter the code in your authenticator app",
|
||||||
|
"mfa.setup.step2.toast.setupFinished.message": "Two-factor authentication enabled",
|
||||||
|
"mfa.setup.step2.toast.setupFinished.error.message": "Error enabling two-factor authentication",
|
||||||
|
"mfa.setup.step2.toast.tokenExpired.error.message": "MFA token expired. Close the modal and enable MFA again",
|
||||||
|
"settings.personal.mfa.section.title": "Two-factor authentication (2FA)",
|
||||||
|
"settings.personal.mfa.button.disabled.infobox": "Two-factor authentication is currently disabled.",
|
||||||
|
"settings.personal.mfa.button.enabled.infobox": "Two-factor authentication is currently enabled.",
|
||||||
|
"settings.personal.mfa.button.enabled": "Enable 2FA",
|
||||||
|
"settings.personal.mfa.button.disabled": "Disable two-factor authentication",
|
||||||
|
"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.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",
|
"sso.login.divider": "or",
|
||||||
"sso.login.button": "Continue with SSO",
|
"sso.login.button": "Continue with SSO",
|
||||||
"executionUsage.currentUsage": "{text} {count}",
|
"executionUsage.currentUsage": "{text} {count}",
|
||||||
|
|
|
@ -133,6 +133,7 @@ import {
|
||||||
faStickyNote as faSolidStickyNote,
|
faStickyNote as faSolidStickyNote,
|
||||||
faUserLock,
|
faUserLock,
|
||||||
faGem,
|
faGem,
|
||||||
|
faDownload,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faVariable, faXmark } from './custom';
|
import { faVariable, faXmark } from './custom';
|
||||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
@ -278,6 +279,7 @@ export const FontAwesomePlugin: Plugin<{}> = {
|
||||||
addIcon(faUserLock);
|
addIcon(faUserLock);
|
||||||
addIcon(faGem);
|
addIcon(faGem);
|
||||||
addIcon(faXmark);
|
addIcon(faXmark);
|
||||||
|
addIcon(faDownload);
|
||||||
|
|
||||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||||
},
|
},
|
||||||
|
|
|
@ -60,6 +60,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
loginLabel: '',
|
loginLabel: '',
|
||||||
loginEnabled: false,
|
loginEnabled: false,
|
||||||
},
|
},
|
||||||
|
mfa: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
onboardingCallPromptEnabled: false,
|
onboardingCallPromptEnabled: false,
|
||||||
saveDataErrorExecution: 'all',
|
saveDataErrorExecution: 'all',
|
||||||
saveDataSuccessExecution: 'all',
|
saveDataSuccessExecution: 'all',
|
||||||
|
@ -133,6 +136,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
isTelemetryEnabled(): boolean {
|
isTelemetryEnabled(): boolean {
|
||||||
return this.settings.telemetry && this.settings.telemetry.enabled;
|
return this.settings.telemetry && this.settings.telemetry.enabled;
|
||||||
},
|
},
|
||||||
|
isMfaFeatureEnabled(): boolean {
|
||||||
|
return this.settings?.mfa?.enabled;
|
||||||
|
},
|
||||||
areTagsEnabled(): boolean {
|
areTagsEnabled(): boolean {
|
||||||
return this.settings.workflowTagsDisabled !== undefined
|
return this.settings.workflowTagsDisabled !== undefined
|
||||||
? !this.settings.workflowTagsDisabled
|
? !this.settings.workflowTagsDisabled
|
||||||
|
@ -354,3 +360,5 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { useUsersStore };
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
IMPORT_CURL_MODAL_KEY,
|
IMPORT_CURL_MODAL_KEY,
|
||||||
INVITE_USER_MODAL_KEY,
|
INVITE_USER_MODAL_KEY,
|
||||||
LOG_STREAM_MODAL_KEY,
|
LOG_STREAM_MODAL_KEY,
|
||||||
|
MFA_SETUP_MODAL_KEY,
|
||||||
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
|
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
|
||||||
PERSONALIZATION_MODAL_KEY,
|
PERSONALIZATION_MODAL_KEY,
|
||||||
STORES,
|
STORES,
|
||||||
|
@ -122,6 +123,9 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
curlCommand: '',
|
curlCommand: '',
|
||||||
httpNodeParameters: '',
|
httpNodeParameters: '',
|
||||||
},
|
},
|
||||||
|
[MFA_SETUP_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
},
|
||||||
[LOG_STREAM_MODAL_KEY]: {
|
[LOG_STREAM_MODAL_KEY]: {
|
||||||
open: false,
|
open: false,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
|
|
@ -38,6 +38,7 @@ import { usePostHog } from './posthog.store';
|
||||||
import { useSettingsStore } from './settings.store';
|
import { useSettingsStore } from './settings.store';
|
||||||
import { useUIStore } from './ui.store';
|
import { useUIStore } from './ui.store';
|
||||||
import { useCloudPlanStore } from './cloudPlan.store';
|
import { useCloudPlanStore } from './cloudPlan.store';
|
||||||
|
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
|
||||||
|
|
||||||
const isDefaultUser = (user: IUserResponse | null) =>
|
const isDefaultUser = (user: IUserResponse | null) =>
|
||||||
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner);
|
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner);
|
||||||
|
@ -68,6 +69,9 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
isInstanceOwner(): boolean {
|
isInstanceOwner(): boolean {
|
||||||
return isInstanceOwner(this.currentUser);
|
return isInstanceOwner(this.currentUser);
|
||||||
},
|
},
|
||||||
|
mfaEnabled(): boolean {
|
||||||
|
return this.currentUser?.mfaEnabled ?? false;
|
||||||
|
},
|
||||||
getUserById(state) {
|
getUserById(state) {
|
||||||
return (userId: string): IUser | null => state.users[userId];
|
return (userId: string): IUser | null => state.users[userId];
|
||||||
},
|
},
|
||||||
|
@ -167,7 +171,12 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
|
|
||||||
usePostHog().init(user.featureFlags);
|
usePostHog().init(user.featureFlags);
|
||||||
},
|
},
|
||||||
async loginWithCreds(params: { email: string; password: string }): Promise<void> {
|
async loginWithCreds(params: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
mfaToken?: string;
|
||||||
|
mfaRecoveryCode?: string;
|
||||||
|
}): Promise<void> {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const user = await login(rootStore.getRestApiContext, params);
|
const user = await login(rootStore.getRestApiContext, params);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -233,7 +242,11 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
await validatePasswordToken(rootStore.getRestApiContext, params);
|
await validatePasswordToken(rootStore.getRestApiContext, params);
|
||||||
},
|
},
|
||||||
async changePassword(params: { token: string; password: string }): Promise<void> {
|
async changePassword(params: {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
mfaToken?: string;
|
||||||
|
}): Promise<void> {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
await changePassword(rootStore.getRestApiContext, params);
|
await changePassword(rootStore.getRestApiContext, params);
|
||||||
},
|
},
|
||||||
|
@ -326,5 +339,31 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
uiStore.openModal(PERSONALIZATION_MODAL_KEY);
|
uiStore.openModal(PERSONALIZATION_MODAL_KEY);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getMfaQR(): Promise<{ qrCode: string; secret: string; recoveryCodes: string[] }> {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
return getMfaQR(rootStore.getRestApiContext);
|
||||||
|
},
|
||||||
|
async verifyMfaToken(data: { token: string }): Promise<void> {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
return verifyMfaToken(rootStore.getRestApiContext, data);
|
||||||
|
},
|
||||||
|
async enableMfa(data: { token: string }) {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
await enableMfa(rootStore.getRestApiContext, data);
|
||||||
|
const currentUser = usersStore.currentUser;
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser.mfaEnabled = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async disabledMfa() {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
await disableMfa(rootStore.getRestApiContext);
|
||||||
|
const currentUser = usersStore.currentUser;
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser.mfaEnabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { useToast } from '@/composables';
|
||||||
|
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import type { IFormBoxConfig } from '@/Interface';
|
import type { IFormBoxConfig } from '@/Interface';
|
||||||
import { VIEWS } from '@/constants';
|
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ export default defineComponent({
|
||||||
...mapStores(useUsersStore),
|
...mapStores(useUsersStore),
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.config = {
|
const form: IFormBoxConfig = {
|
||||||
title: this.$locale.baseText('auth.changePassword'),
|
title: this.$locale.baseText('auth.changePassword'),
|
||||||
buttonText: this.$locale.baseText('auth.changePassword'),
|
buttonText: this.$locale.baseText('auth.changePassword'),
|
||||||
redirectText: this.$locale.baseText('auth.signin'),
|
redirectText: this.$locale.baseText('auth.signin'),
|
||||||
|
@ -77,6 +77,24 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
|
|
||||||
const token = this.getResetToken();
|
const token = this.getResetToken();
|
||||||
|
const mfaEnabled = this.getMfaEnabled();
|
||||||
|
|
||||||
|
if (mfaEnabled) {
|
||||||
|
form.inputs.push({
|
||||||
|
name: 'mfaToken',
|
||||||
|
initialValue: '',
|
||||||
|
properties: {
|
||||||
|
required: true,
|
||||||
|
label: this.$locale.baseText('mfa.code.input.label'),
|
||||||
|
placeholder: this.$locale.baseText('mfa.code.input.placeholder'),
|
||||||
|
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||||
|
capitalize: true,
|
||||||
|
validateOnBlur: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = form;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
@ -110,18 +128,28 @@ export default defineComponent({
|
||||||
this.password = e.value;
|
this.password = e.value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getResetToken(): string | null {
|
getResetToken() {
|
||||||
return !this.$route.query.token || typeof this.$route.query.token !== 'string'
|
return !this.$route.query.token || typeof this.$route.query.token !== 'string'
|
||||||
? null
|
? null
|
||||||
: this.$route.query.token;
|
: this.$route.query.token;
|
||||||
},
|
},
|
||||||
async onSubmit() {
|
getMfaEnabled() {
|
||||||
|
if (!this.$route.query.mfaEnabled) return null;
|
||||||
|
return this.$route.query.mfaEnabled === 'true' ? true : false;
|
||||||
|
},
|
||||||
|
async onSubmit(values: { mfaToken: string }) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const token = this.getResetToken();
|
const token = this.getResetToken();
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.usersStore.changePassword({ token, password: this.password });
|
const changePasswordParameters = {
|
||||||
|
token,
|
||||||
|
password: this.password,
|
||||||
|
...(values.mfaToken && { mfaToken: values.mfaToken }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.usersStore.changePassword(changePasswordParameters);
|
||||||
|
|
||||||
this.showMessage({
|
this.showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
249
packages/editor-ui/src/views/MfaView.vue
Normal file
249
packages/editor-ui/src/views/MfaView.vue
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<div :class="$style.logoContainer">
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
<n8n-card>
|
||||||
|
<div :class="$style.headerContainer">
|
||||||
|
<n8n-heading size="xlarge" color="text-dark">{{
|
||||||
|
showRecoveryCodeForm
|
||||||
|
? $locale.baseText('mfa.recovery.modal.title')
|
||||||
|
: $locale.baseText('mfa.code.modal.title')
|
||||||
|
}}</n8n-heading>
|
||||||
|
</div>
|
||||||
|
<div :class="[$style.formContainer, reportError ? $style.formError : '']">
|
||||||
|
<n8n-form-inputs
|
||||||
|
data-test-id="mfa-login-form"
|
||||||
|
v-if="formInputs"
|
||||||
|
:inputs="formInputs"
|
||||||
|
:eventBus="formBus"
|
||||||
|
@input="onInput"
|
||||||
|
@submit="onSubmit"
|
||||||
|
/>
|
||||||
|
<div :class="$style.infoBox">
|
||||||
|
<n8n-text
|
||||||
|
size="small"
|
||||||
|
color="text-base"
|
||||||
|
:bold="false"
|
||||||
|
v-if="!showRecoveryCodeForm && !reportError"
|
||||||
|
>{{ $locale.baseText('mfa.code.input.info') }}
|
||||||
|
<a data-test-id="mfa-enter-recovery-code-button" @click="onRecoveryCodeClick">{{
|
||||||
|
$locale.baseText('mfa.code.input.info.action')
|
||||||
|
}}</a></n8n-text
|
||||||
|
>
|
||||||
|
<n8n-text color="danger" v-if="reportError" size="small"
|
||||||
|
>{{ formError }}
|
||||||
|
<a
|
||||||
|
v-if="!showRecoveryCodeForm"
|
||||||
|
@click="onRecoveryCodeClick"
|
||||||
|
:class="$style.recoveryCodeLink"
|
||||||
|
>
|
||||||
|
{{ $locale.baseText('mfa.recovery.input.info.action') }}</a
|
||||||
|
>
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<n8n-button
|
||||||
|
float="right"
|
||||||
|
:loading="verifyingMfaToken"
|
||||||
|
:label="
|
||||||
|
showRecoveryCodeForm
|
||||||
|
? $locale.baseText('mfa.recovery.button.verify')
|
||||||
|
: $locale.baseText('mfa.code.button.continue')
|
||||||
|
"
|
||||||
|
size="large"
|
||||||
|
:disabled="!hasAnyChanges"
|
||||||
|
@click="onSaveClick"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
float="left"
|
||||||
|
:label="$locale.baseText('mfa.button.back')"
|
||||||
|
size="large"
|
||||||
|
type="tertiary"
|
||||||
|
@click="onBackClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n8n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||||
|
import type { IFormInputs } from '@/Interface';
|
||||||
|
import Logo from '../components/Logo.vue';
|
||||||
|
import {
|
||||||
|
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||||
|
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||||
|
} from '@/constants';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { mapStores } from 'pinia';
|
||||||
|
import { mfaEventBus } from '@/event-bus';
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
export const FORM = {
|
||||||
|
MFA_TOKEN: 'MFA_TOKEN',
|
||||||
|
MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MfaView',
|
||||||
|
mixins: [genericHelpers],
|
||||||
|
components: {
|
||||||
|
Logo,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
reportError: Boolean,
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.formInputs = [this.mfaTokenFieldWithDefaults()];
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
...useToast(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hasAnyChanges: false,
|
||||||
|
formBus: mfaEventBus,
|
||||||
|
formInputs: null as null | IFormInputs,
|
||||||
|
showRecoveryCodeForm: false,
|
||||||
|
verifyingMfaToken: false,
|
||||||
|
formError: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapStores(useUsersStore),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onRecoveryCodeClick() {
|
||||||
|
this.formError = '';
|
||||||
|
this.showRecoveryCodeForm = true;
|
||||||
|
this.hasAnyChanges = false;
|
||||||
|
this.formInputs = [this.mfaRecoveryCodeFieldWithDefaults()];
|
||||||
|
this.$emit('onFormChanged', FORM.MFA_RECOVERY_CODE);
|
||||||
|
},
|
||||||
|
onBackClick() {
|
||||||
|
if (!this.showRecoveryCodeForm) {
|
||||||
|
this.$emit('onBackClick', FORM.MFA_TOKEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showRecoveryCodeForm = false;
|
||||||
|
this.hasAnyChanges = true;
|
||||||
|
this.formInputs = [this.mfaTokenFieldWithDefaults()];
|
||||||
|
this.$emit('onBackClick', FORM.MFA_RECOVERY_CODE);
|
||||||
|
},
|
||||||
|
onInput({ target: { value, name } }: { target: { value: string; name: string } }) {
|
||||||
|
const isSubmittingMfaToken = name === 'token';
|
||||||
|
const inputValidLength = isSubmittingMfaToken
|
||||||
|
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
||||||
|
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
||||||
|
|
||||||
|
if (value.length !== inputValidLength) {
|
||||||
|
this.hasAnyChanges = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verifyingMfaToken = true;
|
||||||
|
this.hasAnyChanges = true;
|
||||||
|
|
||||||
|
this.onSubmit({ token: value, recoveryCode: value })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => (this.verifyingMfaToken = false));
|
||||||
|
},
|
||||||
|
async onSubmit(form: { token: string; recoveryCode: string }) {
|
||||||
|
this.formError = !this.showRecoveryCodeForm
|
||||||
|
? this.$locale.baseText('mfa.code.invalid')
|
||||||
|
: this.$locale.baseText('mfa.recovery.invalid');
|
||||||
|
this.$emit('submit', form);
|
||||||
|
},
|
||||||
|
onSaveClick() {
|
||||||
|
this.formBus.emit('submit');
|
||||||
|
},
|
||||||
|
mfaTokenFieldWithDefaults() {
|
||||||
|
return this.formField(
|
||||||
|
'token',
|
||||||
|
this.$locale.baseText('mfa.code.input.label'),
|
||||||
|
this.$locale.baseText('mfa.code.input.placeholder'),
|
||||||
|
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mfaRecoveryCodeFieldWithDefaults() {
|
||||||
|
return this.formField(
|
||||||
|
'recoveryCode',
|
||||||
|
this.$locale.baseText('mfa.recovery.input.label'),
|
||||||
|
this.$locale.baseText('mfa.recovery.input.placeholder'),
|
||||||
|
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
formField(name: string, label: string, placeholder: string, maxlength: number) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
initialValue: '',
|
||||||
|
properties: {
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
maxlength,
|
||||||
|
capitalize: true,
|
||||||
|
validateOnBlur: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: var(--spacing-2xl);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
width: 352px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textContainer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formContainer {
|
||||||
|
padding-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrContainer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContainer {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError input {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recoveryCodeLink {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoBox {
|
||||||
|
padding-top: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -43,6 +43,42 @@
|
||||||
}}</n8n-link>
|
}}</n8n-link>
|
||||||
</n8n-input-label>
|
</n8n-input-label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isMfaFeatureEnabled">
|
||||||
|
<div :class="$style.mfaSection">
|
||||||
|
<n8n-input-label :label="$locale.baseText('settings.personal.mfa.section.title')">
|
||||||
|
</n8n-input-label>
|
||||||
|
<n8n-text :bold="false" :class="$style.infoText">
|
||||||
|
{{
|
||||||
|
mfaDisabled
|
||||||
|
? $locale.baseText('settings.personal.mfa.button.disabled.infobox')
|
||||||
|
: $locale.baseText('settings.personal.mfa.button.enabled.infobox')
|
||||||
|
}}
|
||||||
|
<n8n-link :to="mfaDocsUrl" size="small" :bold="true">
|
||||||
|
{{ $locale.baseText('generic.learnMore') }}
|
||||||
|
</n8n-link>
|
||||||
|
</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.mfaButtonContainer" v-if="mfaDisabled">
|
||||||
|
<n8n-button
|
||||||
|
:class="$style.button"
|
||||||
|
float="left"
|
||||||
|
type="tertiary"
|
||||||
|
:label="$locale.baseText('settings.personal.mfa.button.enabled')"
|
||||||
|
data-test-id="enable-mfa-button"
|
||||||
|
@click="onMfaEnableClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<n8n-button
|
||||||
|
:class="$style.disableMfaButton"
|
||||||
|
float="left"
|
||||||
|
type="tertiary"
|
||||||
|
:label="$locale.baseText('settings.personal.mfa.button.disabled')"
|
||||||
|
data-test-id="disable-mfa-button"
|
||||||
|
@click="onMfaDisableClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
|
@ -59,8 +95,8 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useI18n, useToast } from '@/composables';
|
import { useI18n, useToast } from '@/composables';
|
||||||
import { CHANGE_PASSWORD_MODAL_KEY } from '@/constants';
|
|
||||||
import type { IFormInputs, IUser } from '@/Interface';
|
import type { IFormInputs, IUser } from '@/Interface';
|
||||||
|
import { CHANGE_PASSWORD_MODAL_KEY, MFA_DOCS_URL, MFA_SETUP_MODAL_KEY } from '@/constants';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
@ -84,6 +120,7 @@ export default defineComponent({
|
||||||
formInputs: null as null | IFormInputs,
|
formInputs: null as null | IFormInputs,
|
||||||
formBus: createEventBus(),
|
formBus: createEventBus(),
|
||||||
readyToSubmit: false,
|
readyToSubmit: false,
|
||||||
|
mfaDocsUrl: MFA_DOCS_URL,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -143,6 +180,12 @@ export default defineComponent({
|
||||||
this.settingsStore.isSamlLoginEnabled && this.settingsStore.isDefaultAuthenticationSaml
|
this.settingsStore.isSamlLoginEnabled && this.settingsStore.isDefaultAuthenticationSaml
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
mfaDisabled(): boolean {
|
||||||
|
return !this.usersStore.mfaEnabled;
|
||||||
|
},
|
||||||
|
isMfaFeatureEnabled(): boolean {
|
||||||
|
return this.settingsStore.isMfaFeatureEnabled;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onInput() {
|
onInput() {
|
||||||
|
@ -178,6 +221,25 @@ export default defineComponent({
|
||||||
openPasswordModal() {
|
openPasswordModal() {
|
||||||
this.uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
|
this.uiStore.openModal(CHANGE_PASSWORD_MODAL_KEY);
|
||||||
},
|
},
|
||||||
|
onMfaEnableClick() {
|
||||||
|
this.uiStore.openModal(MFA_SETUP_MODAL_KEY);
|
||||||
|
},
|
||||||
|
async onMfaDisableClick() {
|
||||||
|
try {
|
||||||
|
await this.usersStore.disabledMfa();
|
||||||
|
this.showToast({
|
||||||
|
title: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
||||||
|
message: this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.message'),
|
||||||
|
type: 'success',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.showError(
|
||||||
|
e,
|
||||||
|
this.$locale.baseText('settings.personal.mfa.toast.disabledMfa.error.message'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -194,7 +256,6 @@ export default defineComponent({
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
*:first-child {
|
*:first-child {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
@ -220,7 +281,36 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disableMfaButton {
|
||||||
|
--button-color: var(--color-danger);
|
||||||
|
margin-top: var(--spacing-2xs);
|
||||||
|
> span {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: var(--spacing-xs);
|
||||||
|
> span {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mfaSection {
|
||||||
|
margin-top: var(--spacing-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoText {
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.sectionHeader {
|
||||||
|
margin-top: var(--spacing-2xl);
|
||||||
margin-bottom: var(--spacing-s);
|
margin-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mfaButtonContainer {
|
||||||
|
margin-top: var(--spacing-2xs);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,29 +1,43 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<AuthView
|
<AuthView
|
||||||
|
v-if="!showMfaView"
|
||||||
:form="FORM_CONFIG"
|
:form="FORM_CONFIG"
|
||||||
:formLoading="loading"
|
:formLoading="loading"
|
||||||
:with-sso="true"
|
:with-sso="true"
|
||||||
data-test-id="signin-form"
|
data-test-id="signin-form"
|
||||||
@submit="onSubmit"
|
@submit="onEmailPasswordSubmitted"
|
||||||
/>
|
/>
|
||||||
|
<MfaView
|
||||||
|
v-if="showMfaView"
|
||||||
|
@submit="onMFASubmitted"
|
||||||
|
@onBackClick="onBackClick"
|
||||||
|
@onFormChanged="onFormChanged"
|
||||||
|
:reportError="reportError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import AuthView from './AuthView.vue';
|
import AuthView from './AuthView.vue';
|
||||||
|
import MfaView from './MfaView.vue';
|
||||||
import { useToast } from '@/composables';
|
import { useToast } from '@/composables';
|
||||||
|
|
||||||
import type { IFormBoxConfig } from '@/Interface';
|
import type { IFormBoxConfig } from '@/Interface';
|
||||||
import { VIEWS } from '@/constants';
|
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS } from '@/constants';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useCloudPlanStore, useUIStore } from '@/stores';
|
import { useCloudPlanStore, useUIStore } from '@/stores';
|
||||||
|
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||||
|
import { FORM } from './MfaView.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'SigninView',
|
name: 'SigninView',
|
||||||
|
mixins: [genericHelpers],
|
||||||
components: {
|
components: {
|
||||||
AuthView,
|
AuthView,
|
||||||
|
MfaView,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
return {
|
return {
|
||||||
|
@ -34,10 +48,17 @@ export default defineComponent({
|
||||||
return {
|
return {
|
||||||
FORM_CONFIG: {} as IFormBoxConfig,
|
FORM_CONFIG: {} as IFormBoxConfig,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
showMfaView: false,
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
reportError: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useUsersStore, useSettingsStore, useUIStore, useCloudPlanStore),
|
...mapStores(useUsersStore, useSettingsStore, useUIStore, useCloudPlanStore),
|
||||||
|
userHasMfaEnabled() {
|
||||||
|
return !!this.usersStore.currentUser?.mfaEnabled;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
let emailLabel = this.$locale.baseText('auth.email');
|
let emailLabel = this.$locale.baseText('auth.email');
|
||||||
|
@ -84,29 +105,92 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onSubmit(values: { [key: string]: string }) {
|
async onMFASubmitted(form: { token?: string; recoveryCode?: string }) {
|
||||||
|
await this.login({
|
||||||
|
email: this.email,
|
||||||
|
password: this.password,
|
||||||
|
token: form.token,
|
||||||
|
recoveryCode: form.recoveryCode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async onEmailPasswordSubmitted(form: { email: string; password: string }) {
|
||||||
|
await this.login(form);
|
||||||
|
},
|
||||||
|
async login(form: { email: string; password: string; token?: string; recoveryCode?: string }) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
await this.usersStore.loginWithCreds(values as { email: string; password: string });
|
await this.usersStore.loginWithCreds({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
mfaToken: form.token,
|
||||||
|
mfaRecoveryCode: form.recoveryCode,
|
||||||
|
});
|
||||||
|
this.loading = false;
|
||||||
await this.cloudPlanStore.checkForCloudPlanData();
|
await this.cloudPlanStore.checkForCloudPlanData();
|
||||||
await this.uiStore.initBanners();
|
await this.uiStore.initBanners();
|
||||||
this.clearAllStickyNotifications();
|
this.clearAllStickyNotifications();
|
||||||
this.loading = false;
|
this.checkRecoveryCodesLeft();
|
||||||
|
|
||||||
if (typeof this.$route.query.redirect === 'string') {
|
this.$telemetry.track('User attempted to login', {
|
||||||
const redirect = decodeURIComponent(this.$route.query.redirect);
|
result: this.showMfaView ? 'mfa_success' : 'success',
|
||||||
if (redirect.startsWith('/')) {
|
});
|
||||||
// protect against phishing
|
|
||||||
|
if (this.isRedirectSafe()) {
|
||||||
|
const redirect = this.getRedirectQueryParameter();
|
||||||
void this.$router.push(redirect);
|
void this.$router.push(redirect);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this.$router.push({ name: VIEWS.HOMEPAGE });
|
await this.$router.push({ name: VIEWS.HOMEPAGE });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.errorCode === MFA_AUTHENTICATION_REQUIRED_ERROR_CODE) {
|
||||||
|
this.showMfaView = true;
|
||||||
|
this.cacheCredentials(form);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$telemetry.track('User attempted to login', {
|
||||||
|
result: this.showMfaView ? 'mfa_token_rejected' : 'credentials_error',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this.showMfaView) {
|
||||||
this.showError(error, this.$locale.baseText('auth.signin.error'));
|
this.showError(error, this.$locale.baseText('auth.signin.error'));
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportError = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBackClick(fromForm: string) {
|
||||||
|
this.reportError = false;
|
||||||
|
if (fromForm === FORM.MFA_TOKEN) {
|
||||||
|
this.showMfaView = false;
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFormChanged(toForm: string) {
|
||||||
|
if (toForm === FORM.MFA_RECOVERY_CODE) {
|
||||||
|
this.reportError = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cacheCredentials(form: { email: string; password: string }) {
|
||||||
|
this.email = form.email;
|
||||||
|
this.password = form.password;
|
||||||
|
},
|
||||||
|
checkRecoveryCodesLeft() {
|
||||||
|
if (this.usersStore.currentUser) {
|
||||||
|
const { hasRecoveryCodesLeft, mfaEnabled } = this.usersStore.currentUser;
|
||||||
|
|
||||||
|
if (mfaEnabled && !hasRecoveryCodesLeft) {
|
||||||
|
this.showToast({
|
||||||
|
title: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.title'),
|
||||||
|
message: this.$locale.baseText('settings.mfa.toast.noRecoveryCodeLeft.message'),
|
||||||
|
type: 'info',
|
||||||
|
duration: 0,
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -2195,6 +2195,9 @@ export interface IN8nUISettings {
|
||||||
variables: {
|
variables: {
|
||||||
limit: number;
|
limit: number;
|
||||||
};
|
};
|
||||||
|
mfa: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
banners: {
|
banners: {
|
||||||
dismissed: string[];
|
dismissed: string[];
|
||||||
};
|
};
|
||||||
|
|
|
@ -65,6 +65,9 @@ importers:
|
||||||
cypress:
|
cypress:
|
||||||
specifier: ^12.17.2
|
specifier: ^12.17.2
|
||||||
version: 12.17.2
|
version: 12.17.2
|
||||||
|
cypress-otp:
|
||||||
|
specifier: ^1.0.3
|
||||||
|
version: 1.0.3
|
||||||
cypress-real-events:
|
cypress-real-events:
|
||||||
specifier: ^1.9.1
|
specifier: ^1.9.1
|
||||||
version: 1.9.1(cypress@12.17.2)
|
version: 1.9.1(cypress@12.17.2)
|
||||||
|
@ -362,6 +365,9 @@ importers:
|
||||||
openapi-types:
|
openapi-types:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
|
otpauth:
|
||||||
|
specifier: ^9.1.1
|
||||||
|
version: 9.1.1
|
||||||
p-cancelable:
|
p-cancelable:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
@ -884,6 +890,9 @@ importers:
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
qrcode.vue:
|
||||||
|
specifier: ^3.3.4
|
||||||
|
version: 3.3.4(vue@3.3.4)
|
||||||
stream-browserify:
|
stream-browserify:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
@ -4416,6 +4425,39 @@ packages:
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/core@12.0.1:
|
||||||
|
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@otplib/plugin-crypto@12.0.1:
|
||||||
|
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@otplib/plugin-thirty-two@12.0.1:
|
||||||
|
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
thirty-two: 1.0.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@otplib/preset-default@12.0.1:
|
||||||
|
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@otplib/preset-v11@12.0.1:
|
||||||
|
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@pinia/testing@0.1.3(pinia@2.1.6)(vue@3.3.4):
|
/@pinia/testing@0.1.3(pinia@2.1.6)(vue@3.3.4):
|
||||||
resolution: {integrity: sha512-D2Ds2s69kKFaRf2KCcP1NhNZEg5+we59aRyQalwRm7ygWfLM25nDH66267U3hNvRUOTx8ofL24GzodZkOmB5xw==}
|
resolution: {integrity: sha512-D2Ds2s69kKFaRf2KCcP1NhNZEg5+we59aRyQalwRm7ygWfLM25nDH66267U3hNvRUOTx8ofL24GzodZkOmB5xw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -9677,6 +9719,12 @@ packages:
|
||||||
nub: 0.0.0
|
nub: 0.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/cypress-otp@1.0.3:
|
||||||
|
resolution: {integrity: sha512-o7LssfI0HRHa+TkaOE5/Aukv6M9vsoZAtYESr9m7Ky2i+HRNb2p/IRelE7Z0wJ/UK2f+nXAGZIfXqraf9EPDqw==}
|
||||||
|
dependencies:
|
||||||
|
otplib: 12.0.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
/cypress-real-events@1.9.1(cypress@12.17.2):
|
/cypress-real-events@1.9.1(cypress@12.17.2):
|
||||||
resolution: {integrity: sha512-eDYW6NagNs8+68ugyPbB6U1aIsYF0E0WHR6upXo0PbTXZNqBNc2s9Y0u/N+pbU9HpFh+krl6iMhoz/ENlYBdCg==}
|
resolution: {integrity: sha512-eDYW6NagNs8+68ugyPbB6U1aIsYF0E0WHR6upXo0PbTXZNqBNc2s9Y0u/N+pbU9HpFh+krl6iMhoz/ENlYBdCg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -16360,6 +16408,14 @@ packages:
|
||||||
jssha: 3.3.0
|
jssha: 3.3.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/otplib@12.0.1:
|
||||||
|
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/preset-default': 12.0.1
|
||||||
|
'@otplib/preset-v11': 12.0.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
/p-cancelable@2.1.1:
|
/p-cancelable@2.1.1:
|
||||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -17522,6 +17578,14 @@ packages:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/qrcode.vue@3.3.4(vue@3.3.4):
|
||||||
|
resolution: {integrity: sha512-ZVPmKZUUqM/wZ19mIhecFJs7mO6KXFiZZmBZyU6wiB2aXZfYc/VpolXakQcKw/9aGFEmSHHVKfgNwyxtw/Q2Sw==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
dependencies:
|
||||||
|
vue: 3.3.4
|
||||||
|
dev: false
|
||||||
|
|
||||||
/qs@6.10.5:
|
/qs@6.10.5:
|
||||||
resolution: {integrity: sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ==}
|
resolution: {integrity: sha512-O5RlPh0VFtR78y79rgcgKK4wbAI0C5zGVLztOIdpWX6ep368q5Hv6XRxDvXuZ9q3C6v+e3n8UfZZJw7IIG27eQ==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
@ -19620,6 +19684,11 @@ packages:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/thirty-two@1.0.2:
|
||||||
|
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||||
|
engines: {node: '>=0.2.6'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/throttleit@1.0.0:
|
/throttleit@1.0.0:
|
||||||
resolution: {integrity: sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g==}
|
resolution: {integrity: sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
Loading…
Reference in a new issue