0
Fork 0
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:
Xiao Yijun 2023-10-23 16:20:07 +08:00 committed by GitHub
parent a20e9a2641
commit 02e72ea425
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 350 additions and 3 deletions

View file

@ -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>
</>
)}

View file

@ -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;

View file

@ -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,
};
};

View file

@ -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);
}

View file

@ -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;

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.backupCodeInput {
margin: _.unit(4) 0;
}
.switchFactorLink {
margin-top: _.unit(6);
}

View file

@ -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;

View file

@ -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>;

View file

@ -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: [] } });

View file

@ -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);
});
});

View file

@ -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);
})
);
}
}

View file

@ -21,6 +21,7 @@ export type ExperiencePath =
| `${ExperienceType}/verification-code`
| `forgot-password/reset`
| `mfa-binding/${MfaFactor}`
| 'mfa-verification'
| `mfa-verification/${MfaFactor}`;
export type ExpectExperienceOptions = {