mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00: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:
@@ -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",
|
||||
|
||||
@@ -758,6 +758,7 @@ export interface PublicUser {
|
||||
passwordResetToken?: string;
|
||||
createdAt: Date;
|
||||
isPending: boolean;
|
||||
hasRecoveryCodesLeft: boolean;
|
||||
globalRole?: Role;
|
||||
signInType: AuthProviderType;
|
||||
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 {
|
||||
constructor(message: string) {
|
||||
super(message, 401);
|
||||
constructor(message: string, errorCode?: number) {
|
||||
super(message, 401, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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: {
|
||||
justInTimeProvisioning: {
|
||||
format: Boolean,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 { 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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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