0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,phrases): disable auto skip mfa (#4786)

* feat(core,phrases): disable auto skip mfa

* refactor(experience): skip mfa manually (#4788)

---------

Co-authored-by: Xiao Yijun <xiaoyijun@silverhand.io>
This commit is contained in:
wangsijie 2023-11-02 15:09:00 +08:00 committed by GitHub
parent 1c79cde885
commit ddf4468189
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 367 additions and 211 deletions

View file

@ -12,6 +12,7 @@ import type {
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
} from '../types/index.js';
import { userMfaDataKey } from '../verifications/mfa-verification.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
@ -169,6 +170,29 @@ describe('submit action', () => {
});
});
it('register with mfaSkipped', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
mfaSkipped: true,
};
await submitInteraction(interaction, ctx, tenant);
expect(insertUser).toBeCalledWith(
{
id: 'uid',
...upsertProfile,
customData: {
[userMfaDataKey]: {
skipped: true,
},
},
},
['user']
);
});
it('register new social user', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
@ -301,6 +325,37 @@ describe('submit action', () => {
});
});
it('sign-in with mfaSkipped', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
profile: { connectorId: 'logto', password: 'password' },
identifiers,
mfaSkipped: true,
};
await submitInteraction(interaction, ctx, tenant);
expect(updateUserById).toBeCalledWith('foo', {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
identities: {
logto: { userId: userInfo.id, details: userInfo },
google: { userId: 'googleId', details: {} },
},
lastSignInAt: now,
customData: {
[userMfaDataKey]: {
skipped: true,
},
},
});
});
it('sign-in and sync new Social', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },

View file

@ -81,99 +81,133 @@ const getInitialUserRoles = (
isCreatingFirstAdminUser && isCloud && getManagementApiAdminName(adminTenantId)
);
async function handleSubmitRegister(
interaction: VerifiedRegisterInteractionResult,
ctx: WithLogContext & WithInteractionDetailsContext & WithInteractionHooksContext,
tenantContext: TenantContext,
log?: LogEntry
) {
const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext;
const { hasActiveUsers } = queries.users;
const { updateDefaultSignInExperience } = queries.signInExperiences;
const {
users: { generateUserId, insertUser },
} = libraries;
const { pendingAccountId, mfaSkipped } = interaction;
const id = pendingAccountId ?? (await generateUserId());
const userProfile = await parseUserProfile(tenantContext, interaction);
const mfaVerifications = parseBindMfas(interaction);
const { client_id } = ctx.interactionDetails.params;
const { isCloud } = EnvSet.values;
const isInAdminTenant = (await getTenantId(ctx.URL)) === adminTenantId;
const isCreatingFirstAdminUser =
isInAdminTenant && String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers());
await insertUser(
{
id,
...userProfile,
...conditional(
mfaVerifications.length > 0 && {
mfaVerifications,
}
),
...conditional(
mfaSkipped && {
customData: {
[userMfaDataKey]: {
skipped: true,
},
},
}
),
},
getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud)
);
// In OSS, we need to limit sign-in experience to "sign-in only" once
// the first admin has been create since we don't want other unexpected registrations
if (isCreatingFirstAdminUser) {
await updateDefaultSignInExperience({
signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn,
});
}
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
ctx.assignInteractionHookResult({ userId: id });
log?.append({ userId: id });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
consoleLog.warn('Failed to post affiliate logs', error);
void appInsights.trackException(error);
});
}
async function handleSubmitSignIn(
interaction: VerifiedSignInInteractionResult,
ctx: WithLogContext & WithInteractionDetailsContext & WithInteractionHooksContext,
tenantContext: TenantContext,
log?: LogEntry
) {
const { provider, queries } = tenantContext;
const { findUserById, updateUserById } = queries.users;
const { accountId } = interaction;
log?.append({ userId: accountId });
const user = await findUserById(accountId);
const updateUserProfile = await parseUserProfile(tenantContext, interaction, user);
const mfaVerifications = parseBindMfas(interaction);
const { mfaSkipped } = interaction;
await updateUserById(accountId, {
...updateUserProfile,
...conditional(
mfaVerifications.length > 0 && {
mfaVerifications: [...user.mfaVerifications, ...mfaVerifications],
}
),
...conditional(
mfaSkipped && {
customData: {
...user.customData,
[userMfaDataKey]: {
skipped: true,
},
},
}
),
});
await assignInteractionResults(ctx, provider, { login: { accountId } });
ctx.assignInteractionHookResult({ userId: accountId });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) });
}
export default async function submitInteraction(
interaction: VerifiedInteractionResult,
ctx: WithLogContext & WithInteractionDetailsContext & WithInteractionHooksContext,
tenantContext: TenantContext,
log?: LogEntry
) {
const { provider, libraries, queries, cloudConnection, id: tenantId } = tenantContext;
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
const { updateDefaultSignInExperience } = queries.signInExperiences;
const {
users: { generateUserId, insertUser },
} = libraries;
const { provider, queries } = tenantContext;
const { updateUserById } = queries.users;
const { event, profile } = interaction;
if (event === InteractionEvent.Register) {
const { pendingAccountId, mfaSkipped } = interaction;
const id = pendingAccountId ?? (await generateUserId());
const userProfile = await parseUserProfile(tenantContext, interaction);
const mfaVerifications = parseBindMfas(interaction);
const { client_id } = ctx.interactionDetails.params;
const { isCloud } = EnvSet.values;
const isInAdminTenant = (await getTenantId(ctx.URL)) === adminTenantId;
const isCreatingFirstAdminUser =
isInAdminTenant &&
String(client_id) === adminConsoleApplicationId &&
!(await hasActiveUsers());
await insertUser(
{
id,
...userProfile,
...conditional(
mfaVerifications.length > 0 && {
mfaVerifications,
}
),
...conditional(
mfaSkipped && {
[userMfaDataKey]: {
skipped: true,
},
}
),
},
getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud)
);
// In OSS, we need to limit sign-in experience to "sign-in only" once
// the first admin has been create since we don't want other unexpected registrations
if (isCreatingFirstAdminUser) {
await updateDefaultSignInExperience({
signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn,
});
}
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
ctx.assignInteractionHookResult({ userId: id });
log?.append({ userId: id });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });
void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
consoleLog.warn('Failed to post affiliate logs', error);
void appInsights.trackException(error);
});
return;
return handleSubmitRegister(interaction, ctx, tenantContext, log);
}
const { accountId } = interaction;
log?.append({ userId: accountId });
if (event === InteractionEvent.SignIn) {
const user = await findUserById(accountId);
const updateUserProfile = await parseUserProfile(tenantContext, interaction, user);
const mfaVerifications = parseBindMfas(interaction);
await updateUserById(accountId, {
...updateUserProfile,
...conditional(
mfaVerifications.length > 0 && {
mfaVerifications: [...user.mfaVerifications, ...mfaVerifications],
}
),
});
await assignInteractionResults(ctx, provider, { login: { accountId } });
ctx.assignInteractionHookResult({ userId: accountId });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) });
return;
return handleSubmitSignIn(interaction, ctx, tenantContext, log);
}
// Forgot Password

View file

