mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(experience): implement backup code experience flow (#4699)
This commit is contained in:
parent
a20e9a2641
commit
02e72ea425
12 changed files with 350 additions and 3 deletions
|
@ -14,9 +14,11 @@ import Continue from './pages/Continue';
|
|||
import ErrorPage from './pages/ErrorPage';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import MfaBinding from './pages/MfaBinding';
|
||||
import BackupCodeBinding from './pages/MfaBinding/BackupCodeBinding';
|
||||
import TotpBinding from './pages/MfaBinding/TotpBinding';
|
||||
import WebAuthnBinding from './pages/MfaBinding/WebAuthnBinding';
|
||||
import MfaVerification from './pages/MfaVerification';
|
||||
import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification';
|
||||
import TotpVerification from './pages/MfaVerification/TotpVerification';
|
||||
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
|
||||
import Register from './pages/Register';
|
||||
|
@ -83,6 +85,7 @@ const App = () => {
|
|||
<Route index element={<MfaBinding />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpBinding />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnBinding />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeBinding />} />
|
||||
</Route>
|
||||
|
||||
{/* Mfa verification */}
|
||||
|
@ -91,6 +94,7 @@ const App = () => {
|
|||
<Route index element={<MfaVerification />} />
|
||||
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
|
||||
<Route path={MfaFactor.WebAuthn} element={<WebAuthnVerification />} />
|
||||
<Route path={MfaFactor.BackupCode} element={<BackupCodeVerification />} />
|
||||
</Route>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -4,7 +4,12 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { validate } from 'superstruct';
|
||||
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { type MfaFactorsState, mfaErrorDataGuard } from '@/types/guard';
|
||||
import {
|
||||
type MfaFactorsState,
|
||||
mfaErrorDataGuard,
|
||||
backupCodeErrorDataGuard,
|
||||
type BackupCodeBindingState,
|
||||
} from '@/types/guard';
|
||||
|
||||
import type { ErrorHandlers } from './use-error-handler';
|
||||
import useStartTotpBinding from './use-start-totp-binding';
|
||||
|
@ -63,12 +68,30 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => {
|
|||
[handleMfaRedirect, setToast]
|
||||
);
|
||||
|
||||
const handleBackupCodeError = useCallback(
|
||||
(error: RequestErrorBody) => {
|
||||
const [_, data] = validate(error.data, backupCodeErrorDataGuard);
|
||||
|
||||
if (!data) {
|
||||
setToast(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(
|
||||
{ pathname: `/${UserMfaFlow.MfaBinding}/${MfaFactor.BackupCode}` },
|
||||
{ replace, state: data satisfies BackupCodeBindingState }
|
||||
);
|
||||
},
|
||||
[navigate, replace, setToast]
|
||||
);
|
||||
|
||||
const mfaVerificationErrorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.missing_mfa': handleMfaError(UserMfaFlow.MfaBinding),
|
||||
'session.mfa.require_mfa_verification': handleMfaError(UserMfaFlow.MfaVerification),
|
||||
'session.mfa.backup_code_required': handleBackupCodeError,
|
||||
}),
|
||||
[handleMfaError]
|
||||
[handleBackupCodeError, handleMfaError]
|
||||
);
|
||||
|
||||
return mfaVerificationErrorHandler;
|
||||
|
|
|
@ -13,10 +13,19 @@ const useTextHandler = () => {
|
|||
[setToast]
|
||||
);
|
||||
|
||||
// Todo: @xiaoyijun add download text file handler
|
||||
const downloadText = useCallback((text: string, filename: string) => {
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const downloadLink = document.createElement('a');
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
downloadLink.href = URL.createObjectURL(blob);
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
downloadLink.download = filename;
|
||||
downloadLink.click();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
copyText,
|
||||
downloadText,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
@include _.flex-column(center);
|
||||
align-items: stretch;
|
||||
gap: _.unit(4);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.backupCodes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: _.unit(4);
|
||||
font: var(--font-label-1);
|
||||
text-align: center;
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color-bg-layer-2);
|
||||
color: var(--color-type-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@include _.flex-row;
|
||||
gap: _.unit(4);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-secondary);
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { validate } from 'superstruct';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import Button from '@/components/Button';
|
||||
import DynamicT from '@/components/DynamicT';
|
||||
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
|
||||
import useTextHandler from '@/hooks/use-text-handler';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
import { backupCodeBindingStateGuard } from '@/types/guard';
|
||||
import { isNativeWebview } from '@/utils/native-sdk';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const BackupCodeBinding = () => {
|
||||
const { copyText, downloadText } = useTextHandler();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
|
||||
const { state } = useLocation();
|
||||
const [, backupCodeBindingState] = validate(state, backupCodeBindingStateGuard);
|
||||
|
||||
if (!backupCodeBindingState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { codes } = backupCodeBindingState;
|
||||
const backupCodeTextContent = codes.join('\n');
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
title="mfa.save_backup_code"
|
||||
description="mfa.save_backup_code_description"
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.backupCodes}>
|
||||
{codes.map((code) => (
|
||||
<span key={code}>{code}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{!isNativeWebview() && (
|
||||
<Button
|
||||
title="action.download"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
downloadText(backupCodeTextContent, 'backup_code.txt');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
title="action.copy"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
void copyText(backupCodeTextContent, t('mfa.backup_code_copied'));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hint}>
|
||||
<DynamicT forKey="mfa.backup_code_hint" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
title="action.continue"
|
||||
onClick={() => {
|
||||
void sendMfaPayload({
|
||||
flow: UserMfaFlow.MfaBinding,
|
||||
payload: { type: MfaFactor.BackupCode },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupCodeBinding;
|
|
@ -0,0 +1,9 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.backupCodeInput {
|
||||
margin: _.unit(4) 0;
|
||||
}
|
||||
|
||||
.switchFactorLink {
|
||||
margin-top: _.unit(6);
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, type FormEvent } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||
import SectionLayout from '@/Layout/SectionLayout';
|
||||
import Button from '@/components/Button';
|
||||
import { InputField } from '@/components/InputFields';
|
||||
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
|
||||
import useMfaFactorsState from '@/hooks/use-mfa-factors-state';
|
||||
import useSendMfaPayload from '@/hooks/use-send-mfa-payload';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserMfaFlow } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type FormState = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const BackupCodeVerification = () => {
|
||||
const mfaFactorsState = useMfaFactorsState();
|
||||
const sendMfaPayload = useSendMfaPayload();
|
||||
const { register, handleSubmit } = useForm<FormState>({ defaultValues: { code: '' } });
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(event?: FormEvent<HTMLFormElement>) => {
|
||||
void handleSubmit(async ({ code }) => {
|
||||
void sendMfaPayload({
|
||||
flow: UserMfaFlow.MfaVerification,
|
||||
payload: { type: MfaFactor.BackupCode, code },
|
||||
});
|
||||
})(event);
|
||||
},
|
||||
[handleSubmit, sendMfaPayload]
|
||||
);
|
||||
|
||||
if (!mfaFactorsState) {
|
||||
return <ErrorPage title="error.invalid_session" />;
|
||||
}
|
||||
|
||||
const { availableFactors } = mfaFactorsState;
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout title="mfa.verify_mfa_factors">
|
||||
<SectionLayout
|
||||
title="mfa.enter_a_backup_code"
|
||||
description="mfa.enter_backup_code_description"
|
||||
>
|
||||
<form onSubmit={onSubmitHandler}>
|
||||
<InputField
|
||||
placeholder={t('input.backup_code')}
|
||||
className={styles.backupCodeInput}
|
||||
{...register('code')}
|
||||
/>
|
||||
<Button title="action.continue" htmlType="submit" />
|
||||
</form>
|
||||
</SectionLayout>
|
||||
{availableFactors.length > 1 && (
|
||||
<SwitchMfaFactorsLink
|
||||
flow={UserMfaFlow.MfaVerification}
|
||||
factors={availableFactors}
|
||||
className={styles.switchFactorLink}
|
||||
/>
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupCodeVerification;
|
|
@ -88,3 +88,11 @@ export const totpBindingStateGuard = s.assign(
|
|||
);
|
||||
|
||||
export type TotpBindingState = s.Infer<typeof totpBindingStateGuard>;
|
||||
|
||||
export const backupCodeErrorDataGuard = s.object({
|
||||
codes: s.array(s.string()),
|
||||
});
|
||||
|
||||
export const backupCodeBindingStateGuard = backupCodeErrorDataGuard;
|
||||
|
||||
export type BackupCodeBindingState = s.Infer<typeof backupCodeBindingStateGuard>;
|
||||
|
|
|
@ -99,5 +99,13 @@ export const enableMandatoryMfaWithTotpAndBackupCode = async () =>
|
|||
},
|
||||
});
|
||||
|
||||
export const enableMandatoryMfaWithWebAuthnAndBackupCode = async () =>
|
||||
updateSignInExperience({
|
||||
mfa: {
|
||||
factors: [MfaFactor.WebAuthn, MfaFactor.BackupCode],
|
||||
policy: MfaPolicy.Mandatory,
|
||||
},
|
||||
});
|
||||
|
||||
export const resetMfaSettings = async () =>
|
||||
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { demoAppUrl } from '#src/constants.js';
|
||||
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
|
||||
import {
|
||||
enableMandatoryMfaWithWebAuthnAndBackupCode,
|
||||
resetMfaSettings,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import ExpectBackupCodeExperience from '#src/ui-helpers/expect-backup-code-experience.js';
|
||||
import { generateUsername, waitFor } from '#src/utils.js';
|
||||
|
||||
describe('MFA - Backup Code', () => {
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]);
|
||||
await enableMandatoryMfaWithWebAuthnAndBackupCode();
|
||||
await updateSignInExperience({
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
},
|
||||
signIn: {
|
||||
methods: [
|
||||
{
|
||||
identifier: SignInIdentifier.Username,
|
||||
password: true,
|
||||
verificationCode: false,
|
||||
isPasswordPrimary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await resetMfaSettings();
|
||||
});
|
||||
|
||||
const username = generateUsername();
|
||||
const password = 'l0gt0_T3st_P@ssw0rd';
|
||||
|
||||
it('should bind backup codes when registering and verify backup codes when signing in', async () => {
|
||||
const experience = new ExpectBackupCodeExperience(await browser.newPage());
|
||||
await experience.setupVirtualAuthenticator();
|
||||
await experience.startWith(demoAppUrl, 'register');
|
||||
await experience.toFillInput('identifier', username, { submit: true });
|
||||
experience.toBeAt('register/password');
|
||||
await experience.toFillNewPasswords(password);
|
||||
experience.toBeAt('mfa-binding/WebAuthn');
|
||||
await experience.toClick('button', 'Create a passkey');
|
||||
|
||||
// Backup codes page
|
||||
const backupCodes = await experience.retrieveBackupCodes();
|
||||
await experience.toClick('button', 'Continue');
|
||||
|
||||
const userId = await experience.getUserIdFromDemoAppPage();
|
||||
await experience.verifyThenEnd(false);
|
||||
|
||||
// Verify by backup codes
|
||||
await experience.startWith(demoAppUrl, 'sign-in');
|
||||
await experience.toFillForm(
|
||||
{
|
||||
identifier: username,
|
||||
password,
|
||||
},
|
||||
{ submit: true }
|
||||
);
|
||||
// Wait for the page to process submitting request.
|
||||
await waitFor(500);
|
||||
experience.toBeAt('mfa-verification');
|
||||
await experience.toClick('button', 'Backup code');
|
||||
experience.toBeAt('mfa-verification/BackupCode');
|
||||
await experience.toFillInput('code', backupCodes.at(0) ?? '', { submit: true });
|
||||
|
||||
await experience.clearVirtualAuthenticator();
|
||||
await experience.verifyThenEnd();
|
||||
await deleteUser(userId);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
import { cls } from '#src/utils.js';
|
||||
|
||||
import ExpectWebAuthnExperience from './expect-webauthn-experience.js';
|
||||
/**
|
||||
* 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 {
|
||||
constructor(thePage = global.page) {
|
||||
super(thePage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect the page to be at the backup code page and retrieve backup codes.
|
||||
*/
|
||||
async retrieveBackupCodes() {
|
||||
this.toBeAt('mfa-binding/BackupCode');
|
||||
const backupCodesDiv = await expect(this.page).toMatchElement(cls('backupCodes'));
|
||||
const backupCodesSpanList = await backupCodesDiv.$$('span');
|
||||
return Promise.all(
|
||||
backupCodesSpanList.map(async (span) => {
|
||||
return span.evaluate((element) => element.textContent);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ export type ExperiencePath =
|
|||
| `${ExperienceType}/verification-code`
|
||||
| `forgot-password/reset`
|
||||
| `mfa-binding/${MfaFactor}`
|
||||
| 'mfa-verification'
|
||||
| `mfa-verification/${MfaFactor}`;
|
||||
|
||||
export type ExpectExperienceOptions = {
|
||||
|
|
Loading…
Add table
Reference in a new issue