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:
parent
1c79cde885
commit
ddf4468189
28 changed files with 367 additions and 211 deletions
|
@ -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' },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -90,6 +90,7 @@ export type VerifiedSignInInteractionResult = {
|
|||
profile?: Profile;
|
||||
bindMfas?: BindMfa[];
|
||||
verifiedMfa?: VerifyMfaResult;
|
||||
mfaSkipped?: boolean;
|
||||
};
|
||||
|
||||
export type VerifiedForgotPasswordInteractionResult = {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>();
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const config = {
|
||||
launch: {
|
||||
headless: Boolean(process.env.CI),
|
||||
args: ['--accept-lang="en"'],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue