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:
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 { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import type { ICredentialsDb } from '@/Interfaces';
|
||||
|
||||
import { DB_INITIALIZATION_TIMEOUT } from './constants';
|
||||
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
|
||||
import type {
|
||||
@@ -38,6 +37,10 @@ import { VariablesService } from '@/environments/variables/variables.service';
|
||||
import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories';
|
||||
import { separate } from '@/utils';
|
||||
|
||||
import { randomPassword } from '@/Ldap/helpers';
|
||||
import { TOTPService } from '@/Mfa/totp.service';
|
||||
import { MfaService } from '@/Mfa/mfa.service';
|
||||
|
||||
export type TestDBType = 'postgres' | 'mysql';
|
||||
|
||||
export const testDbPrefix = 'n8n_test_';
|
||||
@@ -204,6 +207,41 @@ export async function createLdapUser(attributes: Partial<User>, ldapId: string):
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createUserWithMfaEnabled(
|
||||
data: { numberOfRecoveryCodes: number } = { numberOfRecoveryCodes: 10 },
|
||||
) {
|
||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||
|
||||
const email = randomEmail();
|
||||
const password = randomPassword();
|
||||
|
||||
const toptService = new TOTPService();
|
||||
|
||||
const secret = toptService.generateSecret();
|
||||
|
||||
const mfaService = new MfaService(Db.collections.User, toptService, encryptionKey);
|
||||
|
||||
const recoveryCodes = mfaService.generateRecoveryCodes(data.numberOfRecoveryCodes);
|
||||
|
||||
const { encryptedSecret, encryptedRecoveryCodes } = mfaService.encryptSecretAndRecoveryCodes(
|
||||
secret,
|
||||
recoveryCodes,
|
||||
);
|
||||
|
||||
return {
|
||||
user: await createUser({
|
||||
mfaEnabled: true,
|
||||
password,
|
||||
email,
|
||||
mfaSecret: encryptedSecret,
|
||||
mfaRecoveryCodes: encryptedRecoveryCodes,
|
||||
}),
|
||||
rawPassword: password,
|
||||
rawSecret: secret,
|
||||
rawRecoveryCodes: recoveryCodes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOwner() {
|
||||
return createUser({ globalRole: await getGlobalOwnerRole() });
|
||||
}
|
||||
@@ -592,13 +630,12 @@ const baseOptions = (type: TestDBType) => ({
|
||||
/**
|
||||
* Generate options for a bootstrap DB connection, to create and drop test databases.
|
||||
*/
|
||||
export const getBootstrapDBOptions = (type: TestDBType) =>
|
||||
({
|
||||
type,
|
||||
name: type,
|
||||
database: type,
|
||||
...baseOptions(type),
|
||||
}) as const;
|
||||
export const getBootstrapDBOptions = (type: TestDBType) => ({
|
||||
type,
|
||||
name: type,
|
||||
database: type,
|
||||
...baseOptions(type),
|
||||
});
|
||||
|
||||
const getDBOptions = (type: TestDBType, name: string) => ({
|
||||
type,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type EndpointGroup =
|
||||
| 'license'
|
||||
| 'variables'
|
||||
| 'tags'
|
||||
| 'mfa'
|
||||
| 'metrics';
|
||||
|
||||
export interface SetupProps {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerController } from '@/decorators';
|
||||
import {
|
||||
AuthController,
|
||||
LdapController,
|
||||
MFAController,
|
||||
MeController,
|
||||
NodesController,
|
||||
OwnerController,
|
||||
@@ -49,7 +50,9 @@ import * as testDb from '../../shared/testDb';
|
||||
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
|
||||
import type { EndpointGroup, SetupProps, TestServer } from '../types';
|
||||
import { mockInstance } from './mocking';
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { MfaService } from '@/Mfa/mfa.service';
|
||||
import { TOTPService } from '@/Mfa/totp.service';
|
||||
import { UserSettings } from 'n8n-core';
|
||||
import { MetricsService } from '@/services/metrics.service';
|
||||
|
||||
/**
|
||||
@@ -179,11 +182,12 @@ export const setupTestServer = ({
|
||||
}
|
||||
|
||||
if (functionEndpoints.length) {
|
||||
const encryptionKey = await UserSettings.getEncryptionKey();
|
||||
const repositories = Db.collections;
|
||||
const externalHooks = Container.get(ExternalHooks);
|
||||
const internalHooks = Container.get(InternalHooks);
|
||||
const mailer = Container.get(UserManagementMailer);
|
||||
const jwtService = Container.get(JwtService);
|
||||
const repositories = Db.collections;
|
||||
const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey);
|
||||
|
||||
for (const group of functionEndpoints) {
|
||||
switch (group) {
|
||||
@@ -197,14 +201,11 @@ export const setupTestServer = ({
|
||||
registerController(
|
||||
app,
|
||||
config,
|
||||
new AuthController({
|
||||
config,
|
||||
logger,
|
||||
internalHooks,
|
||||
repositories,
|
||||
}),
|
||||
new AuthController({ config, logger, internalHooks, repositories, mfaService }),
|
||||
);
|
||||
break;
|
||||
case 'mfa':
|
||||
registerController(app, config, new MFAController(mfaService));
|
||||
case 'ldap':
|
||||
Container.get(License).isLdapEnabled = () => true;
|
||||
await handleLdapInit();
|
||||
@@ -250,6 +251,7 @@ export const setupTestServer = ({
|
||||
externalHooks,
|
||||
internalHooks,
|
||||
mailer,
|
||||
mfaService,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user