Skip to content

Commit 76f049d

Browse files
authored
feat(experience): setup phone MFA (#7681)
1 parent 7400f14 commit 76f049d

File tree

25 files changed

+259
-92
lines changed

25 files changed

+259
-92
lines changed

packages/experience/src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import IdentifierSignIn from './pages/IdentifierSignIn';
2121
import MfaBinding from './pages/MfaBinding';
2222
import BackupCodeBinding from './pages/MfaBinding/BackupCodeBinding';
2323
import EmailMfaBinding from './pages/MfaBinding/EmailMfaBinding';
24+
import PhoneMfaBinding from './pages/MfaBinding/PhoneMfaBinding';
2425
import TotpBinding from './pages/MfaBinding/TotpBinding';
2526
import WebAuthnBinding from './pages/MfaBinding/WebAuthnBinding';
2627
import MfaVerification from './pages/MfaVerification';
@@ -110,6 +111,12 @@ const App = () => {
110111
element={<EmailMfaBinding />}
111112
/>
112113
)}
114+
{isDevFeaturesEnabled && (
115+
<Route
116+
path={MfaFactor.PhoneVerificationCode}
117+
element={<PhoneMfaBinding />}
118+
/>
119+
)}
113120
</Route>
114121

115122
{/* Mfa verification */}

packages/experience/src/containers/MfaFactorList/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ const MfaFactorList = ({ flow, flowState }: Props) => {
3636
return;
3737
}
3838

39+
if (factor === MfaFactor.PhoneVerificationCode && flow === UserMfaFlow.MfaBinding) {
40+
navigate(`/${flow}/${factor}`, { state: flowState });
41+
return;
42+
}
43+
3944
navigate(`/${flow}/${factor}`, { state: flowState });
4045
},
4146
[flow, flowState, navigate, startTotpBinding, startWebAuthnProcessing]
Lines changed: 12 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,14 @@
1-
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
2-
import { conditional } from '@silverhand/essentials';
3-
import { useCallback, useContext, useState } from 'react';
4-
import { useLocation, useNavigate } from 'react-router-dom';
5-
import { validate } from 'superstruct';
6-
7-
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
8-
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
9-
import { sendVerificationCode } from '@/apis/experience';
10-
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
11-
import useErrorHandler from '@/hooks/use-error-handler';
12-
import useSkipMfa from '@/hooks/use-skip-mfa';
13-
import IdentifierProfileForm from '@/pages/Continue/IdentifierProfileForm';
14-
import ErrorPage from '@/pages/ErrorPage';
15-
import { UserMfaFlow } from '@/types';
16-
import { mfaFlowStateGuard } from '@/types/guard';
17-
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
18-
19-
import styles from './index.module.scss';
20-
21-
const EmailMfaBinding = () => {
22-
const { state } = useLocation();
23-
const [, mfaFlowState] = validate(state, mfaFlowStateGuard);
24-
const { setVerificationId, setIdentifierInputValue } = useContext(UserInteractionContext);
25-
const navigate = useNavigate();
26-
const [errorMessage, setErrorMessage] = useState<string>();
27-
28-
const skipMfa = useSkipMfa();
29-
const handleError = useErrorHandler();
30-
31-
const clearErrorMessage = useCallback(() => {
32-
setErrorMessage('');
33-
}, []);
34-
35-
const handleSubmit = useCallback(
36-
async (_identifier: SignInIdentifier, value: string) => {
37-
const identifier = { type: SignInIdentifier.Email as const, value };
38-
39-
try {
40-
// TODO @wangsijie LOG-11874: Implement the email verification code template
41-
const result = await sendVerificationCode(InteractionEvent.Register, identifier);
42-
43-
setVerificationId(codeVerificationTypeMap[SignInIdentifier.Email], result.verificationId);
44-
setIdentifierInputValue(identifier);
45-
46-
navigate('/continue/verification-code', {
47-
state: {
48-
flow: UserMfaFlow.MfaBinding,
49-
mfaFlowState,
50-
},
51-
});
52-
} catch (error) {
53-
await handleError(error, {
54-
'guard.invalid_input': () => {
55-
setErrorMessage('invalid_email');
56-
},
57-
});
58-
}
59-
},
60-
[handleError, mfaFlowState, navigate, setIdentifierInputValue, setVerificationId]
61-
);
62-
63-
if (!mfaFlowState) {
64-
return <ErrorPage title="error.invalid_session" />;
65-
}
66-
67-
const { skippable, availableFactors } = mfaFlowState;
68-
69-
return (
70-
<SecondaryPageLayout
71-
title="mfa.link_email_verification_code_description"
72-
description="mfa.link_email_2fa_description"
73-
onSkip={conditional(skippable && skipMfa)}
74-
>
75-
<IdentifierProfileForm
76-
autoFocus
77-
errorMessage={errorMessage}
78-
clearErrorMessage={clearErrorMessage}
79-
defaultType={SignInIdentifier.Email}
80-
enabledTypes={[SignInIdentifier.Email]}
81-
onSubmit={handleSubmit}
82-
/>
83-
{availableFactors.length > 1 && (
84-
<SwitchMfaFactorsLink
85-
flow={UserMfaFlow.MfaBinding}
86-
flowState={{ availableFactors, skippable }}
87-
className={styles.switchLink}
88-
/>
89-
)}
90-
</SecondaryPageLayout>
91-
);
92-
};
1+
import { SignInIdentifier } from '@logto/schemas';
2+
3+
import VerificationCodeMfaBinding from '../VerificationCodeMfaBinding';
4+
5+
const EmailMfaBinding = () => (
6+
<VerificationCodeMfaBinding
7+
identifierType={SignInIdentifier.Email}
8+
titleKey="mfa.link_email_verification_code_description"
9+
descriptionKey="mfa.link_email_2fa_description"
10+
invalidInputErrorKey="invalid_email"
11+
/>
12+
);
9313

