mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor: Standardize MFA code and recovery code naming across code base (#12011)
This commit is contained in:
parent
f16de4db01
commit
70706d81e1
|
@ -49,33 +49,35 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
|
|||
cy.intercept('GET', '/rest/mfa/qr').as('getMfaQrCode');
|
||||
});
|
||||
|
||||
it('Should be able to login with MFA token', () => {
|
||||
it('Should be able to login with MFA code', () => {
|
||||
const { email, password } = user;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
const token = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaToken(email, password, token);
|
||||
const mfaCode = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
|
||||
mainSidebar.actions.signout();
|
||||
});
|
||||
|
||||
it('Should be able to login with recovery code', () => {
|
||||
it('Should be able to login with MFA recovery code', () => {
|
||||
const { email, password } = user;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
mfaLoginPage.actions.loginWithRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
|
||||
mfaLoginPage.actions.loginWithMfaRecoveryCode(email, password, user.mfaRecoveryCodes[0]);
|
||||
mainSidebar.actions.signout();
|
||||
});
|
||||
|
||||
it('Should be able to disable MFA in account', () => {
|
||||
it('Should be able to disable MFA in account with MFA code ', () => {
|
||||
const { email, password } = user;
|
||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||
personalSettingsPage.actions.enableMfa();
|
||||
mainSidebar.actions.signout();
|
||||
const token = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaToken(email, password, token);
|
||||
personalSettingsPage.actions.disableMfa();
|
||||
const loginToken = generateOTPToken(user.mfaSecret);
|
||||
mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken);
|
||||
const disableToken = generateOTPToken(user.mfaSecret);
|
||||
personalSettingsPage.actions.disableMfa(disableToken);
|
||||
personalSettingsPage.getters.enableMfaButton().should('exist');
|
||||
mainSidebar.actions.signout();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,18 +8,18 @@ export class MfaLoginPage extends BasePage {
|
|||
|
||||
getters = {
|
||||
form: () => cy.getByTestId('mfa-login-form'),
|
||||
token: () => cy.getByTestId('token'),
|
||||
recoveryCode: () => cy.getByTestId('recoveryCode'),
|
||||
mfaCode: () => cy.getByTestId('mfaCode'),
|
||||
mfaRecoveryCode: () => cy.getByTestId('mfaRecoveryCode'),
|
||||
enterRecoveryCodeButton: () => cy.getByTestId('mfa-enter-recovery-code-button'),
|
||||
};
|
||||
|
||||
actions = {
|
||||
loginWithMfaToken: (email: string, password: string, mfaToken: string) => {
|
||||
loginWithMfaCode: (email: string, password: string, mfaCode: string) => {
|
||||
const signinPage = new SigninPage();
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
|
||||
cy.session(
|
||||
[mfaToken],
|
||||
[mfaCode],
|
||||
() => {
|
||||
cy.visit(signinPage.url);
|
||||
|
||||
|
@ -30,7 +30,7 @@ export class MfaLoginPage extends BasePage {
|
|||
});
|
||||
|
||||
this.getters.form().within(() => {
|
||||
this.getters.token().type(mfaToken);
|
||||
this.getters.mfaCode().type(mfaCode);
|
||||
});
|
||||
|
||||
// we should be redirected to /workflows
|
||||
|
@ -43,12 +43,12 @@ export class MfaLoginPage extends BasePage {
|
|||
},
|
||||
);
|
||||
},
|
||||
loginWithRecoveryCode: (email: string, password: string, recoveryCode: string) => {
|
||||
loginWithMfaRecoveryCode: (email: string, password: string, mfaRecoveryCode: string) => {
|
||||
const signinPage = new SigninPage();
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
|
||||
cy.session(
|
||||
[recoveryCode],
|
||||
[mfaRecoveryCode],
|
||||
() => {
|
||||
cy.visit(signinPage.url);
|
||||
|
||||
|
@ -61,7 +61,7 @@ export class MfaLoginPage extends BasePage {
|
|||
this.getters.enterRecoveryCodeButton().click();
|
||||
|
||||
this.getters.form().within(() => {
|
||||
this.getters.recoveryCode().type(recoveryCode);
|
||||
this.getters.mfaRecoveryCode().type(mfaRecoveryCode);
|
||||
});
|
||||
|
||||
// we should be redirected to /workflows
|
||||
|
|
|
@ -22,6 +22,8 @@ export class PersonalSettingsPage extends BasePage {
|
|||
saveSettingsButton: () => cy.getByTestId('save-settings-button'),
|
||||
enableMfaButton: () => cy.getByTestId('enable-mfa-button'),
|
||||
disableMfaButton: () => cy.getByTestId('disable-mfa-button'),
|
||||
mfaCodeOrMfaRecoveryCodeInput: () => cy.getByTestId('mfa-code-or-recovery-code-input'),
|
||||
mfaSaveButton: () => cy.getByTestId('mfa-save-button'),
|
||||
themeSelector: () => cy.getByTestId('theme-select'),
|
||||
selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'),
|
||||
};
|
||||
|
@ -83,9 +85,11 @@ export class PersonalSettingsPage extends BasePage {
|
|||
mfaSetupModal.getters.saveButton().click();
|
||||
});
|
||||
},
|
||||
disableMfa: () => {
|
||||
disableMfa: (mfaCodeOrRecoveryCode: string) => {
|
||||
cy.visit(this.url);
|
||||
this.getters.disableMfaButton().click();
|
||||
this.getters.mfaCodeOrMfaRecoveryCodeInput().type(mfaCodeOrRecoveryCode);
|
||||
this.getters.mfaSaveButton().click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ export class AuthController {
|
|||
/** Log in a user */
|
||||
@Post('/login', { skipAuth: true, rateLimit: true })
|
||||
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 (!password) throw new ApplicationError('Password is required to log in');
|
||||
|
||||
|
@ -75,16 +75,16 @@ export class AuthController {
|
|||
|
||||
if (user) {
|
||||
if (user.mfaEnabled) {
|
||||
if (!mfaToken && !mfaRecoveryCode) {
|
||||
if (!mfaCode && !mfaRecoveryCode) {
|
||||
throw new AuthError('MFA Error', 998);
|
||||
}
|
||||
|
||||
const isMFATokenValid = await this.mfaService.validateMfa(
|
||||
const isMfaCodeOrMfaRecoveryCodeValid = await this.mfaService.validateMfa(
|
||||
user.id,
|
||||
mfaToken,
|
||||
mfaCode,
|
||||
mfaRecoveryCode,
|
||||
);
|
||||
if (!isMFATokenValid) {
|
||||
if (!isMfaCodeOrMfaRecoveryCodeValid) {
|
||||
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');
|
||||
}
|
||||
|
||||
const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||
if (!isMfaTokenValid) {
|
||||
const isMfaCodeValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined);
|
||||
if (!isMfaCodeValid) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
}
|
||||
|
@ -142,8 +142,8 @@ export class MeController {
|
|||
throw new BadRequestError('Two-factor code is required to change password.');
|
||||
}
|
||||
|
||||
const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
||||
if (!isMfaTokenValid) {
|
||||
const isMfaCodeValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined);
|
||||
if (!isMfaCodeValid) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ export class MFAController {
|
|||
|
||||
@Post('/enable', { rateLimit: true })
|
||||
async activateMFA(req: MFA.Activate) {
|
||||
const { token = null } = req.body;
|
||||
const { mfaCode = null } = req.body;
|
||||
const { id, mfaEnabled } = req.user;
|
||||
|
||||
await this.externalHooks.run('mfa.beforeSetup', [req.user]);
|
||||
|
@ -67,7 +67,7 @@ export class MFAController {
|
|||
const { decryptedSecret: secret, decryptedRecoveryCodes: recoveryCodes } =
|
||||
await this.mfaService.getSecretAndRecoveryCodes(id);
|
||||
|
||||
if (!token) throw new BadRequestError('Token is required to enable MFA feature');
|
||||
if (!mfaCode) throw new BadRequestError('Token is required to enable MFA feature');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const verified = this.mfaService.totp.verifySecret({ secret, token, window: 10 });
|
||||
const verified = this.mfaService.totp.verifySecret({ secret, mfaCode, window: 10 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -86,27 +86,27 @@ export class MFAController {
|
|||
@Post('/disable', { rateLimit: true })
|
||||
async disableMFA(req: MFA.Disable) {
|
||||
const { id: userId } = req.user;
|
||||
const { token = null } = req.body;
|
||||
const { mfaCode = null } = req.body;
|
||||
|
||||
if (typeof token !== 'string' || !token) {
|
||||
if (typeof mfaCode !== 'string' || !mfaCode) {
|
||||
throw new BadRequestError('Token is required to disable MFA feature');
|
||||
}
|
||||
|
||||
await this.mfaService.disableMfa(userId, token);
|
||||
await this.mfaService.disableMfa(userId, mfaCode);
|
||||
}
|
||||
|
||||
@Post('/verify', { rateLimit: true })
|
||||
async verifyMFA(req: MFA.Verify) {
|
||||
const { id } = req.user;
|
||||
const { token } = req.body;
|
||||
const { mfaCode } = req.body;
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
|
|
|
@ -171,7 +171,7 @@ export class PasswordResetController {
|
|||
*/
|
||||
@Post('/change-password', { skipAuth: true })
|
||||
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
|
||||
const { token, password, mfaToken } = req.body;
|
||||
const { token, password, mfaCode } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
this.logger.debug(
|
||||
|
@ -189,11 +189,11 @@ export class PasswordResetController {
|
|||
if (!user) throw new NotFoundError('');
|
||||
|
||||
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 validToken = this.mfaService.totp.verifySecret({ secret, token: mfaToken });
|
||||
const validToken = this.mfaService.totp.verifySecret({ secret, mfaCode });
|
||||
|
||||
if (!validToken) throw new BadRequestError('Invalid MFA token.');
|
||||
}
|
||||
|
|
|
@ -56,13 +56,13 @@ export class MfaService {
|
|||
|
||||
async validateMfa(
|
||||
userId: string,
|
||||
mfaToken: string | undefined,
|
||||
mfaCode: string | undefined,
|
||||
mfaRecoveryCode: string | undefined,
|
||||
) {
|
||||
const user = await this.authUserRepository.findOneByOrFail({ id: userId });
|
||||
if (mfaToken) {
|
||||
if (mfaCode) {
|
||||
const decryptedSecret = this.cipher.decrypt(user.mfaSecret!);
|
||||
return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken });
|
||||
return this.totp.verifySecret({ secret: decryptedSecret, mfaCode });
|
||||
}
|
||||
|
||||
if (mfaRecoveryCode) {
|
||||
|
@ -85,8 +85,8 @@ export class MfaService {
|
|||
return await this.authUserRepository.save(user);
|
||||
}
|
||||
|
||||
async disableMfa(userId: string, mfaToken: string) {
|
||||
const isValidToken = await this.validateMfa(userId, mfaToken, undefined);
|
||||
async disableMfa(userId: string, mfaCode: string) {
|
||||
const isValidToken = await this.validateMfa(userId, mfaCode, undefined);
|
||||
if (!isValidToken) {
|
||||
throw new InvalidMfaCodeError();
|
||||
}
|
||||
|
|
|
@ -23,10 +23,14 @@ export class TOTPService {
|
|||
}).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({
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).validate({ token, window }) === null
|
||||
}).validate({ token: mfaCode, window }) === null
|
||||
? false
|
||||
: true;
|
||||
}
|
||||
|
|
|
@ -228,7 +228,7 @@ export declare namespace PasswordResetRequest {
|
|||
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;
|
||||
password: string;
|
||||
mfaToken?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}
|
||||
>;
|
||||
|
@ -316,9 +316,9 @@ export type LoginRequest = AuthlessRequest<
|
|||
// ----------------------------------
|
||||
|
||||
export declare namespace MFA {
|
||||
type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Disable = AuthenticatedRequest<{}, {}, { token: string }, {}>;
|
||||
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Disable = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
|
||||
type ValidateRecoveryCode = AuthenticatedRequest<
|
||||
{},
|
||||
|
|
|
@ -89,7 +89,7 @@ describe('POST /login', () => {
|
|||
const response = await testServer.authlessAgent.post('/login').send({
|
||||
email: owner.email,
|
||||
password: ownerPassword,
|
||||
mfaToken: mfaService.totp.generateTOTP(secret),
|
||||
mfaCode: mfaService.totp.generateTOTP(secret),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
|
|
@ -55,8 +55,8 @@ describe('Enable MFA setup', () => {
|
|||
secondCall.body.data.recoveryCodes.join(''),
|
||||
);
|
||||
|
||||
const token = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
||||
await testServer.authAgentFor(owner).post('/mfa/disable').send({ token }).expect(200);
|
||||
const mfaCode = new TOTPService().generateTOTP(firstCall.body.data.secret);
|
||||
await testServer.authAgentFor(owner).post('/mfa/disable').send({ mfaCode }).expect(200);
|
||||
|
||||
const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
|
@ -84,22 +84,22 @@ describe('Enable MFA setup', () => {
|
|||
await testServer.authlessAgent.post('/mfa/verify').expect(401);
|
||||
});
|
||||
|
||||
test('POST /verify should fail due to invalid MFA token', async () => {
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '123' }).expect(400);
|
||||
test('POST /verify should fail due to invalid MFA code', async () => {
|
||||
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 mfaCode parameter', async () => {
|
||||
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 code', async () => {
|
||||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -108,13 +108,13 @@ describe('Enable MFA setup', () => {
|
|||
await testServer.authlessAgent.post('/mfa/enable').expect(401);
|
||||
});
|
||||
|
||||
test('POST /verify should fail due to missing token parameter', async () => {
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ token: '' }).expect(400);
|
||||
test('POST /verify should fail due to missing mfaCode parameter', async () => {
|
||||
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 code', async () => {
|
||||
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 () => {
|
||||
|
@ -125,10 +125,10 @@ describe('Enable MFA setup', () => {
|
|||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
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/enable').send({ token }).expect(200);
|
||||
await testServer.authAgentFor(owner).post('/mfa/verify').send({ mfaCode }).expect(200);
|
||||
await testServer.authAgentFor(owner).post('/mfa/enable').send({ mfaCode }).expect(200);
|
||||
|
||||
const user = await Container.get(AuthUserRepository).findOneOrFail({
|
||||
where: {},
|
||||
|
@ -145,13 +145,13 @@ describe('Enable MFA setup', () => {
|
|||
const response = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200);
|
||||
|
||||
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'));
|
||||
|
||||
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({
|
||||
where: {},
|
||||
|
@ -165,13 +165,13 @@ describe('Enable MFA setup', () => {
|
|||
describe('Disable MFA setup', () => {
|
||||
test('POST /disable should disable login with MFA', async () => {
|
||||
const { user, rawSecret } = await createUserWithMfaEnabled();
|
||||
const token = new TOTPService().generateTOTP(rawSecret);
|
||||
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(user)
|
||||
.post('/mfa/disable')
|
||||
.send({
|
||||
token,
|
||||
mfaCode,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -184,21 +184,21 @@ describe('Disable MFA setup', () => {
|
|||
expect(dbUser.mfaRecoveryCodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test('POST /disable should fail if invalid token is given', async () => {
|
||||
test('POST /disable should fail if invalid mfaCode is given', async () => {
|
||||
const { user } = await createUserWithMfaEnabled();
|
||||
|
||||
await testServer
|
||||
.authAgentFor(user)
|
||||
.post('/mfa/disable')
|
||||
.send({
|
||||
token: 'invalid token',
|
||||
mfaCode: 'invalid token',
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change password with MFA enabled', () => {
|
||||
test('POST /change-password should fail due to missing MFA token', async () => {
|
||||
test('POST /change-password should fail due to missing MFA code', async () => {
|
||||
await createUserWithMfaEnabled();
|
||||
|
||||
const newPassword = randomValidPassword();
|
||||
|
@ -210,7 +210,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.expect(404);
|
||||
});
|
||||
|
||||
test('POST /change-password should fail due to invalid MFA token', async () => {
|
||||
test('POST /change-password should fail due to invalid MFA code', async () => {
|
||||
await createUserWithMfaEnabled();
|
||||
|
||||
const newPassword = randomValidPassword();
|
||||
|
@ -221,7 +221,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.send({
|
||||
password: newPassword,
|
||||
token: resetPasswordToken,
|
||||
mfaToken: randomInt(10),
|
||||
mfaCode: randomInt(10),
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
@ -235,14 +235,14 @@ describe('Change password with MFA enabled', () => {
|
|||
|
||||
const resetPasswordToken = Container.get(AuthService).generatePasswordResetToken(user);
|
||||
|
||||
const mfaToken = new TOTPService().generateTOTP(rawSecret);
|
||||
const mfaCode = new TOTPService().generateTOTP(rawSecret);
|
||||
|
||||
await testServer.authlessAgent
|
||||
.post('/change-password')
|
||||
.send({
|
||||
password: newPassword,
|
||||
token: resetPasswordToken,
|
||||
mfaToken,
|
||||
mfaCode,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -252,7 +252,7 @@ describe('Change password with MFA enabled', () => {
|
|||
.send({
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
mfaToken: new TOTPService().generateTOTP(rawSecret),
|
||||
mfaCode: new TOTPService().generateTOTP(rawSecret),
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -315,7 +315,7 @@ describe('Login', () => {
|
|||
|
||||
await testServer.authlessAgent
|
||||
.post('/login')
|
||||
.send({ email: user.email, password: rawPassword, mfaToken: 'wrongvalue' })
|
||||
.send({ email: user.email, password: rawPassword, mfaCode: 'wrongvalue' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
|
@ -337,7 +337,7 @@ describe('Login', () => {
|
|||
|
||||
const response = await testServer.authlessAgent
|
||||
.post('/login')
|
||||
.send({ email: user.email, password: rawPassword, mfaToken: token })
|
||||
.send({ email: user.email, password: rawPassword, mfaCode: token })
|
||||
.expect(200);
|
||||
|
||||
const data = response.body.data;
|
||||
|
|
|
@ -11,19 +11,22 @@ export async function getMfaQR(
|
|||
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);
|
||||
}
|
||||
|
||||
export async function verifyMfaToken(
|
||||
export async function verifyMfaCode(
|
||||
context: IRestApiContext,
|
||||
data: { token: string },
|
||||
data: { mfaCode: string },
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/verify', data);
|
||||
}
|
||||
|
||||
export type DisableMfaParams = {
|
||||
token: string;
|
||||
mfaCode: string;
|
||||
};
|
||||
|
||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||
|
|
|
@ -21,7 +21,7 @@ export async function loginCurrentUser(
|
|||
|
||||
export async function login(
|
||||
context: IRestApiContext,
|
||||
params: { email: string; password: string; mfaToken?: string; mfaRecoveryToken?: string },
|
||||
params: { email: string; password: string; mfaCode?: string; mfaRecoveryToken?: string },
|
||||
): Promise<CurrentUserResponse> {
|
||||
return await makeRestApiRequest(context, 'POST', '/login', params);
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ export async function validatePasswordToken(
|
|||
|
||||
export async function changePassword(
|
||||
context: IRestApiContext,
|
||||
params: { token: string; password: string; mfaToken?: string },
|
||||
params: { token: string; password: string; mfaCode?: string },
|
||||
): Promise<void> {
|
||||
await makeRestApiRequest(context, 'POST', '/change-password', params);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import Modal from './Modal.vue';
|
||||
import {
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED,
|
||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
} from '../constants';
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
@ -53,12 +53,12 @@ const closeDialog = () => {
|
|||
};
|
||||
|
||||
const onInput = (value: string) => {
|
||||
if (value.length !== MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH) {
|
||||
if (value.length !== MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH) {
|
||||
infoTextErrorMessage.value = '';
|
||||
return;
|
||||
}
|
||||
userStore
|
||||
.verifyMfaToken({ token: value })
|
||||
.verifyMfaCode({ mfaCode: value })
|
||||
.then(() => {
|
||||
showRecoveryCodes.value = true;
|
||||
authenticatorCode.value = value;
|
||||
|
@ -98,14 +98,14 @@ const onDownloadClick = () => {
|
|||
|
||||
const onSetupClick = async () => {
|
||||
try {
|
||||
await userStore.enableMfa({ token: authenticatorCode.value });
|
||||
await userStore.enableMfa({ mfaCode: authenticatorCode.value });
|
||||
closeDialog();
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('mfa.setup.step2.toast.setupFinished.message'),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.errorCode === MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED) {
|
||||
if (e.errorCode === MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED) {
|
||||
toast.showMessage({
|
||||
type: 'error',
|
||||
title: i18n.baseText('mfa.setup.step2.toast.tokenExpired.error.message'),
|
||||
|
|
|
@ -54,7 +54,7 @@ function onFormReady(isReady: boolean) {
|
|||
<template #content>
|
||||
<div :class="[$style.formContainer]">
|
||||
<n8n-form-inputs
|
||||
data-test-id="mfa-code-form"
|
||||
data-test-id="mfa-code-or-recovery-code-input"
|
||||
:inputs="formFields"
|
||||
:event-bus="formBus"
|
||||
@submit="onSubmit"
|
||||
|
|
|
@ -723,9 +723,9 @@ export const MFA_FORM = {
|
|||
|
||||
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;
|
||||
|
||||
export const MFA_AUTHENTICATION_TOKEN_WINDOW_EXPIRED = 997;
|
||||
export const MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED = 997;
|
||||
|
||||
export const MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH = 6;
|
||||
export const MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH = 6;
|
||||
|
||||
export const MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH = 36;
|
||||
|
||||
|
|
|
@ -172,7 +172,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
const loginWithCreds = async (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
mfaToken?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}) => {
|
||||
const user = await usersApi.login(rootStore.restApiContext, params);
|
||||
|
@ -232,7 +232,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
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);
|
||||
};
|
||||
|
||||
|
@ -316,15 +316,15 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
return await mfaApi.getMfaQR(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const verifyMfaToken = async (data: { token: string }) => {
|
||||
return await mfaApi.verifyMfaToken(rootStore.restApiContext, data);
|
||||
const verifyMfaCode = async (data: { mfaCode: string }) => {
|
||||
return await mfaApi.verifyMfaCode(rootStore.restApiContext, data);
|
||||
};
|
||||
|
||||
const canEnableMFA = async () => {
|
||||
return await mfaApi.canEnableMFA(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const enableMfa = async (data: { token: string }) => {
|
||||
const enableMfa = async (data: { mfaCode: string }) => {
|
||||
await mfaApi.enableMfa(rootStore.restApiContext, data);
|
||||
if (currentUser.value) {
|
||||
currentUser.value.mfaEnabled = true;
|
||||
|
@ -333,7 +333,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
|
||||
const disableMfa = async (mfaCode: string) => {
|
||||
await mfaApi.disableMfa(rootStore.restApiContext, {
|
||||
token: mfaCode,
|
||||
mfaCode,
|
||||
});
|
||||
|
||||
if (currentUser.value) {
|
||||
|
@ -404,7 +404,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
submitPersonalizationSurvey,
|
||||
showPersonalizationSurvey,
|
||||
fetchMfaQR,
|
||||
verifyMfaToken,
|
||||
verifyMfaCode,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
canEnableMFA,
|
||||
|
|
|
@ -9,7 +9,7 @@ import { useToast } from '@/composables/useToast';
|
|||
import { useUsersStore } from '@/stores/users.store';
|
||||
|
||||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
|
||||
import { MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH, VIEWS } from '@/constants';
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
|
@ -56,7 +56,7 @@ const onSubmit = async (values: { [key: string]: string }) => {
|
|||
const changePasswordParameters = {
|
||||
token,
|
||||
password: password.value,
|
||||
...(values.mfaToken && { mfaToken: values.mfaToken }),
|
||||
...(values.mfaCode && { mfaCode: values.mfaCode }),
|
||||
};
|
||||
|
||||
await usersStore.changePassword(changePasswordParameters);
|
||||
|
@ -129,13 +129,13 @@ onMounted(async () => {
|
|||
|
||||
if (mfaEnabled) {
|
||||
form.inputs.push({
|
||||
name: 'mfaToken',
|
||||
name: 'mfaCode',
|
||||
initialValue: '',
|
||||
properties: {
|
||||
required: true,
|
||||
label: locale.baseText('mfa.code.input.label'),
|
||||
placeholder: locale.baseText('mfa.code.input.placeholder'),
|
||||
maxlength: MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
maxlength: MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
capitalize: true,
|
||||
validateOnBlur: true,
|
||||
},
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { IFormInputs } from '@/Interface';
|
|||
import Logo from '../components/Logo.vue';
|
||||
import {
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
MFA_FORM,
|
||||
} from '@/constants';
|
||||
import { mfaEventBus } from '@/event-bus';
|
||||
|
@ -29,7 +29,7 @@ const hasAnyChanges = ref(false);
|
|||
const formBus = ref(mfaEventBus);
|
||||
const formInputs = ref<null | IFormInputs>(null);
|
||||
const showRecoveryCodeForm = ref(false);
|
||||
const verifyingMfaToken = ref(false);
|
||||
const verifyingMfaCode = ref(false);
|
||||
const formError = ref('');
|
||||
const { reportError } = toRefs(props);
|
||||
|
||||
|
@ -48,7 +48,7 @@ const i18 = useI18n();
|
|||
const emit = defineEmits<{
|
||||
onFormChanged: [formField: string];
|
||||
onBackClick: [formField: string];
|
||||
submit: [{ token: string; recoveryCode: string }];
|
||||
submit: [{ mfaCode: string; mfaRecoveryCode: string }];
|
||||
}>();
|
||||
|
||||
// #endregion
|
||||
|
@ -94,11 +94,11 @@ const onBackClick = () => {
|
|||
|
||||
showRecoveryCodeForm.value = false;
|
||||
hasAnyChanges.value = true;
|
||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
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
|
||||
? i18.baseText('mfa.code.invalid')
|
||||
: i18.baseText('mfa.recovery.invalid');
|
||||
|
@ -106,9 +106,9 @@ const onSubmit = async (form: { token: string; recoveryCode: string }) => {
|
|||
};
|
||||
|
||||
const onInput = ({ target: { value, name } }: { target: { value: string; name: string } }) => {
|
||||
const isSubmittingMfaToken = name === 'token';
|
||||
const inputValidLength = isSubmittingMfaToken
|
||||
? MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH
|
||||
const isSubmittingMfaCode = name === 'mfaCode';
|
||||
const inputValidLength = isSubmittingMfaCode
|
||||
? MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH
|
||||
: MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH;
|
||||
|
||||
if (value.length !== inputValidLength) {
|
||||
|
@ -116,33 +116,33 @@ const onInput = ({ target: { value, name } }: { target: { value: string; name: s
|
|||
return;
|
||||
}
|
||||
|
||||
verifyingMfaToken.value = true;
|
||||
verifyingMfaCode.value = true;
|
||||
hasAnyChanges.value = true;
|
||||
|
||||
const dataToSubmit = isSubmittingMfaToken
|
||||
? { token: value, recoveryCode: '' }
|
||||
: { token: '', recoveryCode: value };
|
||||
const dataToSubmit = isSubmittingMfaCode
|
||||
? { mfaCode: value, mfaRecoveryCode: '' }
|
||||
: { mfaCode: '', mfaRecoveryCode: value };
|
||||
|
||||
onSubmit(dataToSubmit)
|
||||
.catch(() => {})
|
||||
.finally(() => (verifyingMfaToken.value = false));
|
||||
.finally(() => (verifyingMfaCode.value = false));
|
||||
};
|
||||
|
||||
const mfaRecoveryCodeFieldWithDefaults = () => {
|
||||
return formField(
|
||||
'recoveryCode',
|
||||
'mfaRecoveryCode',
|
||||
i18.baseText('mfa.recovery.input.label'),
|
||||
i18.baseText('mfa.recovery.input.placeholder'),
|
||||
MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH,
|
||||
);
|
||||
};
|
||||
|
||||
const mfaTokenFieldWithDefaults = () => {
|
||||
const mfaCodeFieldWithDefaults = () => {
|
||||
return formField(
|
||||
'token',
|
||||
'mfaCode',
|
||||
i18.baseText('mfa.code.input.label'),
|
||||
i18.baseText('mfa.code.input.placeholder'),
|
||||
MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH,
|
||||
MFA_AUTHENTICATION_CODE_INPUT_MAX_LENGTH,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -157,7 +157,7 @@ const onSaveClick = () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
onMounted(() => {
|
||||
formInputs.value = [mfaTokenFieldWithDefaults()];
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
@ -211,7 +211,7 @@ onMounted(() => {
|
|||
<div>
|
||||
<n8n-button
|
||||
float="right"
|
||||
:loading="verifyingMfaToken"
|
||||
:loading="verifyingMfaCode"
|
||||
:label="
|
||||
showRecoveryCodeForm
|
||||
? i18.baseText('mfa.recovery.button.verify')
|
||||
|
|
|
@ -87,7 +87,7 @@ describe('SigninView', () => {
|
|||
expect(usersStore.loginWithCreds).toHaveBeenCalledWith({
|
||||
email: 'test@n8n.io',
|
||||
password: 'password',
|
||||
mfaToken: undefined,
|
||||
mfaCode: 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({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
token: form.token,
|
||||
recoveryCode: form.recoveryCode,
|
||||
mfaCode: form.mfaCode,
|
||||
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -114,16 +114,16 @@ const getRedirectQueryParameter = () => {
|
|||
const login = async (form: {
|
||||
email: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
recoveryCode?: string;
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
}) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await usersStore.loginWithCreds({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
mfaToken: form.token,
|
||||
mfaRecoveryCode: form.recoveryCode,
|
||||
mfaCode: form.mfaCode,
|
||||
mfaRecoveryCode: form.mfaRecoveryCode,
|
||||
});
|
||||
loading.value = false;
|
||||
if (settingsStore.isCloudDeployment) {
|
||||
|
|
Loading…
Reference in a new issue