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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user