n8n/packages/cli/src/controllers/mfa.controller.ts
Guillaume Jacquart a7e2dcf1fe
feat(core): Add log streaming for personal publishing restriction changes (#25253)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-05 13:29:09 +00:00

210 lines
5.8 KiB
TypeScript

import { AuthenticatedRequest, UserRepository } from '@n8n/db';
import {
createUserKeyedRateLimiter,
Get,
GlobalScope,
Post,
RestController,
} from '@n8n/decorators';
import { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { MfaService } from '@/mfa/mfa.service';
import { MFA } from '@/requests';
@RestController('/mfa')
export class MFAController {
constructor(
private mfaService: MfaService,
private externalHooks: ExternalHooks,
private authService: AuthService,
private userRepository: UserRepository,
private eventService: EventService,
) {}
@Post('/enforce-mfa')
@GlobalScope('user:enforceMfa')
async enforceMFA(req: MFA.Enforce) {
if (req.body.enforce && !(req.authInfo?.usedMfa ?? false)) {
// The current user tries to enforce MFA, but does not have
// MFA set up for them self. We are forbidding this, to
// help the user not lock them selfs out.
throw new BadRequestError(
'You must enable two-factor authentication on your own account before enforcing it for all users',
);
}
await this.mfaService.enforceMFA(req.body.enforce);
this.eventService.emit('instance-policies-updated', {
user: {
id: req.user.id,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
},
settingName: '2fa_enforcement',
value: req.body.enforce,
});
return;
}
@Post('/can-enable', {
allowSkipMFA: true,
})
async canEnableMFA(req: AuthenticatedRequest) {
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
return;
}
@Get('/qr', {
allowSkipMFA: true,
})
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', {
allowSkipMFA: true,
keyedRateLimit: createUserKeyedRateLimiter({}),
})
async activateMFA(req: MFA.Activate, res: Response) {
const { mfaCode = null } = req.body;
const { id, mfaEnabled } = req.user;
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
await this.mfaService.getSecretAndRecoveryCodes(id);
if (!mfaCode) 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, mfaCode, window: 10 });
if (!verified)
throw new BadRequestError('MFA code expired. Close the modal and enable MFA again', 997);
const updatedUser = await this.mfaService.enableMfa(id);
this.eventService.emit('user-mfa-enabled', {
user: {
id: req.user.id,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
},
});
this.authService.issueCookie(res, updatedUser, verified, req.browserId);
}
@Post('/disable', {
ipRateLimit: true,
keyedRateLimit: createUserKeyedRateLimiter({}),
})
async disableMFA(req: MFA.Disable, res: Response) {
const { id: userId } = req.user;
const { mfaCode, mfaRecoveryCode } = req.body;
const mfaCodeDefined = mfaCode && typeof mfaCode === 'string';
const mfaRecoveryCodeDefined = mfaRecoveryCode && typeof mfaRecoveryCode === 'string';
if (!mfaCodeDefined === !mfaRecoveryCodeDefined) {
throw new BadRequestError(
'Either MFA code or recovery code is required to disable MFA feature',
);
}
if (mfaCodeDefined) {
await this.mfaService.disableMfaWithMfaCode(userId, mfaCode);
} else if (mfaRecoveryCodeDefined) {
await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode);
}
this.eventService.emit('user-mfa-disabled', {
user: {
id: req.user.id,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
},
disableMethod: mfaCodeDefined ? 'mfaCode' : 'recoveryCode',
});
const updatedUser = await this.userRepository.findOneOrFail({
where: { id: userId },
relations: ['role'],
});
this.authService.issueCookie(res, updatedUser, false, req.browserId);
}
@Post('/verify', {
allowSkipMFA: true,
keyedRateLimit: createUserKeyedRateLimiter({}),
})
async verifyMFA(req: MFA.Verify) {
const { id } = req.user;
const { mfaCode } = req.body;
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id);
if (!mfaCode) throw new BadRequestError('MFA code 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, mfaCode });
if (!verified) throw new BadRequestError('MFA secret could not be verified');
}
}