9414
export default EmailMfaBinding;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { SignInIdentifier } from '@logto/schemas';
2+
3+
import VerificationCodeMfaBinding from '../VerificationCodeMfaBinding';
4+
5+
const PhoneMfaBinding = () => (
6+
<VerificationCodeMfaBinding
7+
identifierType={SignInIdentifier.Phone}
8+
titleKey="mfa.link_phone_verification_code_description"
9+
descriptionKey="mfa.link_phone_2fa_description"
10+
invalidInputErrorKey="invalid_phone"
11+
/>
12+
);
13+
14+
export default PhoneMfaBinding;

packages/experience/src/pages/MfaBinding/EmailMfaBinding/index.module.scss renamed to packages/experience/src/pages/MfaBinding/VerificationCodeMfaBinding/index.module.scss

File renamed without changes.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { InteractionEvent, type SignInIdentifier } from '@logto/schemas';
2+
import { conditional } from '@silverhand/essentials';
3+
import { type TFuncKey } from 'i18next';
4+
import { useCallback, useContext, useState } from 'react';
5+
import { useLocation, useNavigate } from 'react-router-dom';
6+
import { validate } from 'superstruct';
7+
8+
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
9+
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
10+
import { sendVerificationCode } from '@/apis/experience';
11+
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
12+
import useErrorHandler from '@/hooks/use-error-handler';
13+
import useSkipMfa from '@/hooks/use-skip-mfa';
14+
import IdentifierProfileForm from '@/pages/Continue/IdentifierProfileForm';
15+
import ErrorPage from '@/pages/ErrorPage';
16+
import { UserMfaFlow } from '@/types';
17+
import { mfaFlowStateGuard } from '@/types/guard';
18+
import { codeVerificationTypeMap } from '@/utils/sign-in-experience';
19+
20+
import styles from './index.module.scss';
21+
22+
type Props = {
23+
readonly identifierType: SignInIdentifier.Email | SignInIdentifier.Phone;
24+
readonly titleKey: TFuncKey;
25+
readonly descriptionKey: TFuncKey;
26+
readonly invalidInputErrorKey: string;
27+
};
28+
29+
const VerificationCodeMfaBinding = ({
30+
identifierType,
31+
titleKey,
32+
descriptionKey,
33+
invalidInputErrorKey,
34+
}: Props) => {
35+
const { state } = useLocation();
36+
const [, mfaFlowState] = validate(state, mfaFlowStateGuard);
37+
const { setVerificationId, setIdentifierInputValue } = useContext(UserInteractionContext);
38+
const navigate = useNavigate();
39+
const [errorMessage, setErrorMessage] = useState<string>();
40+
41+
const skipMfa = useSkipMfa();
42+
const handleError = useErrorHandler();
43+
44+
const clearErrorMessage = useCallback(() => {
45+
setErrorMessage('');
46+
}, []);
47+
48+
const handleSubmit = useCallback(
49+
async (_identifier: SignInIdentifier, value: string) => {
50+
const identifier = { type: identifierType, value };
51+
52+
try {
53+
const result = await sendVerificationCode(InteractionEvent.Register, identifier);
54+
55+
setVerificationId(codeVerificationTypeMap[identifierType], result.verificationId);
56+
setIdentifierInputValue(identifier);
57+
58+
navigate('/continue/verification-code', {
59+
state: {
60+
flow: UserMfaFlow.MfaBinding,
61+
mfaFlowState,
62+
},
63+
});
64+
} catch (error) {
65+
await handleError(error, {
66+
'guard.invalid_input': () => {
67+
setErrorMessage(invalidInputErrorKey);
68+
},
69+
});
70+
}
71+
},
72+
[
73+
handleError,
74+
identifierType,
75+
invalidInputErrorKey,
76+
mfaFlowState,
77+
navigate,
78+
setIdentifierInputValue,
79+
setVerificationId,
80+
]
81+
);
82+
83+
if (!mfaFlowState) {
84+
return <ErrorPage title="error.invalid_session" />;
85+
}
86+
87+
const { skippable, availableFactors } = mfaFlowState;
88+
89+
return (
90+
<SecondaryPageLayout
91+
title={titleKey}
92+
description={descriptionKey}
93+
onSkip={conditional(skippable && skipMfa)}
94+
>
95+
<IdentifierProfileForm
96+
autoFocus
97+
errorMessage={errorMessage}
98+
clearErrorMessage={clearErrorMessage}
99+
defaultType={identifierType}
100+
enabledTypes={[identifierType]}
101+
onSubmit={handleSubmit}
102+
/>
103+
{availableFactors.length > 1 && (
104+
<SwitchMfaFactorsLink
105+
flow={UserMfaFlow.MfaBinding}
106+
flowState={{ availableFactors, skippable }}
107+
className={styles.switchLink}
108+
/>
109+
)}
110+
</SecondaryPageLayout>
111+
);
112+
};
113+
114+
export default VerificationCodeMfaBinding;

