mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
refactor(experience): use last used factor when verifing mfa factors (#4775)
This commit is contained in:
parent
77cec73429
commit
de9810709f
6 changed files with 74 additions and 21 deletions
|
@ -28,6 +28,19 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
||||||
(flow: UserMfaFlow, state: MfaFlowState) => {
|
(flow: UserMfaFlow, state: MfaFlowState) => {
|
||||||
const { availableFactors } = state;
|
const { availableFactors } = state;
|
||||||
|
|
||||||
|
if (flow === UserMfaFlow.MfaVerification) {
|
||||||
|
// In MFA verification flow, the user will be redirected to the last used factor which is the first one in the array.
|
||||||
|
const factor = availableFactors[0];
|
||||||
|
|
||||||
|
if (!factor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate({ pathname: `/${flow}/${factor}` }, { replace, state });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding flow
|
||||||
if (availableFactors.length > 1) {
|
if (availableFactors.length > 1) {
|
||||||
navigate({ pathname: `/${flow}` }, { replace, state });
|
navigate({ pathname: `/${flow}` }, { replace, state });
|
||||||
return;
|
return;
|
||||||
|
@ -39,7 +52,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) {
|
if (factor === MfaFactor.TOTP) {
|
||||||
void startTotpBinding(state);
|
void startTotpBinding(state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import {
|
||||||
enableMandatoryMfaWithWebAuthnAndBackupCode,
|
enableMandatoryMfaWithWebAuthnAndBackupCode,
|
||||||
resetMfaSettings,
|
resetMfaSettings,
|
||||||
} from '#src/helpers/sign-in-experience.js';
|
} from '#src/helpers/sign-in-experience.js';
|
||||||
import ExpectBackupCodeExperience from '#src/ui-helpers/expect-backup-code-experience.js';
|
import ExpectWebAuthnExperience from '#src/ui-helpers/expect-webauthn-experience.js';
|
||||||
import { generateUsername, waitFor } from '#src/utils.js';
|
import { generateUsername } from '#src/utils.js';
|
||||||
|
|
||||||
describe('MFA - Backup Code', () => {
|
describe('MFA - Backup Code', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
@ -43,7 +43,7 @@ describe('MFA - Backup Code', () => {
|
||||||
const password = 'l0gt0_T3st_P@ssw0rd';
|
const password = 'l0gt0_T3st_P@ssw0rd';
|
||||||
|
|
||||||
it('should bind backup codes when registering and verify backup codes when signing in', async () => {
|
it('should bind backup codes when registering and verify backup codes when signing in', async () => {
|
||||||
const experience = new ExpectBackupCodeExperience(await browser.newPage());
|
const experience = new ExpectWebAuthnExperience(await browser.newPage());
|
||||||
await experience.setupVirtualAuthenticator();
|
await experience.setupVirtualAuthenticator();
|
||||||
await experience.startWith(demoAppUrl, 'register');
|
await experience.startWith(demoAppUrl, 'register');
|
||||||
await experience.toFillInput('identifier', username, { submit: true });
|
await experience.toFillInput('identifier', username, { submit: true });
|
||||||
|
@ -68,9 +68,15 @@ describe('MFA - Backup Code', () => {
|
||||||
},
|
},
|
||||||
{ submit: true }
|
{ submit: true }
|
||||||
);
|
);
|
||||||
// Wait for the page to process submitting request.
|
|
||||||
await waitFor(500);
|
await experience.page.waitForNetworkIdle();
|
||||||
|
// Should navigate to the WebAuthn verification page
|
||||||
|
experience.toBeAt('mfa-verification/WebAuthn');
|
||||||
|
// Nav back to the list page
|
||||||
|
await experience.toClickSwitchFactorsLink({ isBinding: false });
|
||||||
experience.toBeAt('mfa-verification');
|
experience.toBeAt('mfa-verification');
|
||||||
|
|
||||||
|
// Select backup code
|
||||||
await experience.toClick('button', 'Backup code');
|
await experience.toClick('button', 'Backup code');
|
||||||
experience.toBeAt('mfa-verification/BackupCode');
|
experience.toBeAt('mfa-verification/BackupCode');
|
||||||
await experience.toFillInput('code', backupCodes.at(0) ?? '', { submit: true });
|
await experience.toFillInput('code', backupCodes.at(0) ?? '', { submit: true });
|
||||||
|
|
|
@ -7,10 +7,10 @@ import { demoAppUrl } from '#src/constants.js';
|
||||||
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
|
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
|
||||||
import { resetMfaSettings } from '#src/helpers/sign-in-experience.js';
|
import { resetMfaSettings } from '#src/helpers/sign-in-experience.js';
|
||||||
import { generateNewUser } from '#src/helpers/user.js';
|
import { generateNewUser } from '#src/helpers/user.js';
|
||||||
import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js';
|
import ExpectWebAuthnExperience from '#src/ui-helpers/expect-webauthn-experience.js';
|
||||||
import { waitFor } from '#src/utils.js';
|
import { waitFor } from '#src/utils.js';
|
||||||
|
|
||||||
describe('MFA - Factor switching', () => {
|
describe('MFA - Multi factors', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]);
|
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]);
|
||||||
await setSocialConnector();
|
await setSocialConnector();
|
||||||
|
@ -41,12 +41,12 @@ describe('MFA - Factor switching', () => {
|
||||||
await resetMfaSettings();
|
await resetMfaSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to switch between different factors', async () => {
|
it('should be able to complete MFA flow with multi factors', async () => {
|
||||||
const { userProfile, user } = await generateNewUser({ username: true, password: true });
|
const { userProfile, user } = await generateNewUser({ username: true, password: true });
|
||||||
|
|
||||||
const experience = new ExpectTotpExperience(await browser.newPage());
|
const experience = new ExpectWebAuthnExperience(await browser.newPage());
|
||||||
await experience.startWith(demoAppUrl, 'sign-in');
|
await experience.startWith(demoAppUrl, 'sign-in');
|
||||||
|
await experience.setupVirtualAuthenticator();
|
||||||
await experience.toFillForm(
|
await experience.toFillForm(
|
||||||
{
|
{
|
||||||
identifier: userProfile.username,
|
identifier: userProfile.username,
|
||||||
|
@ -65,22 +65,48 @@ describe('MFA - Factor switching', () => {
|
||||||
experience.toBeAt('mfa-binding/Totp');
|
experience.toBeAt('mfa-binding/Totp');
|
||||||
|
|
||||||
// Navigate back
|
// Navigate back
|
||||||
await experience.toClick('a[class*=switchLink');
|
await experience.toClickSwitchFactorsLink({ isBinding: true });
|
||||||
experience.toBeAt('mfa-binding');
|
experience.toBeAt('mfa-binding');
|
||||||
await expect(experience.page).toMatchElement('div[class$=title]', {
|
await expect(experience.page).toMatchElement('div[class$=title]', {
|
||||||
text: 'Add 2-step authentication',
|
text: 'Add 2-step authentication',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select WebAuthn
|
// Switch to WebAuthn
|
||||||
await experience.toClick('button div[class$=name]', 'Passkey');
|
await experience.toClick('button div[class$=name]', 'Passkey');
|
||||||
experience.toBeAt('mfa-binding/WebAuthn');
|
experience.toBeAt('mfa-binding/WebAuthn');
|
||||||
await experience.toClick('a[class*=switchLink');
|
await experience.toClickSwitchFactorsLink({ isBinding: true });
|
||||||
experience.toBeAt('mfa-binding');
|
experience.toBeAt('mfa-binding');
|
||||||
await expect(experience.page).toMatchElement('div[class$=title]', {
|
await expect(experience.page).toMatchElement('div[class$=title]', {
|
||||||
text: 'Add 2-step authentication',
|
text: 'Add 2-step authentication',
|
||||||
});
|
});
|
||||||
|
|
||||||
await experience.page.close();
|
// Bind WebAuthn
|
||||||
|
await experience.toClick('button div[class$=name]', 'Passkey');
|
||||||
|
// Wait the WebAuthn to be prepared
|
||||||
|
await experience.page.waitForNetworkIdle();
|
||||||
|
experience.toBeAt('mfa-binding/WebAuthn');
|
||||||
|
await experience.toCreatePasskey();
|
||||||
|
|
||||||
|
// Backup codes page
|
||||||
|
await experience.toClick('button', 'Continue');
|
||||||
|
await experience.verifyThenEnd(false);
|
||||||
|
|
||||||
|
// Sign in with latest used factor
|
||||||
|
await experience.startWith(demoAppUrl, 'sign-in');
|
||||||
|
await experience.toFillForm(
|
||||||
|
{
|
||||||
|
identifier: userProfile.username,
|
||||||
|
password: userProfile.password,
|
||||||
|
},
|
||||||
|
{ submit: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await experience.page.waitForNetworkIdle();
|
||||||
|
experience.toBeAt('mfa-verification/WebAuthn');
|
||||||
|
await experience.toVerifyViaPasskey();
|
||||||
|
|
||||||
|
await experience.clearVirtualAuthenticator();
|
||||||
|
await experience.verifyThenEnd();
|
||||||
await deleteUser(user.id);
|
await deleteUser(user.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -1,10 +1,11 @@
|
||||||
import { cls } from '#src/utils.js';
|
import { cls } from '#src/utils.js';
|
||||||
|
|
||||||
import ExpectWebAuthnExperience from './expect-webauthn-experience.js';
|
import ExpectExperience from './expect-experience.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: The backup code tests are based on the WebAuthn experience flow since the backup code factor cannot be enabled alone.
|
* Note: The backup code tests are based on the WebAuthn experience flow since the backup code factor cannot be enabled alone.
|
||||||
*/
|
*/
|
||||||
export default class ExpectBackupCodeExperience extends ExpectWebAuthnExperience {
|
export default class ExpectMfaExperience extends ExpectExperience {
|
||||||
constructor(thePage = global.page) {
|
constructor(thePage = global.page) {
|
||||||
super(thePage);
|
super(thePage);
|
||||||
}
|
}
|
||||||
|
@ -22,4 +23,11 @@ export default class ExpectBackupCodeExperience extends ExpectWebAuthnExperience
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toClickSwitchFactorsLink({ isBinding }: { isBinding: boolean }) {
|
||||||
|
await this.toClick(
|
||||||
|
'a',
|
||||||
|
isBinding ? 'Link another 2-step authentication' : 'Try another method to verify'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,9 +2,9 @@ import { authenticator } from 'otplib';
|
||||||
|
|
||||||
import { waitFor, dcls } from '#src/utils.js';
|
import { waitFor, dcls } from '#src/utils.js';
|
||||||
|
|
||||||
import ExpectExperience from './expect-experience.js';
|
import ExpectMfaExperience from './expect-mfa-experience.js';
|
||||||
|
|
||||||
export default class ExpectTotpExperience extends ExpectExperience {
|
export default class ExpectTotpExperience extends ExpectMfaExperience {
|
||||||
constructor(thePage = global.page) {
|
constructor(thePage = global.page) {
|
||||||
super(thePage);
|
super(thePage);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { type CDPSession } from 'puppeteer';
|
import { type CDPSession } from 'puppeteer';
|
||||||
|
|
||||||
import ExpectExperience from './expect-experience.js';
|
import ExpectMfaExperience from './expect-mfa-experience.js';
|
||||||
|
|
||||||
export default class ExpectWebAuthnExperience extends ExpectExperience {
|
export default class ExpectWebAuthnExperience extends ExpectMfaExperience {
|
||||||
private authenticatorId?: string;
|
private authenticatorId?: string;
|
||||||
private _cdpClient?: CDPSession;
|
private _cdpClient?: CDPSession;
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue