mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,console): new mfa prompt policy
This commit is contained in:
parent
2178589507
commit
a38031caf8
20 changed files with 289 additions and 67 deletions
19
.changeset/hot-oranges-join.md
Normal file
19
.changeset/hot-oranges-join.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
"@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:
|
||||
1. MfaPolicy.Mandatory: MFA is required for all users
|
||||
2. MfaPolicy.NoPrompt: Do not ask users to set up MFA - new
|
||||
3. MfaPolicy.PromptAtSignInAndSignUp: Ask users to set up MFA during registration (skippable, one-time prompt) - new
|
||||
4. MfaPolicy.PromptOnlyAtSignIn: Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt) - the old policy
|
||||
|
||||
`MfaPolicy.UserControlled` is deprecated, use `MfaPolicy.PromptAtSignInAndSignUp` instead.
|
|
@ -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,21 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
|||
return factors.length === 0;
|
||||
}, [formValues, isMfaDisabled]);
|
||||
|
||||
const mfaPolicyOptions = [
|
||||
{
|
||||
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'),
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
trySubmitSafe(async (formData) => {
|
||||
const mfaConfig = convertMfaFormToConfig(formData);
|
||||
|
@ -143,28 +157,37 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
|||
/>
|
||||
)}
|
||||
</FormCard>
|
||||
<FormCard title="mfa.policy">
|
||||
<FormField title="mfa.two_step_sign_in_policy" headlineSpacing="large">
|
||||
<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="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}
|
||||
name="setUpPrompt"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
options={mfaPolicyOptions}
|
||||
isReadOnly={isPolicySettingsDisabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</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