packages/experience/src/types/guard.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const mfaFactorsGuard = s.array(
6565
s.literal(MfaFactor.WebAuthn),
6666
s.literal(MfaFactor.BackupCode),
6767
s.literal(MfaFactor.EmailVerificationCode),
68+
s.literal(MfaFactor.PhoneVerificationCode),
6869
])
6970
);
7071

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { ConnectorType } from '@logto/connector-kit';
2+
import { SignInIdentifier } from '@logto/schemas';
3+
4+
import { deleteUser } from '#src/api/admin-user.js';
5+
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
6+
import { demoAppUrl } from '#src/constants.js';
7+
import { clearConnectorsByTypes, setSmsConnector } from '#src/helpers/connector.js';
8+
import { enableMandatoryMfaWithPhone, resetMfaSettings } from '#src/helpers/sign-in-experience.js';
9+
import { generateNewUser } from '#src/helpers/user.js';
10+
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
11+
import {
12+
devFeatureTest,
13+
generatePhone,
14+
generatePassword,
15+
generateUsername,
16+
waitFor,
17+
} from '#src/utils.js';
18+
19+
devFeatureTest.describe('phone MFA binding', () => {
20+
beforeAll(async () => {
21+
await clearConnectorsByTypes([ConnectorType.Sms]);
22+
await setSmsConnector();
23+
await enableMandatoryMfaWithPhone();
24+
await updateSignInExperience({
25+
signUp: {
26+
identifiers: [SignInIdentifier.Username],
27+
password: true,
28+
verify: false,
29+
},
30+
signIn: {
31+
methods: [
32+
{
33+
identifier: SignInIdentifier.Username,
34+
password: true,
35+
verificationCode: false,
36+
isPasswordPrimary: true,
37+
},
38+
],
39+
},
40+
forgotPasswordMethods: [],
41+
});
42+
});
43+
44+
afterAll(async () => {
45+
await clearConnectorsByTypes([ConnectorType.Sms]);
46+
await resetMfaSettings();
47+
});
48+
49+
it('should bind phone MFA on register', async () => {
50+
const username = generateUsername();
51+
const password = generatePassword();
52+
const phone = generatePhone();
53+
const experience = new ExpectExperience(await browser.newPage());
54+
await experience.startWith(demoAppUrl, 'register');
55+
await experience.toFillInput('identifier', username, { submit: true });
56+
experience.toBeAt('register/password');
57+
await experience.toFillNewPasswords(password);
58+
59+
await waitFor(500);
60+
experience.toBeAt('mfa-binding/PhoneVerificationCode');
61+
await experience.page.waitForSelector('input[name="identifier"]');
62+
await experience.toFillInput('identifier', phone, { submit: true });
63+
await experience.toCompleteVerification('continue', ConnectorType.Sms);
64+
await experience.verifyThenEnd();
65+
});
66+
67+
it('should bind phone MFA on sign in', async () => {
68+
const { userProfile, user } = await generateNewUser({ username: true, password: true });
69+
const phone = generatePhone();
70+
const experience = new ExpectExperience(await browser.newPage());
71+
await experience.startWith(demoAppUrl, 'sign-in');
72+
await experience.toFillForm(
73+
{
74+
identifier: userProfile.username,
75+
password: userProfile.password,
76+
},
77+
{ submit: true }
78+
);
79+
80+
await waitFor(500);
81+
experience.toBeAt('mfa-binding/PhoneVerificationCode');
82+
await experience.page.waitForSelector('input[name="identifier"]');
83+
await experience.toFillInput('identifier', phone, { submit: true });
84+
await experience.toCompleteVerification('continue', ConnectorType.Sms);
85+
await experience.verifyThenEnd();
86+
87+
await deleteUser(user.id);
88+
});
89+
});

