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:
Ricardo Espinoza
2023-08-23 22:59:16 -04:00
committed by GitHub
parent a01c3fbc19
commit 2b7ba6fdf1
61 changed files with 2301 additions and 105 deletions

View File

@@ -159,6 +159,7 @@
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"openapi-types": "^10.0.0",
"otpauth": "^9.1.1",
"p-cancelable": "^2.0.0",
"p-lazy": "^3.1.0",
"passport": "^0.6.0",

View File

@@ -758,6 +758,7 @@ export interface PublicUser {
passwordResetToken?: string;
createdAt: Date;
isPending: boolean;
hasRecoveryCodesLeft: boolean;
globalRole?: Role;
signInType: AuthProviderType;
disabled: boolean;

View File

@@ -0,0 +1 @@
export const MFA_FEATURE_ENABLED = 'mfa.enabled';

View 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);
}
}
};

View 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: [],
});
}
}

View 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),
});
}
}

View File

@@ -45,8 +45,8 @@ export class BadRequestError extends ResponseError {
}
export class AuthError extends ResponseError {
constructor(message: string) {
super(message, 401);
constructor(message: string, errorCode?: number) {
super(message, 401, errorCode);
}
}

View File

@@ -88,6 +88,7 @@ import {
AuthController,
LdapController,
MeController,
MFAController,
NodesController,
NodeTypesController,
OwnerController,
@@ -167,6 +168,9 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
import { ExecutionRepository } from '@db/repositories';
import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
import { TOTPService } from './Mfa/totp.service';
import { MfaService } from './Mfa/mfa.service';
import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers';
const exec = promisify(callbackExec);
@@ -313,6 +317,9 @@ export class Server extends AbstractServer {
showNonProdBanner: false,
debugInEditor: false,
},
mfa: {
enabled: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
@@ -471,6 +478,9 @@ export class Server extends AbstractServer {
if (config.get('nodes.packagesMissing').length > 0) {
this.frontendSettings.missingPackages = true;
}
this.frontendSettings.mfa.enabled = isMfaFeatureEnabled();
return this.frontendSettings;
}
@@ -479,31 +489,19 @@ export class Server extends AbstractServer {
const repositories = Db.collections;
setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint);
const encryptionKey = await UserSettings.getEncryptionKey();
const logger = LoggerProxy;
const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer);
const postHog = this.postHog;
const mfaService = new MfaService(repositories.User, new TOTPService(), encryptionKey);
const controllers: object[] = [
new EventBusController(),
new AuthController({
config,
internalHooks,
repositories,
logger,
postHog,
}),
new OwnerController({
config,
internalHooks,
repositories,
logger,
}),
new MeController({
externalHooks,
internalHooks,
logger,
}),
new AuthController({ config, internalHooks, logger, postHog, mfaService }),
new OwnerController({ config, internalHooks, repositories, logger, postHog }),
new MeController({ externalHooks, internalHooks, logger }),
new NodeTypesController({ config, nodeTypes }),
new PasswordResetController({
config,
@@ -511,6 +509,7 @@ export class Server extends AbstractServer {
internalHooks,
mailer,
logger,
mfaService,
}),
Container.get(TagsController),
new TranslationController(config, this.credentialTypes),
@@ -546,6 +545,10 @@ export class Server extends AbstractServer {
controllers.push(Container.get(E2EController));
}
if (isMfaFeatureEnabled()) {
controllers.push(new MFAController(mfaService));
}
controllers.forEach((controller) => registerController(app, config, controller));
}
@@ -623,6 +626,8 @@ export class Server extends AbstractServer {
await handleLdapInit();
await handleMfaDisable();
await this.registerControllers(ignoredEndpoints);
this.app.use(`/${this.restEndpoint}/credentials`, credentialsController);

View File

@@ -88,21 +88,26 @@ export function validatePassword(password?: string): string {
* Remove sensitive properties from the user to return to the client.
*/
export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
const { password, updatedAt, apiKey, authIdentities, mfaSecret, mfaRecoveryCodes, ...rest } =
user;
if (withoutKeys) {
withoutKeys.forEach((key) => {
// @ts-ignore
delete rest[key];
});
}
const sanitizedUser: PublicUser = {
...rest,
signInType: 'email',
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
};
const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
if (ldapIdentity) {
sanitizedUser.signInType = 'ldap';
}
return sanitizedUser;
}

View 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`);
}
}

View File

@@ -929,6 +929,15 @@ export const schema = {
},
},
mfa: {
enabled: {
format: Boolean,
default: true,
doc: 'Whether to enable MFA feature in instance.',
env: 'N8N_MFA_ENABLED',
},
},
sso: {
justInTimeProvisioning: {
format: Boolean,

View File

@@ -16,12 +16,7 @@ import type { ILogger } from 'n8n-workflow';
import type { User } from '@db/entities/User';
import { LoginRequest, UserRequest } from '@/requests';
import type { Config } from '@/config';
import type {
PublicUser,
IDatabaseCollections,
IInternalHooksClass,
CurrentUser,
} from '@/Interfaces';
import type { PublicUser, IInternalHooksClass, CurrentUser } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import type { PostHogClient } from '@/posthog';
import {
@@ -32,6 +27,7 @@ import {
import { InternalHooks } from '../InternalHooks';
import { License } from '@/License';
import { UserService } from '@/services/user.service';
import type { MfaService } from '@/Mfa/mfa.service';
@RestController()
export class AuthController {
@@ -45,23 +41,27 @@ export class AuthController {
private readonly postHog?: PostHogClient;
private readonly mfaService: MfaService;
constructor({
config,
logger,
internalHooks,
postHog,
mfaService,
}: {
config: Config;
logger: ILogger;
internalHooks: IInternalHooksClass;
repositories: Pick<IDatabaseCollections, 'User'>;
postHog?: PostHogClient;
mfaService: MfaService;
}) {
this.config = config;
this.logger = logger;
this.internalHooks = internalHooks;
this.postHog = postHog;
this.userService = Container.get(UserService);
this.mfaService = mfaService;
}
/**
@@ -69,7 +69,7 @@ export class AuthController {
*/
@Post('/login')
async login(req: LoginRequest, res: Response): Promise<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 (!password) throw new Error('Password is required to log in');
@@ -94,7 +94,28 @@ export class AuthController {
} else {
user = await handleEmailLogin(email, password);
}
if (user) {
if (user.mfaEnabled) {
if (!mfaToken && !mfaRecoveryCode) {
throw new AuthError('MFA Error', 998);
}
const { decryptedRecoveryCodes, decryptedSecret } =
await this.mfaService.getSecretAndRecoveryCodes(user.id);
user.mfaSecret = decryptedSecret;
user.mfaRecoveryCodes = decryptedRecoveryCodes;
const isMFATokenValid =
(await this.validateMfaToken(user, mfaToken)) ||
(await this.validateMfaRecoveryCode(user, mfaRecoveryCode));
if (!isMFATokenValid) {
throw new AuthError('Invalid mfa token or recovery code');
}
}
await issueCookie(res, user);
void Container.get(InternalHooks).onUserLoginSuccess({
user,
@@ -229,4 +250,27 @@ export class AuthController {
res.clearCookie(AUTH_COOKIE_NAME);
return { loggedOut: true };
}
private async validateMfaToken(user: User, token?: string) {
if (!!!token) return false;
return this.mfaService.totp.verifySecret({
secret: user.mfaSecret ?? '',
token,
});
}
private async validateMfaRecoveryCode(user: User, mfaRecoveryCode?: string) {
if (!!!mfaRecoveryCode) return false;
const index = user.mfaRecoveryCodes.indexOf(mfaRecoveryCode);
if (index === -1) return false;
// remove used recovery code
user.mfaRecoveryCodes.splice(index, 1);
await this.userService.update(user.id, {
mfaRecoveryCodes: this.mfaService.encryptRecoveryCodes(user.mfaRecoveryCodes),
});
return true;
}
}

View File

@@ -12,6 +12,9 @@ import { LICENSE_FEATURES, inE2ETests } from '@/constants';
import { NoAuthRequired, Patch, Post, RestController } from '@/decorators';
import type { UserSetupPayload } from '@/requests';
import type { BooleanLicenseFeature } from '@/Interfaces';
import { UserSettings } from 'n8n-core';
import { MfaService } from '@/Mfa/mfa.service';
import { TOTPService } from '@/Mfa/totp.service';
if (!inE2ETests) {
console.error('E2E endpoints only allowed during E2E tests');
@@ -136,13 +139,30 @@ export class E2EController {
roles.map(([name, scope], index) => ({ name, scope, id: (index + 1).toString() })),
);
const users = [];
users.push({
const encryptionKey = await UserSettings.getEncryptionKey();
const mfaService = new MfaService(this.userRepo, new TOTPService(), encryptionKey);
const instanceOwner = {
id: uuid(),
...owner,
password: await hashPassword(owner.password),
globalRoleId: globalOwnerRoleId,
});
};
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
const { encryptedRecoveryCodes, encryptedSecret } = mfaService.encryptSecretAndRecoveryCodes(
owner.mfaSecret,
owner.mfaRecoveryCodes,
);
instanceOwner.mfaSecret = encryptedSecret;
instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes;
}
const users = [];
users.push(instanceOwner);
for (const { password, ...payload } of members) {
users.push(
this.userRepo.create({

View File

@@ -1,6 +1,7 @@
export { AuthController } from './auth.controller';
export { LdapController } from './ldap.controller';
export { MeController } from './me.controller';
export { MFAController } from './mfa.controller';
export { NodesController } from './nodes.controller';
export { NodeTypesController } from './nodeTypes.controller';
export { OwnerController } from './owner.controller';

View 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');
}
}

View File

@@ -30,6 +30,7 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { TokenExpiredError } from 'jsonwebtoken';
import type { JwtPayload } from '@/services/jwt.service';
import { JwtService } from '@/services/jwt.service';
import type { MfaService } from '@/Mfa/mfa.service';
@RestController()
export class PasswordResetController {
@@ -47,18 +48,22 @@ export class PasswordResetController {
private readonly userService: UserService;
private readonly mfaService: MfaService;
constructor({
config,
logger,
externalHooks,
internalHooks,
mailer,
mfaService,
}: {
config: Config;
logger: ILogger;
externalHooks: IExternalHooksClass;
internalHooks: IInternalHooksClass;
mailer: UserManagementMailer;
mfaService: MfaService;
}) {
this.config = config;
this.logger = logger;
@@ -67,6 +72,7 @@ export class PasswordResetController {
this.mailer = mailer;
this.jwtService = Container.get(JwtService);
this.userService = Container.get(UserService);
this.mfaService = mfaService;
}
/**
@@ -150,7 +156,11 @@ export class PasswordResetController {
},
);
const url = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken);
const url = this.userService.generatePasswordResetUrl(
baseUrl,
resetPasswordToken,
user.mfaEnabled,
);
try {
await this.mailer.passwordReset({
@@ -233,7 +243,7 @@ export class PasswordResetController {
*/
@Post('/change-password')
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token: resetPasswordToken, password } = req.body;
const { token: resetPasswordToken, password, mfaToken } = req.body;
if (!resetPasswordToken || !password) {
this.logger.debug(
@@ -264,6 +274,16 @@ export class PasswordResetController {
throw new NotFoundError('');
}
if (user.mfaEnabled) {
if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.');
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id);
const validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken });
if (!validToken) throw new BadRequestError('Invalid MFA token.');
}
const passwordHash = await hashPassword(validPassword);
await this.userService.update(user.id, { password: passwordHash });

View File

@@ -389,7 +389,11 @@ export class UsersController {
const baseUrl = getInstanceBaseUrl();
const link = this.userService.generatePasswordResetUrl(baseUrl, resetPasswordToken);
const link = this.userService.generatePasswordResetUrl(
baseUrl,
resetPasswordToken,
user.mfaEnabled,
);
return {
link,
};

View File

@@ -96,6 +96,15 @@ export class User extends WithTimestamps implements IUser {
@Index({ unique: true })
apiKey?: string | null;
@Column({ type: Boolean, default: false })
mfaEnabled: boolean;
@Column({ type: String, nullable: true, select: false })
mfaSecret?: string | null;
@Column({ type: 'simple-array', default: '', select: false })
mfaRecoveryCodes: string[];
/**
* Whether the user is pending setup completion.
*/

View File

@@ -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',
]);
}
}

View File

@@ -44,6 +44,7 @@ import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionD
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@@ -91,4 +92,5 @@ export const mysqlMigrations: Migration[] = [
RemoveSkipOwnerSetup1681134145997,
RemoveResetPasswordColumns1690000000030,
CreateWorkflowNameIndex1691088862123,
AddMfaColumns1690000000030,
];

View File

@@ -42,6 +42,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
import { AddMissingPrimaryKeyOnExecutionData1690787606731 } from './1690787606731-AddMissingPrimaryKeyOnExecutionData';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@@ -87,4 +88,5 @@ export const postgresMigrations: Migration[] = [
RemoveResetPasswordColumns1690000000030,
AddMissingPrimaryKeyOnExecutionData1690787606731,
CreateWorkflowNameIndex1691088862123,
AddMfaColumns1690000000030,
];

View File

@@ -41,6 +41,7 @@ import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwn
import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration';
import { RemoveResetPasswordColumns1690000000030 } from './1690000000030-RemoveResetPasswordColumns';
import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex';
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -85,6 +86,7 @@ const sqliteMigrations: Migration[] = [
FixMissingIndicesFromStringIdMigration1690000000020,
RemoveResetPasswordColumns1690000000030,
CreateWorkflowNameIndex1691088862123,
AddMfaColumns1690000000030,
];
export { sqliteMigrations };

View File

@@ -227,7 +227,7 @@ export declare namespace MeRequest {
export type Password = AuthenticatedRequest<
{},
{},
{ currentPassword: string; newPassword: string }
{ currentPassword: string; newPassword: string; token?: string }
>;
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
}
@@ -237,6 +237,9 @@ export interface UserSetupPayload {
password: string;
firstName: string;
lastName: string;
mfaEnabled?: boolean;
mfaSecret?: string;
mfaRecoveryCodes?: string[];
}
// ----------------------------------
@@ -261,7 +264,7 @@ export declare namespace PasswordResetRequest {
export type NewPassword = AuthlessRequest<
{},
{},
Pick<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;
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
// ----------------------------------

View File

@@ -51,10 +51,11 @@ export class UserService {
return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } });
}
generatePasswordResetUrl(instanceBaseUrl: string, token: string) {
generatePasswordResetUrl(instanceBaseUrl: string, token: string, mfaEnabled: boolean) {
const url = new URL(`${instanceBaseUrl}/change-password`);
url.searchParams.append('token', token);
url.searchParams.append('mfaEnabled', mfaEnabled.toString());
return url.toString();
}

View 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);
});
});
});

View File

@@ -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,

View File

@@ -26,6 +26,7 @@ export type EndpointGroup =
| 'license'
| 'variables'
| 'tags'
| 'mfa'
| 'metrics';
export interface SetupProps {

View File

@@ -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;