@ -1,4 +1,4 @@
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
import { demoAppApplicationId, InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockBackupCodeBind, mockTotpBind } from '#src/__mocks__/mfa-verification.js';
@ -66,12 +66,13 @@ const baseProviderMock = {
};
const updateUserById = jest.fn();
const findDefaultSignInExperience = jest.fn().mockResolvedValue(mockSignInExperience);
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
{
signInExperiences: {
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
findDefaultSignInExperience,
},
users: {
findUserById: jest.fn().mockResolvedValue(mockUserWithMfaVerifications),
@ -248,4 +249,46 @@ describe('interaction routes (MFA verification)', () => {
);
});
});
describe('PUT /interaction/mfa-skipped', () => {
afterEach(() => {
findDefaultSignInExperience.mockResolvedValue(mockSignInExperience);
});
const path = `${interactionPrefix}/mfa-skipped`;
it('should throw if is in mandatory mode', async () => {
findDefaultSignInExperience.mockResolvedValue({
...mockSignInExperience,
mfa: {
policy: MfaPolicy.Mandatory,
},
});
const response = await sessionRequest.put(path).send({
mfaSkipped: true,
});
expect(response.status).toEqual(422);
});
it('should update interaction', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.Register,
});
const response = await sessionRequest.put(path).send({
mfaSkipped: true,
});
expect(response.status).toEqual(204);
expect(storeInteractionResult).toBeCalledWith(
{
mfaSkipped: true,
},
expect.anything(),
expect.anything(),
expect.anything()
);
});
});
});

View file

@ -1,11 +1,13 @@
import {
InteractionEvent,
MfaFactor,
MfaPolicy,
bindMfaPayloadGuard,
verifyMfaPayloadGuard,
} from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
@ -149,4 +151,38 @@ export default function mfaRoutes<T extends IRouterParamContext>(
return next();
}
);
// Update MFA skip
router.put(
`${interactionPrefix}/mfa-skipped`,
koaGuard({
body: z.object({
// Only allow to skip MFA binding
mfaSkipped: z.literal(true),
}),
status: [204, 400, 422],
}),
koaInteractionSie(queries),
async (ctx, next) => {
const {
signInExperience: {
mfa: { policy },
},
} = ctx;
assertThat(
policy === MfaPolicy.UserControlled,
new RequestError({
code: 'session.mfa.mfa_policy_not_user_controlled',
status: 422,
})
);
await storeInteractionResult({ mfaSkipped: true }, ctx, provider, true);
ctx.status = 204;
return next();
}
);
}

View file