packages/phrases-experience/src/locales/ar/mfa.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const mfa = {
1010
link_email_verification_code_description: 'ربط عنوان بريدك الإلكتروني',
1111
link_email_2fa_description: 'ربط عنوان بريدك الإلكتروني للتحقق بخطوتين',
1212
link_phone_verification_code_description: 'ربط رقم هاتفك',
13+
link_phone_2fa_description: 'ربط رقم هاتفك للتحقق بخطوتين',
1314
verify_totp_description: 'أدخل الرمز المرة الواحدة في التطبيق',
1415
verify_webauthn_description: 'تحقق من جهازك أو جهاز USB الخاص بك',
1516
verify_backup_code_description: 'الصق رمز النسخ الاحتياطي الذي حفظته',

packages/phrases-experience/src/locales/de/mfa.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const mfa = {
1010
link_email_verification_code_description: 'Verknüpfen Sie Ihre E-Mail-Adresse',
1111
link_email_2fa_description: 'Verknüpfen Sie Ihre E-Mail-Adresse für 2-Schritt-Verifizierung',
1212
link_phone_verification_code_description: 'Verknüpfen Sie Ihre Telefonnummer',
13+
link_phone_2fa_description: 'Verknüpfen Sie Ihre Telefonnummer für 2-Schritt-Verifizierung',
1314
verify_totp_description: 'Geben Sie den Einmalcode in der App ein',
1415
verify_webauthn_description: 'Verifizieren Sie Ihr Gerät oder Ihre USB-Hardware',
1516
verify_backup_code_description: 'Fügen Sie den gespeicherten Backup-Code ein',

0 commit comments

Comments
 (0)