mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core,console): new mfa prompt policy (#6880)
This commit is contained in:
parent
bbbfd01d7b
commit
f1b1d9e95a
20 changed files with 297 additions and 67 deletions
24
.changeset/hot-oranges-join.md
Normal file
24
.changeset/hot-oranges-join.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
"@logto/experience-legacy": minor
|
||||
"@logto/integration-tests": minor
|
||||
"@logto/experience": minor
|
||||
"@logto/console": minor
|
||||
"@logto/phrases": minor
|
||||
"@logto/schemas": minor
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
new MFA prompt policy
|
||||
|
||||
You can now cutomize the MFA prompt policy in the Console.
|
||||
|
||||
First, choose if you want to enable **Require MFA**:
|
||||
|
||||
- **Enable**: Users will be prompted to set up MFA during the sign-in process which cannot be skipped. If the user fails to set up MFA or deletes their MFA settings, they will be locked out of their account until they set up MFA again.
|
||||
- **Disable**: Users can skip the MFA setup process during sign-up flow.
|
||||
|
||||
If you choose to **Disable**, you can choose the MFA setup prompt:
|
||||
|
||||
- Do not ask users to set up MFA.
|
||||
- Ask users to set up MFA during registration (skippable, one-time prompt). **The same prompt as previous policy (UserControlled)**
|
||||
- Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt)
|
|
@ -1,7 +0,0 @@
|
|||
import { type AdminConsoleKey } from '@logto/phrases';
|
||||
import { MfaPolicy } from '@logto/schemas';
|
||||
|
||||
export const policyOptionTitleMap: Record<MfaPolicy, AdminConsoleKey> = {
|
||||
[MfaPolicy.UserControlled]: 'mfa.user_controlled',
|
||||
[MfaPolicy.Mandatory]: 'mfa.mandatory',
|
||||
};
|
|
@ -13,7 +13,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
|||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
|
||||
import Select from '@/ds-components/Select';
|
||||
import Switch from '@/ds-components/Switch';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
|
@ -24,7 +24,6 @@ import { type MfaConfigForm, type MfaConfig } from '../types';
|
|||
|
||||
import FactorLabel from './FactorLabel';
|
||||
import UpsellNotice from './UpsellNotice';
|
||||
import { policyOptionTitleMap } from './constants';
|
||||
import styles from './index.module.scss';
|
||||
import { convertMfaFormToConfig, convertMfaConfigToForm, validateBackupCodeFactor } from './utils';
|
||||
|
||||
|
@ -69,6 +68,24 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
|||
return factors.length === 0;
|
||||
}, [formValues, isMfaDisabled]);
|
||||
|
||||
const mfaPolicyOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: MfaPolicy.NoPrompt,
|
||||
title: t('mfa.no_prompt'),
|
||||
},
|
||||
{
|
||||
value: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
title: t('mfa.prompt_at_sign_in_and_sign_up'),
|
||||
},
|
||||
{
|
||||
value: MfaPolicy.PromptOnlyAtSignIn,
|
||||
title: t('mfa.prompt_only_at_sign_in'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (formData) => {
|
||||
const mfaConfig = convertMfaFormToConfig(formData);
|
||||
|
@ -143,28 +160,37 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
|||
/>
|
||||
)}
|
||||
</FormCard>
|
||||
<FormCard title="mfa.policy">
|
||||
<FormField title="mfa.two_step_sign_in_policy" headlineSpacing="large">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policy"
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<RadioGroup name={name} value={value} onChange={onChange}>
|
||||
{Object.values(MfaPolicy).map((policy) => {
|
||||
const title = policyOptionTitleMap[policy];
|
||||
return (
|
||||
<Radio
|
||||
key={policy}
|
||||
isDisabled={isPolicySettingsDisabled}
|
||||
title={title}
|
||||
value={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
<FormCard
|
||||
title="mfa.policy"
|
||||
description="mfa.policy_description"
|
||||
learnMoreLink={{
|
||||
href: getDocumentationUrl('/docs/recipes/multi-factor-auth/configure-mfa'),
|
||||
targetBlank: 'noopener',
|
||||
}}
|
||||
>
|
||||
<FormField title="mfa.require_mfa" headlineSpacing="large">
|
||||
<Switch
|
||||
disabled={isPolicySettingsDisabled}
|
||||
label={t('mfa.require_mfa_label')}
|
||||
{...register('isMandatory')}
|
||||
/>
|
||||
</FormField>
|
||||
{!formValues.isMandatory && (
|
||||
<FormField title="mfa.set_up_prompt" headlineSpacing="large">
|
||||
<Controller
|
||||
control={control}
|
||||
name="setUpPrompt"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
options={mfaPolicyOptions}
|
||||
isReadOnly={isPolicySettingsDisabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</FormCard>
|
||||
</DetailsForm>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
|
||||
import { type MfaConfig, type MfaConfigForm } from '../types';
|
||||
import { type SignInPrompt, type MfaConfig, type MfaConfigForm } from '../types';
|
||||
|
||||
const isSignInPrompt = (policy: MfaPolicy): policy is SignInPrompt =>
|
||||
[MfaPolicy.NoPrompt, MfaPolicy.PromptAtSignInAndSignUp, MfaPolicy.PromptOnlyAtSignIn].includes(
|
||||
policy
|
||||
);
|
||||
|
||||
export const convertMfaConfigToForm = ({ policy, factors }: MfaConfig): MfaConfigForm => ({
|
||||
policy,
|
||||
isMandatory: policy === MfaPolicy.Mandatory,
|
||||
setUpPrompt: isSignInPrompt(policy) ? policy : MfaPolicy.PromptAtSignInAndSignUp,
|
||||
totpEnabled: factors.includes(MfaFactor.TOTP),
|
||||
webAuthnEnabled: factors.includes(MfaFactor.WebAuthn),
|
||||
backupCodeEnabled: factors.includes(MfaFactor.BackupCode),
|
||||
});
|
||||
|
||||
export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig => {
|
||||
const { policy, totpEnabled, webAuthnEnabled, backupCodeEnabled } = mfaConfigForm;
|
||||
const { isMandatory, setUpPrompt, totpEnabled, webAuthnEnabled, backupCodeEnabled } =
|
||||
mfaConfigForm;
|
||||
|
||||
const factors = [
|
||||
conditional(totpEnabled && MfaFactor.TOTP),
|
||||
|
@ -21,7 +28,7 @@ export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig
|
|||
].filter((factor): factor is MfaFactor => Boolean(factor));
|
||||
|
||||
return {
|
||||
policy,
|
||||
policy: isMandatory ? MfaPolicy.Mandatory : setUpPrompt,
|
||||
factors,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { type MfaPolicy, type SignInExperience } from '@logto/schemas';
|
||||
|
||||
export type MfaConfig = SignInExperience['mfa'];
|
||||
export type SignInPrompt = Exclude<MfaPolicy, MfaPolicy.UserControlled | MfaPolicy.Mandatory>;
|
||||
|
||||
export type MfaConfigForm = {
|
||||
policy: MfaPolicy;
|
||||
totpEnabled: boolean;
|
||||
webAuthnEnabled: boolean;
|
||||
backupCodeEnabled: boolean;
|
||||
isMandatory: boolean;
|
||||
setUpPrompt: SignInPrompt;
|
||||
};
|
||||
|
|
|
@ -95,7 +95,7 @@ export const mockSignInExperience: SignInExperience = {
|
|||
customUiAssets: null,
|
||||
passwordPolicy: {},
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
singleSignOnEnabled: true,
|
||||
|
|
|
@ -10,7 +10,7 @@ describe('validate mfa', () => {
|
|||
expect(() => {
|
||||
validateMfa({
|
||||
factors: [],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
@ -19,7 +19,7 @@ describe('validate mfa', () => {
|
|||
expect(() => {
|
||||
validateMfa({
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
@ -28,7 +28,7 @@ describe('validate mfa', () => {
|
|||
expect(() => {
|
||||
validateMfa({
|
||||
factors: [MfaFactor.TOTP, MfaFactor.BackupCode],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ describe('validate mfa', () => {
|
|||
expect(() => {
|
||||
validateMfa({
|
||||
factors: [MfaFactor.BackupCode],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
});
|
||||
}).toMatchError(new RequestError('sign_in_experiences.backup_code_cannot_be_enabled_alone'));
|
||||
});
|
||||
|
@ -47,7 +47,7 @@ describe('validate mfa', () => {
|
|||
expect(() => {
|
||||
validateMfa({
|
||||
factors: [MfaFactor.TOTP, MfaFactor.TOTP],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
});
|
||||
}).toMatchError(new RequestError('sign_in_experiences.duplicated_mfa_factors'));
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
bindTotpGuard,
|
||||
type BindWebAuthn,
|
||||
bindWebAuthnGuard,
|
||||
InteractionEvent,
|
||||
type JsonObject,
|
||||
MfaFactor,
|
||||
MfaPolicy,
|
||||
|
@ -152,7 +153,7 @@ export class Mfa {
|
|||
const { policy } = await this.signInExperienceValidator.getMfaSettings();
|
||||
|
||||
assertThat(
|
||||
policy === MfaPolicy.UserControlled,
|
||||
policy !== MfaPolicy.Mandatory,
|
||||
new RequestError({
|
||||
code: 'session.mfa.mfa_policy_not_user_controlled',
|
||||
status: 422,
|
||||
|
@ -278,9 +279,21 @@ export class Mfa {
|
|||
|
||||
const { mfaVerifications, logtoConfig } = await this.interactionContext.getIdentifiedUser();
|
||||
|
||||
// If the policy is user controlled and the user has skipped MFA, then there is nothing to check
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
if ((policy === MfaPolicy.UserControlled && this.#mfaSkipped) || isMfaSkipped(logtoConfig)) {
|
||||
// If the policy is no prompt, then there is nothing to check
|
||||
if (policy === MfaPolicy.NoPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the policy is not mandatory and the user has skipped MFA, then there is nothing to check
|
||||
if ((policy !== MfaPolicy.Mandatory && this.#mfaSkipped) ?? isMfaSkipped(logtoConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the policy is prompt only at sign-in, and the event is register, skip check
|
||||
if (
|
||||
this.interactionContext.getInteractionEvent() === InteractionEvent.Register &&
|
||||
policy === MfaPolicy.PromptOnlyAtSignIn
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -171,7 +171,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
|||
} = ctx;
|
||||
|
||||
assertThat(
|
||||
policy === MfaPolicy.UserControlled,
|
||||
policy !== MfaPolicy.Mandatory,
|
||||
new RequestError({
|
||||
code: 'session.mfa.mfa_policy_not_user_controlled',
|
||||
status: 422,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
import { PasswordPolicyChecker } from '@logto/core-kit';
|
||||
import type { SignInExperience } from '@logto/schemas';
|
||||
import { MfaPolicy, type SignInExperience } from '@logto/schemas';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
|
||||
|
@ -24,7 +24,17 @@ export default function koaInteractionSie<StateT, ContextT extends IRouterParamC
|
|||
return async (ctx, next) => {
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
|
||||
ctx.signInExperience = signInExperience;
|
||||
ctx.signInExperience = {
|
||||
...signInExperience,
|
||||
mfa: {
|
||||
...signInExperience.mfa,
|
||||
policy:
|
||||
// Fallback deprecated UserControlled policy to PromptAtSignInAndSignUp
|
||||
signInExperience.mfa.policy === MfaPolicy.UserControlled
|
||||
? MfaPolicy.PromptAtSignInAndSignUp
|
||||
: signInExperience.mfa.policy,
|
||||
},
|
||||
};
|
||||
ctx.passwordPolicyChecker = new PasswordPolicyChecker(
|
||||
signInExperience.passwordPolicy,
|
||||
crypto.subtle
|
||||
|
|
|
@ -54,7 +54,7 @@ const baseCtx = {
|
|||
...mockSignInExperience,
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
},
|
||||
},
|
||||
passwordPolicyChecker: new PasswordPolicyChecker(
|
||||
|
|
|
@ -55,7 +55,7 @@ const baseCtx = {
|
|||
...mockSignInExperience,
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
},
|
||||
},
|
||||
passwordPolicyChecker: new PasswordPolicyChecker(
|
||||
|
@ -75,6 +75,17 @@ const mfaRequiredCtx = {
|
|||
},
|
||||
};
|
||||
|
||||
const promptOnlyAtSignInCtx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.PromptOnlyAtSignIn,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mfaRequiredTotpOnlyCtx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
|
@ -92,7 +103,7 @@ const allFactorsEnabledCtx = {
|
|||
...mockSignInExperience,
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -142,7 +153,7 @@ describe('validateMandatoryBindMfa', () => {
|
|||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('bindMfa missing and not required should throw (for skip)', async () => {
|
||||
it('bindMfa missing and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
|
||||
).rejects.toMatchError(
|
||||
|
@ -156,7 +167,13 @@ describe('validateMandatoryBindMfa', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('bindMfa missing and not required, marked as skipped should pass', async () => {
|
||||
it('bindMfas missing and policy is PromptOnlyAtSignIn should pass', async () => {
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, promptOnlyAtSignInCtx, interaction)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('bindMfa missing and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, baseCtx, {
|
||||
...interaction,
|
||||
|
@ -182,7 +199,7 @@ describe('validateMandatoryBindMfa', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('user mfaVerifications and bindMfa missing, and not required should throw (for skip)', async () => {
|
||||
it('user mfaVerifications and bindMfa missing, and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUser);
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
||||
|
@ -197,6 +214,21 @@ describe('validateMandatoryBindMfa', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('user mfaVerifications and bindMfa missing, and policy is PromptOnlyAtSignIn should throw (for skip)', async () => {
|
||||
findUserById.mockResolvedValueOnce(mockUser);
|
||||
await expect(
|
||||
validateMandatoryBindMfa(tenantContext, promptOnlyAtSignInCtx, signInInteraction)
|
||||
).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
},
|
||||
{ availableFactors: [MfaFactor.TOTP], skippable: true }
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('user mfaVerifications and bindMfa missing, mark skipped, and not required should pass', async () => {
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable complexity */
|
||||
import {
|
||||
InteractionEvent,
|
||||
MfaFactor,
|
||||
|
@ -147,13 +148,19 @@ export const validateMandatoryBindMfa = async (
|
|||
const { event, bindMfas } = interaction;
|
||||
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
|
||||
|
||||
// No available MFA, skip check
|
||||
if (availableFactors.length === 0) {
|
||||
// No available MFA, or no prompt policy, skip check
|
||||
if (availableFactors.length === 0 || policy === MfaPolicy.NoPrompt) {
|
||||
return interaction;
|
||||
}
|
||||
|
||||
// If the policy is not mandatory and the user has skipped MFA, skip check
|
||||
const { mfaSkipped } = interaction;
|
||||
if (policy === MfaPolicy.UserControlled && mfaSkipped) {
|
||||
if (policy !== MfaPolicy.Mandatory && mfaSkipped) {
|
||||
return interaction;
|
||||
}
|
||||
|
||||
// If the policy is prompt only at sign-in, and the event is register, skip check
|
||||
if (interaction.event === InteractionEvent.Register && policy === MfaPolicy.PromptOnlyAtSignIn) {
|
||||
return interaction;
|
||||
}
|
||||
|
||||
|
@ -268,3 +275,4 @@ export const validateBindMfaBackupCode = async (
|
|||
{ codes }
|
||||
);
|
||||
};
|
||||
/* eslint-enable complexity */
|
||||
|
|
|
@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
|
|||
customUiAssets: null,
|
||||
passwordPolicy: {},
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
singleSignOnEnabled: true,
|
||||
|
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
|||
customUiAssets: null,
|
||||
passwordPolicy: {},
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
isDevelopmentTenant: false,
|
||||
|
|
|
@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
|
|||
customUiAssets: null,
|
||||
passwordPolicy: {},
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
singleSignOnEnabled: true,
|
||||
|
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
|||
customUiAssets: null,
|
||||
passwordPolicy: {},
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
isDevelopmentTenant: false,
|
||||
|
|
|
@ -81,7 +81,7 @@ export const enableAllPasswordSignInMethods = async (
|
|||
signIn: {
|
||||
methods: defaultPasswordSignInMethods,
|
||||
},
|
||||
mfa: { factors: [], policy: MfaPolicy.UserControlled },
|
||||
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
|
||||
});
|
||||
|
||||
export const resetPasswordPolicy = async () =>
|
||||
|
@ -102,14 +102,30 @@ export const enableAllVerificationCodeSignInMethods = async (
|
|||
signIn: {
|
||||
methods: defaultVerificationCodeSignInMethods,
|
||||
},
|
||||
mfa: { factors: [], policy: MfaPolicy.UserControlled },
|
||||
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
|
||||
});
|
||||
|
||||
export const enableUserControlledMfaWithTotp = async () =>
|
||||
updateSignInExperience({
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
},
|
||||
});
|
||||
|
||||
export const enableUserControlledMfaWithTotpOnlyAtSignIn = async () =>
|
||||
updateSignInExperience({
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.PromptOnlyAtSignIn,
|
||||
},
|
||||
});
|
||||
|
||||
export const enableUserControlledMfaWithNoPrompt = async () =>
|
||||
updateSignInExperience({
|
||||
mfa: {
|
||||
factors: [MfaFactor.TOTP],
|
||||
policy: MfaPolicy.NoPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -146,7 +162,7 @@ export const enableMandatoryMfaWithWebAuthnAndBackupCode = async () =>
|
|||
});
|
||||
|
||||
export const resetMfaSettings = async () =>
|
||||
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
|
||||
updateSignInExperience({ mfa: { policy: MfaPolicy.PromptAtSignInAndSignUp, factors: [] } });
|
||||
|
||||
/** Enable only username and password sign-in and sign-up. */
|
||||
export const setUsernamePasswordOnly = async () => {
|
||||
|
|
|
@ -16,7 +16,9 @@ import {
|
|||
enableAllPasswordSignInMethods,
|
||||
enableMandatoryMfaWithTotp,
|
||||
enableMandatoryMfaWithTotpAndBackupCode,
|
||||
enableUserControlledMfaWithNoPrompt,
|
||||
enableUserControlledMfaWithTotp,
|
||||
enableUserControlledMfaWithTotpOnlyAtSignIn,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
|
||||
|
@ -122,7 +124,7 @@ describe('Bind MFA APIs happy path', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('user controlled TOTP', () => {
|
||||
describe('TOTP with policy PromptAtSignInAndSignUp', () => {
|
||||
beforeAll(async () => {
|
||||
await enableUserControlledMfaWithTotp();
|
||||
});
|
||||
|
@ -183,6 +185,86 @@ describe('Bind MFA APIs happy path', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('TOTP with policy PromptOnlyAtSignIn', () => {
|
||||
beforeAll(async () => {
|
||||
await enableUserControlledMfaWithTotpOnlyAtSignIn();
|
||||
});
|
||||
|
||||
it('should able to register without MFA', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
|
||||
const { verificationId } = await client.createNewPasswordIdentityVerification({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await client.identifyUser({ verificationId });
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should able to skip MFA binding on sign-in', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
await userApi.create({ username, password });
|
||||
|
||||
const client = await initExperienceClient();
|
||||
await identifyUserWithUsernamePassword(client, username, password);
|
||||
|
||||
await expectRejects(client.submitInteraction(), {
|
||||
code: 'user.missing_mfa',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
await client.skipMfaBinding();
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOTP with policy NoPrompt', () => {
|
||||
beforeAll(async () => {
|
||||
await enableUserControlledMfaWithNoPrompt();
|
||||
});
|
||||
|
||||
it('should able to register without MFA', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
|
||||
const { verificationId } = await client.createNewPasswordIdentityVerification({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await client.identifyUser({ verificationId });
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should able to sign-in without MFA', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
await userApi.create({ username, password });
|
||||
|
||||
const client = await initExperienceClient();
|
||||
await identifyUserWithUsernamePassword(client, username, password);
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mandatory TOTP with backup codes', () => {
|
||||
beforeAll(async () => {
|
||||
await enableMandatoryMfaWithTotpAndBackupCode();
|
||||
|
|
|
@ -32,7 +32,7 @@ describe('admin console sign-in experience', () => {
|
|||
termsOfUseUrl: 'mock://fake-url/terms',
|
||||
privacyPolicyUrl: 'mock://fake-url/privacy',
|
||||
mfa: {
|
||||
policy: MfaPolicy.UserControlled,
|
||||
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||
factors: [],
|
||||
},
|
||||
singleSignOnEnabled: true,
|
||||
|
|
|
@ -28,6 +28,15 @@ const mfa = {
|
|||
mandatory: 'Users are always required to use MFA at sign-in',
|
||||
mandatory_tip:
|
||||
'Users must set up MFA the first time at sign-in or sign-up, and use it for all future sign-ins.',
|
||||
require_mfa: 'Require MFA',
|
||||
require_mfa_label:
|
||||
'Enable this to make 2-step verification mandatory for accessing your applications. If disabled, users can decide whether to enable MFA for themselves.',
|
||||
set_up_prompt: 'MFA set-up prompt',
|
||||
no_prompt: 'Do not ask users to set up MFA',
|
||||
prompt_at_sign_in_and_sign_up:
|
||||
'Ask users to set up MFA during registration (skippable, one-time prompt)',
|
||||
prompt_only_at_sign_in:
|
||||
'Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt)',
|
||||
};
|
||||
|
||||
export default Object.freeze(mfa);
|
||||
|
|
|
@ -105,8 +105,16 @@ export const mfaFactorsGuard = z.nativeEnum(MfaFactor).array();
|
|||
export type MfaFactors = z.infer<typeof mfaFactorsGuard>;
|
||||
|
||||
export enum MfaPolicy {
|
||||
/** @deprecated, use `PromptAtSignInAndSignUp` instead */
|
||||
UserControlled = 'UserControlled',
|
||||
/** MFA is required for all users */
|
||||
Mandatory = 'Mandatory',
|
||||
/** Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt) */
|
||||
PromptOnlyAtSignIn = 'PromptOnlyAtSignIn',
|
||||
/** Ask users to set up MFA during registration (skippable, one-time prompt) */
|
||||
PromptAtSignInAndSignUp = 'PromptAtSignInAndSignUp',
|
||||
/** Do not ask users to set up MFA */
|
||||
NoPrompt = 'NoPrompt',
|
||||
}
|
||||
|
||||
export const mfaGuard = z.object({
|
||||
|
|
Loading…
Reference in a new issue