@ -90,6 +90,7 @@ export type VerifiedSignInInteractionResult = {
profile?: Profile;
bindMfas?: BindMfa[];
verifiedMfa?: VerifyMfaResult;
mfaSkipped?: boolean;
};
export type VerifiedForgotPasswordInteractionResult = {

View file

@ -154,12 +154,6 @@ describe('validateMandatoryBindMfa', () => {
{ availableFactors: [MfaFactor.TOTP], skippable: true }
)
);
expect(storeInteractionResult).toHaveBeenCalledWith(
{ mfaSkipped: true },
expect.anything(),
expect.anything(),
expect.anything()
);
});
it('bindMfa missing and not required, marked as skipped should pass', async () => {
@ -201,13 +195,6 @@ describe('validateMandatoryBindMfa', () => {
{ availableFactors: [MfaFactor.TOTP], skippable: true }
)
);
expect(updateUserById).toHaveBeenCalledWith(signInInteraction.accountId, {
customData: {
mfa: {
skipped: true,
},
},
});
});
it('user mfaVerifications and bindMfa missing, mark skipped, and not required should pass', async () => {

View file

@ -136,78 +136,6 @@ const isMfaSkipped = (customData: JsonObject): boolean => {
return parsed.success ? parsed.data[userMfaDataKey].skipped === true : false;
};
const validateMandatoryBindMfaForSignIn = async (
tenant: TenantContext,
ctx: WithInteractionSieContext & WithInteractionDetailsContext,
interaction: VerifiedSignInInteractionResult
): Promise<VerifiedInteractionResult> => {
const {
mfa: { policy, factors },
} = ctx.signInExperience;
const { bindMfas } = interaction;
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
// No available MFA, skip check
if (availableFactors.length === 0) {
return interaction;
}
// If the user has linked new MFA in current interaction
const hasFactorInBindMfas = Boolean(
bindMfas &&
availableFactors.some((factor) => bindMfas.some((bindMfa) => bindMfa.type === factor))
);
const { accountId } = interaction;
const { mfaVerifications, customData } = await tenant.queries.users.findUserById(accountId);
// If the user has linked currently available MFA before
const hasFactorInUser = availableFactors.some((factor) =>
mfaVerifications.some(({ type }) => type === factor)
);
// MFA is bound in current interaction or MFA is bound before, skip check
if (hasFactorInBindMfas || hasFactorInUser) {
return interaction;
}
// Mandatory, can not skip, throw error
if (policy === MfaPolicy.Mandatory) {
throw new RequestError(
{
code: 'user.missing_mfa',
status: 422,
},
{ availableFactors }
);
}
if (isMfaSkipped(customData)) {
return interaction;
}
if (!isMfaSkipped(customData)) {
// Update user custom data to skip MFA binding
// that means that this prompt is only shown once
await tenant.queries.users.updateUserById(accountId, {
customData: {
...customData,
[userMfaDataKey]: {
skipped: true,
},
},
});
}
throw new RequestError(
{
code: 'user.missing_mfa',
status: 422,
},
{ availableFactors, skippable: true }
);
};
export const validateMandatoryBindMfa = async (
tenant: TenantContext,
ctx: Context & WithInteractionSieContext & WithInteractionDetailsContext,
@ -224,51 +152,46 @@ export const validateMandatoryBindMfa = async (
return interaction;
}
const { mfaSkipped } = interaction;
if (policy === MfaPolicy.UserControlled && mfaSkipped) {
return interaction;
}
const hasFactorInBind = Boolean(
bindMfas &&
availableFactors.some((factor) => bindMfas.some((bindMfa) => bindMfa.type === factor))
);
if (event === InteractionEvent.Register) {
if (hasFactorInBind) {
return interaction;
}
if (policy === MfaPolicy.Mandatory) {
throw new RequestError(
{
code: 'user.missing_mfa',
status: 422,
},
{ availableFactors }
);
}
const { mfaSkipped } = interaction;
if (mfaSkipped) {
return interaction;
}
// Auto mark MFA skipped for new users, will change to manual mark in the future
await storeInteractionResult(
{
mfaSkipped: true,
},
ctx,
tenant.provider,
true
);
throw new RequestError(
{
code: 'user.missing_mfa',
status: 422,
},
{ availableFactors, skippable: true }
);
if (hasFactorInBind) {
return interaction;
}
return validateMandatoryBindMfaForSignIn(tenant, ctx, interaction);
if (event === InteractionEvent.SignIn) {
const { accountId } = interaction;
const { mfaVerifications, customData } = await tenant.queries.users.findUserById(accountId);
if (isMfaSkipped(customData)) {
return interaction;
}
// If the user has linked currently available MFA before
const hasFactorInUser = availableFactors.some((factor) =>
mfaVerifications.some(({ type }) => type === factor)
);
// MFA is bound in current interaction or MFA is bound before, skip check
if (hasFactorInUser) {
return interaction;
}
}
throw new RequestError(
{
code: 'user.missing_mfa',
status: 422,
},
policy === MfaPolicy.Mandatory ? { availableFactors } : { availableFactors, skippable: true }
);
};
/**

View file

@ -254,5 +254,8 @@ export const verifyMfa = async (payload: VerifyMfaPayload) => {
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const submitInteraction = async () =>
api.post(`${interactionPrefix}/submit`).json<Response>();
export const skipMfa = async () => {
await api.put(`${interactionPrefix}/mfa-skipped`, { json: { mfaSkipped: true } });
return api.post(`${interactionPrefix}/submit`).json<Response>();
};

View file

@ -1,19 +1,19 @@
import { useCallback } from 'react';
import { submitInteraction } from '@/apis/interaction';
import { skipMfa } from '@/apis/interaction';
import useApi from './use-api';
import useErrorHandler from './use-error-handler';
import usePreSignInErrorHandler from './use-pre-sign-in-error-handler';
const useSkipMfa = () => {
const asyncSubmitInteraction = useApi(submitInteraction);
const asyncSkipMfa = useApi(skipMfa);
const handleError = useErrorHandler();
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
return useCallback(async () => {
const [error, result] = await asyncSubmitInteraction();
const [error, result] = await asyncSkipMfa();
if (error) {
await handleError(error, preSignInErrorHandler);
return;
@ -22,7 +22,7 @@ const useSkipMfa = () => {
if (result) {
window.location.replace(result.redirectTo);
}
}, [asyncSubmitInteraction, handleError, preSignInErrorHandler]);
}, [asyncSkipMfa, handleError, preSignInErrorHandler]);
};
export default useSkipMfa;

View file

@ -1,6 +1,7 @@
const config = {
launch: {
headless: Boolean(process.env.CI),
args: ['--accept-lang="en"'],
},
};

View file

@ -135,6 +135,15 @@ export const initTotp = async (cookie: string) =>
})
.json<{ secret: string }>();
export const skipMfaBinding = async (cookie: string) =>
api.put('interaction/mfa-skipped', {
headers: { cookie },
json: {
mfaSkipped: true,
},
followRedirect: false,
});
export const consent = async (api: Got, cookie: string) =>
api
.post('interaction/consent', {

View file

@ -7,6 +7,7 @@ import {
initTotp,
postInteractionBindMfa,
putInteractionMfa,
skipMfaBinding,
} from '#src/api/index.js';
import { initClient, processSession, logoutClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
@ -209,7 +210,8 @@ describe('sign in and fulfill mfa (user-controlled TOTP)', () => {
statusCode: 422,
});
// Try again, should auto skip
await client.successSend(skipMfaBinding);
await client.submitInteraction();
await deleteUser(user.id);
});

View file

@ -11,7 +11,7 @@ import {
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js';
import { waitFor } from '#src/utils.js';
import { generateUsername, waitFor } from '#src/utils.js';
describe('MFA - User controlled', () => {
beforeAll(async () => {
@ -42,6 +42,39 @@ describe('MFA - User controlled', () => {
await resetMfaSettings();
});
it('can skip MFA binding when registering and no need to verify MFA when signing in', async () => {
const username = generateUsername();
const password = 'l0gt0_T3st_P@ssw0rd';
const experience = new ExpectTotpExperience(await browser.newPage());
// Register
await experience.startWith(demoAppUrl, 'register');
await experience.toFillInput('identifier', username, { submit: true });
experience.toBeAt('register/password');
await experience.toFillNewPasswords(password);
// Skip MFA
await experience.toClick('div[role=button][class$=skipButton]');
await experience.page.waitForNetworkIdle();
await experience.verifyThenEnd(false);
// Sign in
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillForm(
{
identifier: username,
password,
},
{ submit: true }
);
const userId = await experience.getUserIdFromDemoAppPage();
// Sign in successfully
await experience.verifyThenEnd();
await deleteUser(userId);
});
it('can skip MFA binding when signing in at the first time', async () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });

View file

@ -47,6 +47,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -33,6 +33,7 @@ const session = {
backup_code_can_not_be_alone: 'Backup code can not be the only MFA.',
backup_code_required: 'Backup code is required.',
invalid_backup_code: 'Invalid backup code.',
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -48,6 +48,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -49,6 +49,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -47,6 +47,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -45,6 +45,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -44,6 +44,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -46,6 +46,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -47,6 +47,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -49,6 +49,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -45,6 +45,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -46,6 +46,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -41,6 +41,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -41,6 +41,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};

View file

@ -41,6 +41,8 @@ const session = {
backup_code_required: 'Backup code is required.',
/** UNTRANSLATED */
invalid_backup_code: 'Invalid backup code.',
/** UNTRANSLATED */
mfa_policy_not_user_controlled: 'MFA policy is not user controlled.',
},
};