mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 13:27:31 -08:00
standardize how we refer to mfa code and recovery code
Also updates the UI to allow the use of recovery codes when disabling MFA
This commit is contained in:
parent
f6e4118a1c
commit
f2a540db04
|
@ -76,7 +76,7 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
|
||||||
const loginToken = generateOTPToken(user.mfaSecret);
|
const loginToken = generateOTPToken(user.mfaSecret);
|
||||||
mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken);
|
mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken);
|
||||||
const disableToken = generateOTPToken(user.mfaSecret);
|
const disableToken = generateOTPToken(user.mfaSecret);
|
||||||
personalSettingsPage.actions.disableMfaWithMfaToken(disableToken);
|
personalSettingsPage.actions.disableMfa(disableToken);
|
||||||
personalSettingsPage.getters.enableMfaButton().should('exist');
|
personalSettingsPage.getters.enableMfaButton().should('exist');
|
||||||
mainSidebar.actions.signout();
|
mainSidebar.actions.signout();
|
||||||
});
|
});
|
||||||
|
@ -88,7 +88,7 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
|
||||||
mainSidebar.actions.signout();
|
mainSidebar.actions.signout();
|
||||||
const loginToken = generateOTPToken(user.mfaSecret);
|
const loginToken = generateOTPToken(user.mfaSecret);
|
||||||
mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken);
|
mfaLoginPage.actions.loginWithMfaToken(email, password, loginToken);
|
||||||
personalSettingsPage.actions.disableMfaWitRecoveryCode(user.mfaRecoveryCodes[0]);
|
personalSettingsPage.actions.disableMfa(user.mfaRecoveryCodes[0]);
|
||||||
personalSettingsPage.getters.enableMfaButton().should('exist');
|
personalSettingsPage.getters.enableMfaButton().should('exist');
|
||||||
mainSidebar.actions.signout();
|
mainSidebar.actions.signout();
|
||||||
});
|
});
|
||||||
|
|
|
@ -23,7 +23,6 @@ export class PersonalSettingsPage extends BasePage {
|
||||||
enableMfaButton: () => cy.getByTestId('enable-mfa-button'),
|
enableMfaButton: () => cy.getByTestId('enable-mfa-button'),
|
||||||
disableMfaButton: () => cy.getByTestId('disable-mfa-button'),
|
disableMfaButton: () => cy.getByTestId('disable-mfa-button'),
|
||||||
mfaCodeInput: () => cy.getByTestId('mfa-code-input'),
|
mfaCodeInput: () => cy.getByTestId('mfa-code-input'),
|
||||||
recoveryCodeInput: () => cy.getByTestId('recovery-code-input'),
|
|
||||||
mfaSaveButton: () => cy.getByTestId('mfa-save-button'),
|
mfaSaveButton: () => cy.getByTestId('mfa-save-button'),
|
||||||
themeSelector: () => cy.getByTestId('theme-select'),
|
themeSelector: () => cy.getByTestId('theme-select'),
|
||||||
selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'),
|
selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'),
|
||||||
|
@ -86,16 +85,10 @@ export class PersonalSettingsPage extends BasePage {
|
||||||
mfaSetupModal.getters.saveButton().click();
|
mfaSetupModal.getters.saveButton().click();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
disableMfaWithMfaToken: (token: string) => {
|
disableMfa: (code: string) => {
|
||||||
cy.visit(this.url);
|
cy.visit(this.url);
|
||||||
this.getters.disableMfaButton().click();
|
this.getters.disableMfaButton().click();
|
||||||
this.getters.mfaCodeInput().type(token);
|
this.getters.mfaCodeInput().type(code);
|
||||||
this.getters.mfaSaveButton().click();
|
|
||||||
},
|
|
||||||
disableMfaWitRecoveryCode: (recoveryCode: string) => {
|
|
||||||
cy.visit(this.url);
|
|
||||||
this.getters.disableMfaButton().click();
|
|
||||||
this.getters.recoveryCodeInput().type(recoveryCode);
|
|
||||||
this.getters.mfaSaveButton().click();
|
this.getters.mfaSaveButton().click();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -41,7 +41,7 @@ export class AuthController {
|
||||||
/** Log in a user */
|
/** Log in a user */
|
||||||
@Post('/login', { skipAuth: true, rateLimit: true })
|
@Post('/login', { skipAuth: true, rateLimit: true })
|
||||||
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
|
||||||
const { email, password, mfaToken, mfaRecoveryCode } = req.body;
|
const { email, password, mfaCode, mfaRecoveryCode } = req.body;
|
||||||
if (!email) throw new ApplicationError('Email is required to log in');
|
if (!email) throw new ApplicationError('Email is required to log in');
|
||||||
if (!password) throw new ApplicationError('Password is required to log in');
|
if (!password) throw new ApplicationError('Password is required to log in');
|
||||||
|
|
||||||
|
@ -75,16 +75,12 @@ export class AuthController {
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
if (!mfaToken && !mfaRecoveryCode) {
|
if (!mfaCode && !mfaRecoveryCode) {
|
||||||
throw new AuthError('MFA Error', 998);
|
throw new AuthError('MFA Error', 998);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMFATokenValid = await this.mfaService.validateMfa(
|
const isMFACodeValid = await this.mfaService.validateMfa(user.id, mfaCode, mfaRecoveryCode);
|
||||||
user.id,
|
if (!isMFACodeValid) {
|
||||||
mfaToken,
|
|
||||||
mfaRecoveryCode,
|
|
||||||
);
|
|
||||||
if (!isMFATokenValid) {
|
|
||||||
throw new AuthError('Invalid mfa token or recovery code');
|
throw new AuthError('Invalid mfa token or recovery code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,8 +68,8 @@ export class MeController {
|
||||||
throw new BadRequestError('Two-factor code is required to change email');
|
throw new BadRequestError('Two-factor code is required to change email');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||||
if (!isMfaTokenValid) {
|
if (!isMfaCodeValid) {
|
||||||
throw new InvalidMfaCodeError();
|
throw new InvalidMfaCodeError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,8 +142,8 @@ export class MeController {
|
||||||
throw new BadRequestError('Two-factor code is required to change password.');
|
throw new BadRequestError('Two-factor code is required to change password.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
const isMfaCodeValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
||||||
if (!isMfaTokenValid) {
|
if (!isMfaCodeValid) {
|
||||||
throw new InvalidMfaCodeError();
|
throw new InvalidMfaCodeError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ export class MFAController {
|
||||||
|
|
||||||
@Post('/enable', { rateLimit: true })
|
@Post('/enable', { rateLimit: true })
|
||||||
async activateMFA(req: MFA.Activate) {
|
async activateMFA(req: MFA.Activate) {
|
||||||
const { token = null } = req.body;
|
const { mfaCode = null } = req.body;
|
||||||
const { id, mfaEnabled } = req.user;
|
const { id, mfaEnabled } = req.user;
|
||||||
|
|
||||||
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||||
|
@ -67,7 +67,7 @@ export class MFAController {
|
||||||
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
||||||
await this.mfaService.getSecretAndRecoveryCodes(id);
|
await this.mfaService.getSecretAndRecoveryCodes(id);
|
||||||
|
|
||||||
if (!token) throw new BadRequestError('Token is required to enable MFA feature');
|
if (!mfaCode) throw new BadRequestError('MFA code is required to enable MFA feature');
|
||||||
|
|
||||||
if (mfaEnabled) throw new BadRequestError('MFA already enabled');
|
if (mfaEnabled) throw new BadRequestError('MFA already enabled');
|
||||||
|
|
||||||
|
@ -75,10 +75,10 @@ export class MFAController {
|
||||||
throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes');
|
throw new BadRequestError('Cannot enable MFA without generating secret and recovery codes');
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = this.mfaService.totp.verifySecret({ secret, token, window: 10 });
|
const verified = this.mfaService.totp.verifySecret({ secret, mfaCode, window: 10 });
|
||||||
|
|
||||||
if (!verified)
|
if (!verified)
|
||||||
throw new BadRequestError('MFA token expired. Close the modal and enable MFA again', 997);
|
throw new BadRequestError('MFA code expired. Close the modal and enable MFA again', 997);
|
||||||
|
|
||||||
await this.mfaService.enableMfa(id);
|
await this.mfaService.enableMfa(id);
|
||||||
}
|
}
|
||||||
|
@ -86,27 +86,28 @@ export class MFAController {
|
||||||
@Post('/disable', { rateLimit: true })
|
@Post('/disable', { rateLimit: true })
|
||||||
async disableMFA(req: MFA.Disable) {
|
async disableMFA(req: MFA.Disable) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
const { token = null, recoveryCode = null } = req.body;
|
|
||||||
|
|
||||||
if (typeof token !== 'string' || typeof recoveryCode !== 'string') {
|
const { mfaCode, mfaRecoveryCode } = req.body;
|
||||||
throw new BadRequestError('Token or recovery code should be strings');
|
|
||||||
|
if (mfaCode && typeof mfaCode === 'string') {
|
||||||
|
await this.mfaService.disableMfaWithMfaCode(userId, mfaCode);
|
||||||
|
} else if (mfaRecoveryCode && typeof mfaRecoveryCode === 'string') {
|
||||||
|
await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.mfaService.disableMfa(userId, token, recoveryCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/verify', { rateLimit: true })
|
@Post('/verify', { rateLimit: true })
|
||||||
async verifyMFA(req: MFA.Verify) {
|
async verifyMFA(req: MFA.Verify) {
|
||||||
const { id } = req.user;
|
const { id } = req.user;
|
||||||
const { token } = req.body;
|
const { mfaCode } = req.body;
|
||||||
|
|
||||||
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id);
|
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(id);
|
||||||
|
|
||||||
if (!token) throw new BadRequestError('Token is required to enable MFA feature');
|
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');
|
if (!secret) throw new BadRequestError('No MFA secret se for this user');
|
||||||
|
|
||||||
const verified = this.mfaService.totp.verifySecret({ secret, token });
|
const verified = this.mfaService.totp.verifySecret({ secret, mfaCode });
|
||||||
|
|
||||||
if (!verified) throw new BadRequestError('MFA secret could not be verified');
|
if (!verified) throw new BadRequestError('MFA secret could not be verified');
|
||||||
}
|
}
|
||||||
|
|
|
@ -171,7 +171,7 @@ export class PasswordResetController {
|
||||||
*/
|
*/
|
||||||
@Post('/change-password', { skipAuth: true })
|
@Post('/change-password', { skipAuth: true })
|
||||||
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
||||||
const { token, password, mfaToken } = req.body;
|
const { token, password, mfaCode } = req.body;
|
||||||
|
|
||||||
if (!token || !password) {
|
if (!token || !password) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
|
@ -189,11 +189,11 @@ export class PasswordResetController {
|
||||||
if (!user) throw new NotFoundError('');
|
if (!user) throw new NotFoundError('');
|
||||||
|
|
||||||
if (user.mfaEnabled) {
|
if (user.mfaEnabled) {
|
||||||
if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.');
|
if (!mfaCode) throw new BadRequestError('If MFA enabled, mfaCode is required.');
|
||||||
|
|
||||||
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id);
|
const { decryptedSecret: secret } = await this.mfaService.getSecretAndRecoveryCodes(user.id);
|
||||||
|
|
||||||
const validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken });
|
const validToken = this.mfaService.totp.verifySecret({ secret, mfaCode });
|
||||||
|
|
||||||
if (!validToken) throw new BadRequestError('Invalid MFA token.');
|
if (!validToken) throw new BadRequestError('Invalid MFA token.');
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,6 @@ import { ForbiddenError } from './forbidden.error';
|
||||||
|
|
||||||
export class InvalidMfaCodeError extends ForbiddenError {
|
export class InvalidMfaCodeError extends ForbiddenError {
|
||||||
constructor(hint?: string) {
|
constructor(hint?: string) {
|
||||||
super('Invalid two-factor code or recovery code.', hint);
|
super('Invalid two-factor code.', hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ForbiddenError } from './forbidden.error';
|
||||||
|
|
||||||
|
export class InvalidMfaRecoveryCodeError extends ForbiddenError {
|
||||||
|
constructor(hint?: string) {
|
||||||
|
super('Invalid MFA recovery code', hint);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
||||||
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
||||||
|
import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error';
|
||||||
|
|
||||||
import { TOTPService } from './totp.service';
|
import { TOTPService } from './totp.service';
|
||||||
|
|
||||||
|
@ -56,13 +57,13 @@ export class MfaService {
|
||||||
|
|
||||||
async validateMfa(
|
async validateMfa(
|
||||||
userId: string,
|
userId: string,
|
||||||
mfaToken: string | undefined,
|
mfaCode: string | undefined,
|
||||||
mfaRecoveryCode: string | undefined,
|
mfaRecoveryCode: string | undefined,
|
||||||
) {
|
) {
|
||||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
||||||
if (mfaToken) {
|
if (mfaCode) {
|
||||||
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
||||||
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
|
return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mfaRecoveryCode) {
|
if (mfaRecoveryCode) {
|
||||||
|
@ -85,13 +86,27 @@ export class MfaService {
|
||||||
return await this.authUserRepository.save(user);
|
return await this.authUserRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableMfa(userId: string, mfaToken: string, recoveryCode: string) {
|
async disableMfaWithMfaCode(userId: string, mfaCode: string) {
|
||||||
const isValidToken = await this.validateMfa(userId, mfaToken, recoveryCode);
|
const isValidToken = await this.validateMfa(userId, mfaCode, '');
|
||||||
|
|
||||||
if (!isValidToken) {
|
if (!isValidToken) {
|
||||||
throw new InvalidMfaCodeError();
|
throw new InvalidMfaCodeError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.disableMfaForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableMfaWithRecoveryCode(userId: string, recoveryCode: string) {
|
||||||
|
const isValidToken = await this.validateMfa(userId, '', recoveryCode);
|
||||||
|
|
||||||
|
if (!isValidToken) {
|
||||||
|
throw new InvalidMfaRecoveryCodeError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.disableMfaForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async disableMfaForUser(userId: string) {
|
||||||
await this.authUserRepository.update(userId, {
|
await this.authUserRepository.update(userId, {
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
mfaSecret: null,
|
mfaSecret: null,
|
||||||
|
|
|
@ -23,10 +23,14 @@ export class TOTPService {
|
||||||
}).toString();
|
}).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
verifySecret({ secret, token, window = 2 }: { secret: string; token: string; window?: number }) {
|
verifySecret({
|
||||||
|
secret,
|
||||||
|
mfaCode,
|
||||||
|
window = 2,
|
||||||
|
}: { secret: string; mfaCode: string; window?: number }) {
|
||||||
return new OTPAuth.TOTP({
|
return new OTPAuth.TOTP({
|
||||||
secret: OTPAuth.Secret.fromBase32(secret),
|
secret: OTPAuth.Secret.fromBase32(secret),
|
||||||
}).validate({ token, window }) === null
|
}).validate({ token: mfaCode, window }) === null
|
||||||
? false
|
? false
|
||||||
: true;
|
: true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -228,7 +228,7 @@ export declare namespace PasswordResetRequest {
|
||||||
export type NewPassword = AuthlessRequest<
|
export type NewPassword = AuthlessRequest<
|
||||||
{},
|
{},
|
||||||
{},
|
{},
|
||||||
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaToken?: string }
|
Pick<PublicUser, 'password'> & { token?: string; userId?: string; mfaCode?: string }
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -306,7 +306,7 @@ export type LoginRequest = AuthlessRequest<
|
||||||
{
|
{
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
mfaToken?: string;
|
mfaCode?: string;
|
||||||
mfaRecoveryCode?: string;
|
mfaRecoveryCode?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
@ -316,9 +316,9 @@ export type LoginRequest = AuthlessRequest<
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
||||||
export declare namespace MFA {
|
export declare namespace MFA {
|
||||||
type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||||
type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||||
type Disable = AuthenticatedRequest<{}, {}, { token?: string; recoveryCode?: string }, {}>;
|
type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>;
|
||||||
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
|
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
|
||||||
type ValidateRecoveryCode = AuthenticatedRequest<
|
type ValidateRecoveryCode = AuthenticatedRequest<
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -89,7 +89,7 @@ describe('POST /login', () => {
|
||||||
const response = await testServer.authlessAgent.post('/login').send({
|
const response = await testServer.authlessAgent.post('/login').send({
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
password: ownerPassword,
|
password: ownerPassword,
|
||||||
mfaToken: mfaService.totp.generateTOTP(secret),
|
mfaCode: mfaService.totp.generateTOTP(secret),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
|
@ -55,8 +55,8 @@ describe('Enable MFA setup', () => {
|
||||||
secondCall.body.data.recoveryCodes.join(''),
|
secondCall.body.data.recoveryCodes.join(''),
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
const mfaCode = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
||||||
await testServer.authAgentFor(owner).post('/mfa/disable').send({ token }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/disable').send({ mfaCode }).expect(200);
|
||||||
|
|
||||||
const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
|
|
||||||
|
@ -85,21 +85,21 @@ describe('Enable MFA setup', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /verify should fail due to invalid MFA token', async () => {
|
test('POST /verify should fail due to invalid MFA token', async () => {
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '123' }).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /verify should fail due to missing token parameter', async () => {
|
test('POST /verify should fail due to missing token parameter', async () => {
|
||||||
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '' }).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /verify should validate MFA token', async () => {
|
test('POST /verify should validate MFA token', async () => {
|
||||||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
|
|
||||||
const { secret } = response.body.data;
|
const { secret } = response.body.data;
|
||||||
const token = new TOTPService().generateTOTP(secret);
|
const mfaCode = new TOTPService().generateTOTP(secret);
|
||||||
|
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,12 +109,12 @@ describe('Enable MFA setup', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /verify should fail due to missing token parameter', async () => {
|
test('POST /verify should fail due to missing token parameter', async () => {
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode: '' }).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /enable should fail due to invalid MFA token', async () => {
|
test('POST /enable should fail due to invalid MFA token', async () => {
|
||||||
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token: '123' }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode: '123' }).expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /enable should fail due to empty secret and recovery codes', async () => {
|
test('POST /enable should fail due to empty secret and recovery codes', async () => {
|
||||||
|
@ -125,10 +125,10 @@ describe('Enable MFA setup', () => {
|
||||||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
|
|
||||||
const { secret } = response.body.data;
|
const { secret } = response.body.data;
|
||||||
const token = new TOTPService().generateTOTP(secret);
|
const mfaCode = new TOTPService().generateTOTP(secret);
|
||||||
|
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
|
||||||
|
|
||||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
||||||
where: {},
|
where: {},
|
||||||
|
@ -145,13 +145,13 @@ describe('Enable MFA setup', () => {
|
||||||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||||
|
|
||||||
const { secret } = response.body.data;
|
const { secret } = response.body.data;
|
||||||
const token = new TOTPService().generateTOTP(secret);
|
const mfaCode = new TOTPService().generateTOTP(secret);
|
||||||
|
|
||||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token }).expect(200);
|
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||||
|
|
||||||
externalHooks.run.mockRejectedValue(new BadRequestError('Error message'));
|
externalHooks.run.mockRejectedValue(new BadRequestError('Error message'));
|
||||||
|
|
||||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ token }).expect(400);
|
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(400);
|
||||||
|
|
||||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
||||||
where: {},
|
where: {},
|
||||||
|
@ -165,13 +165,13 @@ describe('Enable MFA setup', () => {
|
||||||
describe('Disable MFA setup', () => {
|
describe('Disable MFA setup', () => {
|
||||||
test('POST /disable should disable login with MFA', async () => {
|
test('POST /disable should disable login with MFA', async () => {
|
||||||
const { user, rawSecret } = await createUserWithMfaEnabled();
|
const { user, rawSecret } = await createUserWithMfaEnabled();
|
||||||
const token = new TOTPService().generateTOTP(rawSecret);
|
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||||
|
|
||||||
await testServer
|
await testServer
|
||||||
.authAgentFor(user)
|
.authAgentFor(user)
|
||||||
.post('/mfa/disable')
|
.post('/mfa/disable')
|
||||||
.send({
|
.send({
|
||||||
token,
|
mfaCode,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
@ -184,15 +184,26 @@ describe('Disable MFA setup', () => {
|
||||||
expect(dbUser.mfaRecoveryCodes.length).toBe(0);
|
expect(dbUser.mfaRecoveryCodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /disable should fail if invalid token is given', async () => {
|
test('POST /disable should fail if invalid mfa token is given', async () => {
|
||||||
const { user } = await createUserWithMfaEnabled();
|
const { user } = await createUserWithMfaEnabled();
|
||||||
|
|
||||||
await testServer
|
await testServer
|
||||||
.authAgentFor(user)
|
.authAgentFor(user)
|
||||||
.post('/mfa/disable')
|
.post('/mfa/disable')
|
||||||
.send({
|
.send({
|
||||||
token: 'invalid token',
|
mfaCode: 'invalid token',
|
||||||
recoveryCode: '',
|
})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /disable should fail if invalid recovery code is given', async () => {
|
||||||
|
const { user } = await createUserWithMfaEnabled();
|
||||||
|
|
||||||
|
await testServer
|
||||||
|
.authAgentFor(user)
|
||||||
|
.post('/mfa/disable')
|
||||||
|
.send({
|
||||||
|
mfaRecoveryCode: 'invalid token',
|
||||||
})
|
})
|
||||||
.expect(403);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
@ -222,7 +233,7 @@ describe('Change password with MFA enabled', () => {
|
||||||
.send({
|
.send({
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
mfaToken: randomInt(10),
|
mfaCode: randomInt(10),
|
||||||
})
|
})
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
@ -236,14 +247,14 @@ describe('Change password with MFA enabled', () => {
|
||||||
|
|
||||||
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
|
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
|
||||||
|
|
||||||
const mfaToken = new TOTPService().generateTOTP(rawSecret);
|
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||||
|
|
||||||
await testServer.authlessAgent
|
await testServer.authlessAgent
|
||||||
.post('/change-password')
|
.post('/change-password')
|
||||||
.send({
|
.send({
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
token: resetPasswordToken,
|
token: resetPasswordToken,
|
||||||
mfaToken,
|
mfaCode,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
@ -253,7 +264,7 @@ describe('Change password with MFA enabled', () => {
|
||||||
.send({
|
.send({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
mfaToken: new TOTPService().generateTOTP(rawSecret),
|
mfaCode: new TOTPService().generateTOTP(rawSecret),
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
@ -316,7 +327,7 @@ describe('Login', () => {
|
||||||
|
|
||||||
await testServer.authlessAgent
|
await testServer.authlessAgent
|
||||||
.post('/login')
|
.post('/login')
|
||||||
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' })
|
.send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' })
|
||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -334,11 +345,11 @@ describe('Login', () => {
|
||||||
test('POST /login should succeed with MFA token', async () => {
|
test('POST /login should succeed with MFA token', async () => {
|
||||||
const { user, rawSecret, rawPassword } = await createUserWithMfaEnabled();
|
const { user, rawSecret, rawPassword } = await createUserWithMfaEnabled();
|
||||||
|
|
||||||
const token = new TOTPService().generateTOTP(rawSecret);
|
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||||
|
|
||||||
const response = await testServer.authlessAgent
|
const response = await testServer.authlessAgent
|
||||||
.post('/login')
|
.post('/login')
|
||||||
.send({ email: user.email, password: rawPassword, mfaToken: token })
|
.send({ email: user.email, password: rawPassword, mfaCode })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const data = response.body.data;
|
const data = response.body.data;
|
||||||
|
|
|
@ -11,20 +11,23 @@ export async function getMfaQR(
|
||||||
return await makeRestApiRequest(context, 'GET', '/mfa/qr');
|
return await makeRestApiRequest(context, 'GET', '/mfa/qr');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableMfa(context: IRestApiContext, data: { token: string }): Promise<void> {
|
export async function enableMfa(
|
||||||
|
context: IRestApiContext,
|
||||||
|
data: { mfaCode: string },
|
||||||
|
): Promise<void> {
|
||||||
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
|
return await makeRestApiRequest(context, 'POST', '/mfa/enable', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyMfaToken(
|
export async function verifyMfaCode(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
data: { token: string },
|
data: { mfaCode: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisableMfaParams = {
|
export type DisableMfaParams = {
|
||||||
token: string;
|
mfaCode?: string;
|
||||||
recoveryCode: string;
|
recoveryCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||||
|
|
|
@ -21,7 +21,7 @@ export async function loginCurrentUser(
|
||||||
|
|
||||||
export async function login(
|
export async function login(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string },
|
params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string },
|
||||||
): Promise<CurrentUserResponse> {
|
): Promise<CurrentUserResponse> {
|
||||||
return await makeRestApiRequest(context, 'POST', '/login', params);
|
return await makeRestApiRequest(context, 'POST', '/login', params);
|
||||||
}
|
}
|
||||||
|
@ -84,7 +84,7 @@ export async function validatePasswordToken(
|
||||||
|
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
params: { token: string; password: string; mfaToken?: string },
|
params: { token: string; password: string; mfaCode?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ const onInput = (value: string) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
userStore
|
userStore
|
||||||
.verifyMfaToken({ token: value })
|
.verifyMfaCode({ mfaCode: value })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showRecoveryCodes.value = true;
|
showRecoveryCodes.value = true;
|
||||||
authenticatorCode.value = value;
|
authenticatorCode.value = value;
|
||||||
|
@ -98,7 +98,7 @@ const onDownloadClick = () => {
|
||||||
|
|
||||||
const onSetupClick = async () => {
|
const onSetupClick = async () => {
|
||||||
try {
|
try {
|
||||||
await userStore.enableMfa({ token: authenticatorCode.value });
|
await userStore.enableMfa({ mfaCode: authenticatorCode.value });
|
||||||
closeDialog();
|
closeDialog();
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
|
@ -1,89 +1,59 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import Modal from '../Modal.vue';
|
import Modal from '../Modal.vue';
|
||||||
import { PROMPT_MFA_CODE_MODAL_KEY } from '@/constants';
|
import { PROMPT_MFA_CODE_MODAL_KEY } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { promptMfaCodeBus } from '@/event-bus';
|
import { promptMfaCodeBus } from '@/event-bus';
|
||||||
|
import type { IFormInputs } from '@/Interface';
|
||||||
|
import { createFormEventBus } from 'n8n-design-system';
|
||||||
import { validate as uuidValidate } from 'uuid';
|
import { validate as uuidValidate } from 'uuid';
|
||||||
|
|
||||||
const MFA_CODE_INPUT_NAME = 'mfaCodeInput';
|
|
||||||
|
|
||||||
const MFA_CODE_VALIDATORS = {
|
|
||||||
mfaCode: {
|
|
||||||
validate: (value: string) => {
|
|
||||||
if (value === '') {
|
|
||||||
return { message: ' ' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length < 6) {
|
|
||||||
return {
|
|
||||||
message: 'Code must be 6 digits',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^\d+$/.test(value)) {
|
|
||||||
return {
|
|
||||||
message: 'Only digits are allow',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const RECOVERY_CODE_VALIDATORS = {
|
|
||||||
recoveryCode: {
|
|
||||||
validate: (value: string) => {
|
|
||||||
if (value === '') {
|
|
||||||
return { message: ' ' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uuidValidate(value)) {
|
|
||||||
return {
|
|
||||||
message: 'Must be an UUID',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const mfaCode = ref('');
|
const formBus = createFormEventBus();
|
||||||
const recoveryCode = ref('');
|
const readyToSubmit = ref(false);
|
||||||
|
|
||||||
const isMfaCodeValid = ref(false);
|
const formFields: IFormInputs = [
|
||||||
const isRecoveryCodeValid = ref(false);
|
{
|
||||||
|
name: 'code',
|
||||||
|
initialValue: '',
|
||||||
|
properties: {
|
||||||
|
label: i18n.baseText('mfa.code.recovery.input.label'),
|
||||||
|
placeholder: i18n.baseText('mfa.code.recovery.input.placeholder'),
|
||||||
|
focusInitially: true,
|
||||||
|
capitalize: true,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const anyFieldValid = computed(() => isMfaCodeValid.value || isRecoveryCodeValid.value);
|
function onSubmit(values: { code: string }) {
|
||||||
|
if (uuidValidate(values.code)) {
|
||||||
|
promptMfaCodeBus.emit('close', {
|
||||||
|
mfaRecoveryCode: values.code,
|
||||||
|
});
|
||||||
|
|
||||||
function onClickSave() {
|
|
||||||
if (!anyFieldValid.value) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
promptMfaCodeBus.emit('close', {
|
promptMfaCodeBus.emit('close', {
|
||||||
mfaCode: mfaCode.value,
|
mfaCode: values.code,
|
||||||
recoveryCode: recoveryCode.value,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onValidate(name: string, value: boolean) {
|
function onClickSave() {
|
||||||
if (name === MFA_CODE_INPUT_NAME) {
|
formBus.emit('submit');
|
||||||
isMfaCodeValid.value = value;
|
}
|
||||||
} else {
|
|
||||||
isRecoveryCodeValid.value = value;
|
function onFormReady(isReady: boolean) {
|
||||||
}
|
readyToSubmit.value = isReady;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
width="500px"
|
width="500px"
|
||||||
height="400px"
|
height="300px"
|
||||||
max-height="640px"
|
max-height="640px"
|
||||||
:title="i18n.baseText('mfa.prompt.code.modal.title')"
|
:title="i18n.baseText('mfa.prompt.code.modal.title')"
|
||||||
:event-bus="promptMfaCodeBus"
|
:event-bus="promptMfaCodeBus"
|
||||||
|
@ -92,27 +62,12 @@ function onValidate(name: string, value: boolean) {
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div :class="[$style.formContainer]">
|
<div :class="[$style.formContainer]">
|
||||||
<n8n-form-input
|
<n8n-form-inputs
|
||||||
v-model="mfaCode"
|
|
||||||
name="mfaCode"
|
|
||||||
data-test-id="mfa-code-input"
|
data-test-id="mfa-code-input"
|
||||||
:label="i18n.baseText('mfa.code.input.label')"
|
:inputs="formFields"
|
||||||
:placeholder="i18n.baseText('mfa.code.input.placeholder')"
|
:event-bus="formBus"
|
||||||
:validators="MFA_CODE_VALIDATORS"
|
@submit="onSubmit"
|
||||||
:validation-rules="[{ name: 'MAX_LENGTH', config: { maximum: 6 } }, { name: 'mfaCode' }]"
|
@ready="onFormReady"
|
||||||
@validate="(value: boolean) => onValidate(MFA_CODE_INPUT_NAME, value)"
|
|
||||||
/>
|
|
||||||
<span> {{ i18n.baseText('mfa.prompt.code.modal.divider') }} </span>
|
|
||||||
|
|
||||||
<n8n-form-input
|
|
||||||
v-model="recoveryCode"
|
|
||||||
name="recoveryCode"
|
|
||||||
data-test-id="recovery-code-input"
|
|
||||||
:label="i18n.baseText('mfa.recoveryCode.input.label')"
|
|
||||||
:placeholder="i18n.baseText('mfa.recovery.input.placeholder')"
|
|
||||||
:validators="RECOVERY_CODE_VALIDATORS"
|
|
||||||
:validation-rules="[{ name: 'recoveryCode' }]"
|
|
||||||
@validate="(value: boolean) => onValidate('recoveryCodeInput', value)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -120,9 +75,9 @@ function onValidate(name: string, value: boolean) {
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
float="right"
|
float="right"
|
||||||
|
:disabled="!readyToSubmit"
|
||||||
:label="i18n.baseText('settings.personal.save')"
|
:label="i18n.baseText('settings.personal.save')"
|
||||||
size="large"
|
size="large"
|
||||||
:disabled="!anyFieldValid"
|
|
||||||
data-test-id="mfa-save-button"
|
data-test-id="mfa-save-button"
|
||||||
@click="onClickSave"
|
@click="onClickSave"
|
||||||
/>
|
/>
|
||||||
|
@ -133,12 +88,6 @@ function onValidate(name: string, value: boolean) {
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.formContainer {
|
.formContainer {
|
||||||
display: flex;
|
padding-bottom: var(--spacing-xl);
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-s);
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { createEventBus } from 'n8n-design-system/utils';
|
||||||
export const mfaEventBus = createEventBus();
|
export const mfaEventBus = createEventBus();
|
||||||
|
|
||||||
export interface MfaModalClosedEventPayload {
|
export interface MfaModalClosedEventPayload {
|
||||||
mfaCode: string;
|
mfaCode?: string;
|
||||||
recoveryCode: string;
|
mfaRecoveryCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaModalEvents {
|
export interface MfaModalEvents {
|
||||||
|
|
|
@ -2582,10 +2582,11 @@
|
||||||
"mfa.code.button.continue": "Continue",
|
"mfa.code.button.continue": "Continue",
|
||||||
"mfa.recovery.button.verify": "Verify",
|
"mfa.recovery.button.verify": "Verify",
|
||||||
"mfa.button.back": "Back",
|
"mfa.button.back": "Back",
|
||||||
"mfa.recoveryCode.input.label": "Recovery code",
|
|
||||||
"mfa.code.input.label": "Two-factor code",
|
"mfa.code.input.label": "Two-factor code",
|
||||||
"mfa.code.input.placeholder": "e.g. 123456",
|
"mfa.code.input.placeholder": "e.g. 123456",
|
||||||
"mfa.recovery.input.label": "Recovery Code",
|
"mfa.code.recovery.input.label": "Two-factor code or recovery code",
|
||||||
|
"mfa.code.recovery.input.placeholder": "e.g. 123456 or c79f9c02-7b2e-44...",
|
||||||
|
"mfa.recovery.input.label": "Recovery code",
|
||||||
"mfa.recovery.input.placeholder": "e.g c79f9c02-7b2e-44...",
|
"mfa.recovery.input.placeholder": "e.g c79f9c02-7b2e-44...",
|
||||||
"mfa.code.invalid": "This code is invalid, try again or",
|
"mfa.code.invalid": "This code is invalid, try again or",
|
||||||
"mfa.recovery.invalid": "This code is invalid or was already used, try again",
|
"mfa.recovery.invalid": "This code is invalid or was already used, try again",
|
||||||
|
@ -2610,7 +2611,6 @@
|
||||||
"mfa.setup.step2.toast.setupFinished.error.message": "Error enabling two-factor authentication",
|
"mfa.setup.step2.toast.setupFinished.error.message": "Error enabling two-factor authentication",
|
||||||
"mfa.setup.step2.toast.tokenExpired.error.message": "MFA token expired. Close the modal and enable MFA again",
|
"mfa.setup.step2.toast.tokenExpired.error.message": "MFA token expired. Close the modal and enable MFA again",
|
||||||
"mfa.prompt.code.modal.title": "Two-factor code or recovery code required",
|
"mfa.prompt.code.modal.title": "Two-factor code or recovery code required",
|
||||||
"mfa.prompt.code.modal.divider": "Or",
|
|
||||||
"settings.personal.mfa.section.title": "Two-factor authentication (2FA)",
|
"settings.personal.mfa.section.title": "Two-factor authentication (2FA)",
|
||||||
"settings.personal.personalisation": "Personalisation",
|
"settings.personal.personalisation": "Personalisation",
|
||||||
"settings.personal.theme": "Theme",
|
"settings.personal.theme": "Theme",
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { useNpsSurveyStore } from './npsSurvey.store';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { DisableMfaParams } from '@/api/mfa';
|
||||||
|
|
||||||
const _isPendingUser = (user: IUserResponse | null) => !!user?.isPending;
|
const _isPendingUser = (user: IUserResponse | null) => !!user?.isPending;
|
||||||
const _isInstanceOwner = (user: IUserResponse | null) => user?.role === ROLE.Owner;
|
const _isInstanceOwner = (user: IUserResponse | null) => user?.role === ROLE.Owner;
|
||||||
|
@ -172,7 +173,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
const loginWithCreds = async (params: {
|
const loginWithCreds = async (params: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
mfaToken?: string;
|
mfaCode?: string;
|
||||||
mfaRecoveryCode?: string;
|
mfaRecoveryCode?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const user = await usersApi.login(rootStore.restApiContext, params);
|
const user = await usersApi.login(rootStore.restApiContext, params);
|
||||||
|
@ -232,7 +233,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
await usersApi.validatePasswordToken(rootStore.restApiContext, params);
|
await usersApi.validatePasswordToken(rootStore.restApiContext, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changePassword = async (params: { token: string; password: string; mfaToken?: string }) => {
|
const changePassword = async (params: { token: string; password: string; mfaCode?: string }) => {
|
||||||
await usersApi.changePassword(rootStore.restApiContext, params);
|
await usersApi.changePassword(rootStore.restApiContext, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -316,26 +317,23 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
return await mfaApi.getMfaQR(rootStore.restApiContext);
|
return await mfaApi.getMfaQR(rootStore.restApiContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyMfaToken = async (data: { token: string }) => {
|
const verifyMfaCode = async (data: { mfaCode: string }) => {
|
||||||
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
|
return await mfaApi.verifyMfaCode(rootStore.restApiContext, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const canEnableMFA = async () => {
|
const canEnableMFA = async () => {
|
||||||
return await mfaApi.canEnableMFA(rootStore.restApiContext);
|
return await mfaApi.canEnableMFA(rootStore.restApiContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableMfa = async (data: { token: string }) => {
|
const enableMfa = async (data: { mfaCode: string }) => {
|
||||||
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
currentUser.value.mfaEnabled = true;
|
currentUser.value.mfaEnabled = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableMfa = async (mfaCode: string, recoveryCode: string) => {
|
const disableMfa = async (data: DisableMfaParams) => {
|
||||||
await mfaApi.disableMfa(rootStore.restApiContext, {
|
await mfaApi.disableMfa(rootStore.restApiContext, data);
|
||||||
token: mfaCode,
|
|
||||||
recoveryCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
currentUser.value.mfaEnabled = false;
|
currentUser.value.mfaEnabled = false;
|
||||||
|
@ -405,7 +403,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
submitPersonalizationSurvey,
|
submitPersonalizationSurvey,
|
||||||
showPersonalizationSurvey,
|
showPersonalizationSurvey,
|
||||||
fetchMfaQR,
|
fetchMfaQR,
|
||||||
verifyMfaToken,
|
verifyMfaCode,
|
||||||
enableMfa,
|
enableMfa,
|
||||||
disableMfa,
|
disableMfa,
|
||||||
canEnableMFA,
|
canEnableMFA,
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { useToast } from '@/composables/useToast';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
import type { IFormBoxConfig } from '@/Interface';
|
import type { IFormBoxConfig } from '@/Interface';
|
||||||
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ const onSubmit = async (values: { [key: string]: string }) => {
|
||||||
const changePasswordParameters = {
|
const changePasswordParameters = {
|
||||||
token,
|
token,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
...(values.mfaToken && { mfaToken: values.mfaToken }),
|
...(values.mfaCode && { mfaCode: values.mfaCode }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await usersStore.changePassword(changePasswordParameters);
|
await usersStore.changePassword(changePasswordParameters);
|
||||||
|
@ -129,13 +129,12 @@ onMounted(async () => {
|
||||||
|
|
||||||
if (mfaEnabled) {
|
if (mfaEnabled) {
|
||||||
form.inputs.push({
|
form.inputs.push({
|
||||||
name: 'mfaToken',
|
name: 'mfaCode',
|
||||||
initialValue: '',
|
initialValue: '',
|
||||||
properties: {
|
properties: {
|
||||||
required: true,
|
required: true,
|
||||||
label: locale.baseText('mfa.code.input.label'),
|
label: locale.baseText('mfa.code.input.label'),
|
||||||
placeholder: locale.baseText('mfa.code.input.placeholder'),
|
placeholder: locale.baseText('mfa.code.input.placeholder'),
|
||||||
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
validateOnBlur: true,
|
validateOnBlur: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,7 +29,7 @@ const hasAnyChanges = ref(false);
|
||||||
const formBus = ref(mfaEventBus);
|
const formBus = ref(mfaEventBus);
|
||||||
const formInputs = ref<null | IFormInputs>(null);
|
const formInputs = ref<null | IFormInputs>(null);
|
||||||
const showRecoveryCodeForm = ref(false);
|
const showRecoveryCodeForm = ref(false);
|
||||||
const verifyingMfaToken = ref(false);
|
const verifyingMfaCode = ref(false);
|
||||||
const formError = ref('');
|
const formError = ref('');
|
||||||
const { reportError } = toRefs(props);
|
const { reportError } = toRefs(props);
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ const i18 = useI18n();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
onFormChanged: [formField: string];
|
onFormChanged: [formField: string];
|
||||||
onBackClick: [formField: string];
|
onBackClick: [formField: string];
|
||||||
submit: [{ token: string; recoveryCode: string }];
|
submit: [{ mfaCode: string; mfaRecoveryCode: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
@ -94,11 +94,11 @@ const onBackClick = () => {
|
||||||
|
|
||||||
showRecoveryCodeForm.value = false;
|
showRecoveryCodeForm.value = false;
|
||||||
hasAnyChanges.value = true;
|
hasAnyChanges.value = true;
|
||||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||||
emit('onBackClick', MFA_FORM.MFA_RECOVERY_CODE);
|
emit('onBackClick', MFA_FORM.MFA_RECOVERY_CODE);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (form: { token: string; recoveryCode: string }) => {
|
const onSubmit = async (form: { mfaCode: string; mfaRecoveryCode: string }) => {
|
||||||
formError.value = !showRecoveryCodeForm.value
|
formError.value = !showRecoveryCodeForm.value
|
||||||
? i18.baseText('mfa.code.invalid')
|
? i18.baseText('mfa.code.invalid')
|
||||||
: i18.baseText('mfa.recovery.invalid');
|
: i18.baseText('mfa.recovery.invalid');
|
||||||
|
@ -106,8 +106,8 @@ const onSubmit = async (form: { token: string; recoveryCode: string }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInput = ({ target: { value, name } }: { target: { value: string; name: string } }) => {
|
const onInput = ({ target: { value, name } }: { target: { value: string; name: string } }) => {
|
||||||
const isSubmittingMfaToken = name === 'token';
|
const isSubmittingMfaCode = name === 'mfaToken';
|
||||||
const inputValidLength = isSubmittingMfaToken
|
const inputValidLength = isSubmittingMfaCode
|
||||||
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
||||||
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
||||||
|
|
||||||
|
@ -116,30 +116,30 @@ const onInput = ({ target: { value, name } }: { target: { value: string; name: s
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyingMfaToken.value = true;
|
verifyingMfaCode.value = true;
|
||||||
hasAnyChanges.value = true;
|
hasAnyChanges.value = true;
|
||||||
|
|
||||||
const dataToSubmit = isSubmittingMfaToken
|
const dataToSubmit = isSubmittingMfaCode
|
||||||
? { token: value, recoveryCode: '' }
|
? { mfaCode: value, mfaRecoveryCode: '' }
|
||||||
: { token: '', recoveryCode: value };
|
: { mfaCode: '', mfaRecoveryCode: value };
|
||||||
|
|
||||||
onSubmit(dataToSubmit)
|
onSubmit(dataToSubmit)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => (verifyingMfaToken.value = false));
|
.finally(() => (verifyingMfaCode.value = false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const mfaRecoveryCodeFieldWithDefaults = () => {
|
const mfaRecoveryCodeFieldWithDefaults = () => {
|
||||||
return formField(
|
return formField(
|
||||||
'recoveryCode',
|
'mfaRecoveryCode',
|
||||||
i18.baseText('mfa.recovery.input.label'),
|
i18.baseText('mfa.recovery.input.label'),
|
||||||
i18.baseText('mfa.recovery.input.placeholder'),
|
i18.baseText('mfa.recovery.input.placeholder'),
|
||||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mfaTokenFieldWithDefaults = () => {
|
const mfaCodeFieldWithDefaults = () => {
|
||||||
return formField(
|
return formField(
|
||||||
'token',
|
'mfaToken',
|
||||||
i18.baseText('mfa.code.input.label'),
|
i18.baseText('mfa.code.input.label'),
|
||||||
i18.baseText('mfa.code.input.placeholder'),
|
i18.baseText('mfa.code.input.placeholder'),
|
||||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||||
|
@ -157,7 +157,7 @@ const onSaveClick = () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||||
});
|
});
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
@ -211,7 +211,7 @@ onMounted(() => {
|
||||||
<div>
|
<div>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
float="right"
|
float="right"
|
||||||
:loading="verifyingMfaToken"
|
:loading="verifyingMfaCode"
|
||||||
:label="
|
:label="
|
||||||
showRecoveryCodeForm
|
showRecoveryCodeForm
|
||||||
? i18.baseText('mfa.recovery.button.verify')
|
? i18.baseText('mfa.recovery.button.verify')
|
||||||
|
|
|
@ -226,7 +226,7 @@ async function disableMfa(payload: MfaModalEvents['closed']) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await usersStore.disableMfa(payload.mfaCode, payload.recoveryCode);
|
await usersStore.disableMfa(payload);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
||||||
|
|
|
@ -87,7 +87,7 @@ describe('SigninView', () => {
|
||||||
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
|
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
|
||||||
email: 'test@n8n.io',
|
email: 'test@n8n.io',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
mfaToken: undefined,
|
mfaCode: undefined,
|
||||||
mfaRecoveryCode: undefined,
|
mfaRecoveryCode: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -78,12 +78,12 @@ const formConfig: IFormBoxConfig = reactive({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onMFASubmitted = async (form: { token?: string; recoveryCode?: string }) => {
|
const onMFASubmitted = async (form: { mfaCode?: string; mfaRecoveryCode?: string }) => {
|
||||||
await login({
|
await login({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
token: form.token,
|
mfaCode: form.mfaCode,
|
||||||
recoveryCode: form.recoveryCode,
|
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -114,16 +114,16 @@ const getRedirectQueryParameter = () => {
|
||||||
const login = async (form: {
|
const login = async (form: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
token?: string;
|
mfaCode?: string;
|
||||||
recoveryCode?: string;
|
mfaRecoveryCode?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await usersStore.loginWithCreds({
|
await usersStore.loginWithCreds({
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
mfaToken: form.token,
|
mfaCode: form.mfaCode,
|
||||||
mfaRecoveryCode: form.recoveryCode,
|
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||||
});
|
});
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
if (settingsStore.isCloudDeployment) {
|
if (settingsStore.isCloudDeployment) {
|
||||||
|
|
Loading…
Reference in a new issue