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

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