mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
feat(core,phrases,schemas): implement bind mfa totp (#4551)
This commit is contained in:
parent
19939811c1
commit
08a35a1695
24 changed files with 1205 additions and 175 deletions
|
@ -69,6 +69,7 @@
|
||||||
"lru-cache": "^10.0.0",
|
"lru-cache": "^10.0.0",
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
"oidc-provider": "^8.2.2",
|
"oidc-provider": "^8.2.2",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"p-retry": "^6.0.0",
|
"p-retry": "^6.0.0",
|
||||||
"pg-protocol": "^1.6.0",
|
"pg-protocol": "^1.6.0",
|
||||||
"redis": "^4.6.5",
|
"redis": "^4.6.5",
|
||||||
|
|
6
packages/core/src/__mocks__/mfa-verification.ts
Normal file
6
packages/core/src/__mocks__/mfa-verification.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { MfaFactor, type BindMfa } from '@logto/schemas';
|
||||||
|
|
||||||
|
export const mockTotpBind: BindMfa = {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
};
|
|
@ -1,4 +1,9 @@
|
||||||
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
|
import {
|
||||||
|
InteractionEvent,
|
||||||
|
MfaFactor,
|
||||||
|
adminConsoleApplicationId,
|
||||||
|
adminTenantId,
|
||||||
|
} from '@logto/schemas';
|
||||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
|
@ -31,14 +36,19 @@ const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mockEsm('@logto/shared', () => ({
|
||||||
|
generateStandardId: jest.fn().mockReturnValue('uid'),
|
||||||
|
}));
|
||||||
|
|
||||||
mockEsm('#src/utils/tenant.js', () => ({
|
mockEsm('#src/utils/tenant.js', () => ({
|
||||||
getTenantId: () => adminTenantId,
|
getTenantId: () => adminTenantId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const userQueries = {
|
const userQueries = {
|
||||||
findUserById: jest
|
findUserById: jest.fn().mockResolvedValue({
|
||||||
.fn()
|
identities: { google: { userId: 'googleId', details: {} } },
|
||||||
.mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }),
|
mfaVerifications: [],
|
||||||
|
}),
|
||||||
updateUserById: jest.fn(),
|
updateUserById: jest.fn(),
|
||||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||||
};
|
};
|
||||||
|
@ -165,6 +175,35 @@ describe('submit action', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('register with bindMfa', async () => {
|
||||||
|
const interaction: VerifiedRegisterInteractionResult = {
|
||||||
|
event: InteractionEvent.Register,
|
||||||
|
profile,
|
||||||
|
identifiers,
|
||||||
|
bindMfa: { type: MfaFactor.TOTP, secret: 'secret' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await submitInteraction(interaction, ctx, tenant);
|
||||||
|
expect(generateUserId).toBeCalled();
|
||||||
|
expect(hasActiveUsers).not.toBeCalled();
|
||||||
|
|
||||||
|
expect(insertUser).toBeCalledWith(
|
||||||
|
{
|
||||||
|
id: 'uid',
|
||||||
|
mfaVerifications: [
|
||||||
|
{
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
key: 'secret',
|
||||||
|
id: 'uid',
|
||||||
|
createdAt: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...upsertProfile,
|
||||||
|
},
|
||||||
|
['user']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('admin user register', async () => {
|
it('admin user register', async () => {
|
||||||
hasActiveUsers.mockResolvedValueOnce(false);
|
hasActiveUsers.mockResolvedValueOnce(false);
|
||||||
const adminConsoleCtx = {
|
const adminConsoleCtx = {
|
||||||
|
@ -234,6 +273,41 @@ describe('submit action', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sign-in with bindMfa', async () => {
|
||||||
|
getLogtoConnectorById.mockResolvedValueOnce({
|
||||||
|
metadata: { target: 'logto' },
|
||||||
|
dbEntry: { syncProfile: false },
|
||||||
|
});
|
||||||
|
const interaction: VerifiedSignInInteractionResult = {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
accountId: 'foo',
|
||||||
|
identifiers,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await submitInteraction(interaction, ctx, tenant);
|
||||||
|
|
||||||
|
expect(getLogtoConnectorById).toBeCalledWith('logto');
|
||||||
|
|
||||||
|
expect(updateUserById).toBeCalledWith('foo', {
|
||||||
|
mfaVerifications: [
|
||||||
|
{
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
key: 'secret',
|
||||||
|
id: 'uid',
|
||||||
|
createdAt: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastSignInAt: now,
|
||||||
|
});
|
||||||
|
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
|
||||||
|
login: { accountId: 'foo' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sign-in and sync new Social', async () => {
|
it('sign-in and sync new Social', async () => {
|
||||||
getLogtoConnectorById.mockResolvedValueOnce({
|
getLogtoConnectorById.mockResolvedValueOnce({
|
||||||
metadata: { target: 'logto' },
|
metadata: { target: 'logto' },
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
InteractionEvent,
|
InteractionEvent,
|
||||||
adminConsoleApplicationId,
|
adminConsoleApplicationId,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { type OmitAutoSetFields } from '@logto/shared';
|
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
|
||||||
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
|
||||||
import { type IRouterContext } from 'koa-router';
|
import { type IRouterContext } from 'koa-router';
|
||||||
|
|
||||||
|
@ -168,6 +168,23 @@ const parseUserProfile = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseBindMfa = ({
|
||||||
|
bindMfa,
|
||||||
|
}: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult):
|
||||||
|
| User['mfaVerifications'][number]
|
||||||
|
| undefined => {
|
||||||
|
if (!bindMfa) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: bindMfa.type,
|
||||||
|
key: bindMfa.secret,
|
||||||
|
id: generateStandardId(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getInitialUserRoles = (
|
const getInitialUserRoles = (
|
||||||
isInAdminTenant: boolean,
|
isInAdminTenant: boolean,
|
||||||
isCreatingFirstAdminUser: boolean,
|
isCreatingFirstAdminUser: boolean,
|
||||||
|
@ -220,6 +237,7 @@ export default async function submitInteraction(
|
||||||
if (event === InteractionEvent.Register) {
|
if (event === InteractionEvent.Register) {
|
||||||
const id = await generateUserId();
|
const id = await generateUserId();
|
||||||
const userProfile = await parseUserProfile(connectors, interaction);
|
const userProfile = await parseUserProfile(connectors, interaction);
|
||||||
|
const mfaVerification = parseBindMfa(interaction);
|
||||||
|
|
||||||
const { client_id } = ctx.interactionDetails.params;
|
const { client_id } = ctx.interactionDetails.params;
|
||||||
|
|
||||||
|
@ -234,6 +252,7 @@ export default async function submitInteraction(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
...userProfile,
|
...userProfile,
|
||||||
|
...conditional(mfaVerification && { mfaVerifications: [mfaVerification] }),
|
||||||
},
|
},
|
||||||
getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud)
|
getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud)
|
||||||
);
|
);
|
||||||
|
@ -265,8 +284,14 @@ export default async function submitInteraction(
|
||||||
if (event === InteractionEvent.SignIn) {
|
if (event === InteractionEvent.SignIn) {
|
||||||
const user = await findUserById(accountId);
|
const user = await findUserById(accountId);
|
||||||
const updateUserProfile = await parseUserProfile(connectors, interaction, user);
|
const updateUserProfile = await parseUserProfile(connectors, interaction, user);
|
||||||
|
const mfaVerification = parseBindMfa(interaction);
|
||||||
|
|
||||||
await updateUserById(accountId, updateUserProfile);
|
await updateUserById(accountId, {
|
||||||
|
...updateUserProfile,
|
||||||
|
...conditional(
|
||||||
|
mfaVerification && { mfaVerifications: [...user.mfaVerifications, mfaVerification] }
|
||||||
|
),
|
||||||
|
});
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId } });
|
await assignInteractionResults(ctx, provider, { login: { accountId } });
|
||||||
ctx.assignInteractionHookResult({ userId: accountId });
|
ctx.assignInteractionHookResult({ userId: accountId });
|
||||||
|
|
||||||
|
|
199
packages/core/src/routes/interaction/additional.test.ts
Normal file
199
packages/core/src/routes/interaction/additional.test.ts
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
import { ConnectorType } from '@logto/connector-kit';
|
||||||
|
import { demoAppApplicationId, InteractionEvent } from '@logto/schemas';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
|
||||||
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
|
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||||
|
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||||
|
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
|
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
||||||
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import { verificationPath, interactionPrefix } from './const.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
|
||||||
|
// FIXME @Darcy: no more `enabled` for `connectors` table
|
||||||
|
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
||||||
|
const metadata = {
|
||||||
|
id:
|
||||||
|
connectorId === 'social_enabled'
|
||||||
|
? 'social_enabled'
|
||||||
|
: connectorId === 'social_disabled'
|
||||||
|
? 'social_disabled'
|
||||||
|
: 'others',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dbEntry: {},
|
||||||
|
metadata,
|
||||||
|
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
|
||||||
|
getAuthorizationUri: jest.fn(async () => ''),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getInteractionStorage, storeInteractionResult } = await mockEsmWithActual(
|
||||||
|
'./utils/interaction.js',
|
||||||
|
() => ({
|
||||||
|
getInteractionStorage: jest.fn().mockReturnValue({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
}),
|
||||||
|
storeInteractionResult: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
|
||||||
|
'./utils/verification-code-validation.js',
|
||||||
|
() => ({
|
||||||
|
sendVerificationCodeToIdentifier: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createLog, prependAllLogEntries } = createMockLogContext();
|
||||||
|
|
||||||
|
await mockEsmWithActual(
|
||||||
|
'#src/middleware/koa-audit-log.js',
|
||||||
|
(): { default: typeof koaAuditLog } => ({
|
||||||
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
|
default: () => async (ctx, next) => {
|
||||||
|
ctx.createLog = createLog;
|
||||||
|
ctx.prependAllLogEntries = prependAllLogEntries;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseProviderMock = {
|
||||||
|
params: {},
|
||||||
|
jti: 'jti',
|
||||||
|
client_id: demoAppApplicationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantContext = new MockTenant(
|
||||||
|
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||||
|
{
|
||||||
|
signInExperiences: {
|
||||||
|
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLogtoConnectorById: async (connectorId: string) => {
|
||||||
|
const connector = await getLogtoConnectorByIdHelper(connectorId);
|
||||||
|
|
||||||
|
if (connector.type !== ConnectorType.Social) {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'entity.not_found',
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
return connector as LogtoConnector;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { default: interactionRoutes } = await import('./index.js');
|
||||||
|
|
||||||
|
describe('interaction routes', () => {
|
||||||
|
const sessionRequest = createRequester({
|
||||||
|
anonymousRoutes: interactionRoutes,
|
||||||
|
tenantContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
getInteractionStorage.mockReturnValue({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /interaction/verification/verification-code', () => {
|
||||||
|
const path = `${interactionPrefix}/${verificationPath}/verification-code`;
|
||||||
|
|
||||||
|
it('should call send verificationCode properly', async () => {
|
||||||
|
const body = {
|
||||||
|
email: 'email@logto.io',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await sessionRequest.post(path).send(body);
|
||||||
|
expect(getInteractionStorage).toBeCalled();
|
||||||
|
expect(sendVerificationCodeToIdentifier).toBeCalledWith(
|
||||||
|
{
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
...body,
|
||||||
|
},
|
||||||
|
'jti',
|
||||||
|
createLog,
|
||||||
|
tenantContext.libraries.passcodes
|
||||||
|
);
|
||||||
|
expect(response.status).toEqual(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /verification/social/authorization-uri', () => {
|
||||||
|
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
|
||||||
|
|
||||||
|
it('should throw when redirectURI is invalid', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send({
|
||||||
|
connectorId: 'social_enabled',
|
||||||
|
state: 'state',
|
||||||
|
redirectUri: 'logto.dev',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the authorization-uri properly', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send({
|
||||||
|
connectorId: 'social_enabled',
|
||||||
|
state: 'state',
|
||||||
|
redirectUri: 'https://logto.dev',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toEqual(200);
|
||||||
|
expect(response.body).toHaveProperty('redirectTo', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw error when sign-in with social but miss state', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send({
|
||||||
|
connectorId: 'social_enabled',
|
||||||
|
redirectUri: 'https://logto.dev',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw error when sign-in with social but miss redirectUri', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send({
|
||||||
|
connectorId: 'social_enabled',
|
||||||
|
state: 'state',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throw error when no social connector is found', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send({
|
||||||
|
connectorId: 'others',
|
||||||
|
state: 'state',
|
||||||
|
redirectUri: 'https://logto.dev',
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toEqual(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /verification/totp', () => {
|
||||||
|
const path = `${interactionPrefix}/${verificationPath}/totp`;
|
||||||
|
|
||||||
|
it('should return the generated secret', async () => {
|
||||||
|
const response = await sessionRequest.post(path).send();
|
||||||
|
expect(getInteractionStorage).toBeCalled();
|
||||||
|
expect(storeInteractionResult).toBeCalled();
|
||||||
|
expect(response.statusCode).toEqual(200);
|
||||||
|
expect(response.body).toHaveProperty('secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
102
packages/core/src/routes/interaction/additional.ts
Normal file
102
packages/core/src/routes/interaction/additional.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { MfaFactor, requestVerificationCodePayloadGuard } from '@logto/schemas';
|
||||||
|
import type Router from 'koa-router';
|
||||||
|
import { type IRouterParamContext } from 'koa-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
|
import { interactionPrefix, verificationPath } from './const.js';
|
||||||
|
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||||
|
import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
||||||
|
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
|
||||||
|
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
|
||||||
|
import { generateTotpSecret } from './utils/totp-validation.js';
|
||||||
|
import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js';
|
||||||
|
|
||||||
|
export default function additionalRoutes<T extends IRouterParamContext>(
|
||||||
|
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
||||||
|
tenant: TenantContext
|
||||||
|
) {
|
||||||
|
// Create social authorization url interaction verification
|
||||||
|
router.post(
|
||||||
|
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
|
||||||
|
koaGuard({
|
||||||
|
body: socialAuthorizationUrlPayloadGuard,
|
||||||
|
status: [200, 400, 404],
|
||||||
|
response: z.object({
|
||||||
|
redirectTo: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
// Check interaction exists
|
||||||
|
const { event } = getInteractionStorage(ctx.interactionDetails.result);
|
||||||
|
const log = ctx.createLog(`Interaction.${event}.Identifier.Social.Create`);
|
||||||
|
|
||||||
|
const { body: payload } = ctx.guard;
|
||||||
|
|
||||||
|
log.append(payload);
|
||||||
|
|
||||||
|
const redirectTo = await createSocialAuthorizationUrl(ctx, tenant, payload);
|
||||||
|
|
||||||
|
ctx.body = { redirectTo };
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create passwordless interaction verification-code
|
||||||
|
router.post(
|
||||||
|
`${interactionPrefix}/${verificationPath}/verification-code`,
|
||||||
|
koaGuard({
|
||||||
|
body: requestVerificationCodePayloadGuard,
|
||||||
|
status: [204, 400, 404],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { interactionDetails, guard, createLog } = ctx;
|
||||||
|
// Check interaction exists
|
||||||
|
const { event } = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
|
await sendVerificationCodeToIdentifier(
|
||||||
|
{ event, ...guard.body },
|
||||||
|
interactionDetails.jti,
|
||||||
|
createLog,
|
||||||
|
tenant.libraries.passcodes
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare new totp secret
|
||||||
|
router.post(
|
||||||
|
`${interactionPrefix}/${verificationPath}/totp`,
|
||||||
|
koaGuard({
|
||||||
|
status: [200],
|
||||||
|
response: z.object({
|
||||||
|
secret: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { interactionDetails, createLog } = ctx;
|
||||||
|
// Check interaction exists
|
||||||
|
const { event } = getInteractionStorage(interactionDetails.result);
|
||||||
|
createLog(`Interaction.${event}.BindMfa.Totp.Create`);
|
||||||
|
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
await storeInteractionResult(
|
||||||
|
{ pendingMfa: { type: MfaFactor.TOTP, secret } },
|
||||||
|
ctx,
|
||||||
|
tenant.provider,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.body = { secret };
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -11,10 +11,10 @@ import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
import { verificationPath, interactionPrefix } from './const.js';
|
import { interactionPrefix } from './const.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
const { mockEsm, mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
|
const { mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
|
||||||
// FIXME @Darcy: no more `enabled` for `connectors` table
|
// FIXME @Darcy: no more `enabled` for `connectors` table
|
||||||
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
||||||
|
@ -39,23 +39,29 @@ const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/ses
|
||||||
assignInteractionResults: jest.fn(),
|
assignInteractionResults: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = mockEsm(
|
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } =
|
||||||
'./utils/sign-in-experience-validation.js',
|
await mockEsmWithActual('./utils/sign-in-experience-validation.js', () => ({
|
||||||
() => ({
|
|
||||||
verifySignInModeSettings: jest.fn(),
|
verifySignInModeSettings: jest.fn(),
|
||||||
verifyIdentifierSettings: jest.fn(),
|
verifyIdentifierSettings: jest.fn(),
|
||||||
verifyProfileSettings: jest.fn(),
|
verifyProfileSettings: jest.fn(),
|
||||||
})
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
|
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
|
||||||
|
|
||||||
const { verifyIdentifierPayload, verifyIdentifier, verifyProfile, validateMandatoryUserProfile } =
|
const {
|
||||||
await mockEsmWithActual('./verifications/index.js', () => ({
|
verifyIdentifierPayload,
|
||||||
|
verifyIdentifier,
|
||||||
|
verifyProfile,
|
||||||
|
validateMandatoryUserProfile,
|
||||||
|
validateMandatoryBindMfa,
|
||||||
|
verifyBindMfa,
|
||||||
|
} = await mockEsmWithActual('./verifications/index.js', () => ({
|
||||||
verifyIdentifierPayload: jest.fn(),
|
verifyIdentifierPayload: jest.fn(),
|
||||||
verifyIdentifier: jest.fn().mockResolvedValue({}),
|
verifyIdentifier: jest.fn().mockResolvedValue({}),
|
||||||
verifyProfile: jest.fn(),
|
verifyProfile: jest.fn(),
|
||||||
validateMandatoryUserProfile: jest.fn(),
|
validateMandatoryUserProfile: jest.fn(),
|
||||||
|
validateMandatoryBindMfa: jest.fn(),
|
||||||
|
verifyBindMfa: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual(
|
const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual(
|
||||||
|
@ -69,13 +75,6 @@ const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = awai
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
|
|
||||||
'./utils/verification-code-validation.js',
|
|
||||||
() => ({
|
|
||||||
sendVerificationCodeToIdentifier: jest.fn(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { validatePassword } = await mockEsmWithActual('./utils/validate-password.js', () => ({
|
const { validatePassword } = await mockEsmWithActual('./utils/validate-password.js', () => ({
|
||||||
validatePassword: jest.fn(),
|
validatePassword: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
@ -175,20 +174,28 @@ describe('interaction routes', () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call identifier and profile verification properly', async () => {
|
it('should call identifier, profile and bindMfa verification properly', async () => {
|
||||||
verifyProfile.mockReturnValueOnce({
|
verifyProfile.mockReturnValueOnce({
|
||||||
event: InteractionEvent.SignIn,
|
event: InteractionEvent.SignIn,
|
||||||
});
|
});
|
||||||
|
validateMandatoryUserProfile.mockReturnValueOnce({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
});
|
||||||
|
verifyBindMfa.mockReturnValueOnce({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
});
|
||||||
|
|
||||||
await sessionRequest.post(path).send();
|
await sessionRequest.post(path).send();
|
||||||
expect(getInteractionStorage).toBeCalled();
|
expect(getInteractionStorage).toBeCalled();
|
||||||
expect(verifyIdentifier).toBeCalled();
|
expect(verifyIdentifier).toBeCalled();
|
||||||
expect(verifyProfile).toBeCalled();
|
expect(verifyProfile).toBeCalled();
|
||||||
expect(validateMandatoryUserProfile).toBeCalled();
|
expect(validateMandatoryUserProfile).toBeCalled();
|
||||||
|
expect(verifyBindMfa).toBeCalled();
|
||||||
|
expect(validateMandatoryBindMfa).toBeCalled();
|
||||||
expect(submitInteraction).toBeCalled();
|
expect(submitInteraction).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
it('should not call validateMandatoryUserProfile and validateMandatoryBindMfa for forgot password request', async () => {
|
||||||
getInteractionStorage.mockReturnValue({
|
getInteractionStorage.mockReturnValue({
|
||||||
event: InteractionEvent.ForgotPassword,
|
event: InteractionEvent.ForgotPassword,
|
||||||
});
|
});
|
||||||
|
@ -196,12 +203,19 @@ describe('interaction routes', () => {
|
||||||
verifyProfile.mockReturnValueOnce({
|
verifyProfile.mockReturnValueOnce({
|
||||||
event: InteractionEvent.ForgotPassword,
|
event: InteractionEvent.ForgotPassword,
|
||||||
});
|
});
|
||||||
|
validateMandatoryUserProfile.mockReturnValueOnce({
|
||||||
|
event: InteractionEvent.ForgotPassword,
|
||||||
|
});
|
||||||
|
verifyBindMfa.mockReturnValueOnce({
|
||||||
|
event: InteractionEvent.ForgotPassword,
|
||||||
|
});
|
||||||
|
|
||||||
await sessionRequest.post(path).send();
|
await sessionRequest.post(path).send();
|
||||||
expect(getInteractionStorage).toBeCalled();
|
expect(getInteractionStorage).toBeCalled();
|
||||||
expect(verifyIdentifier).toBeCalled();
|
expect(verifyIdentifier).toBeCalled();
|
||||||
expect(verifyProfile).toBeCalled();
|
expect(verifyProfile).toBeCalled();
|
||||||
expect(validateMandatoryUserProfile).not.toBeCalled();
|
expect(validateMandatoryUserProfile).not.toBeCalled();
|
||||||
|
expect(validateMandatoryBindMfa).not.toBeCalled();
|
||||||
expect(submitInteraction).toBeCalled();
|
expect(submitInteraction).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -309,76 +323,4 @@ describe('interaction routes', () => {
|
||||||
expect(response.status).toEqual(204);
|
expect(response.status).toEqual(204);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /interaction/verification/verification-code', () => {
|
|
||||||
const path = `${interactionPrefix}/${verificationPath}/verification-code`;
|
|
||||||
|
|
||||||
it('should call send verificationCode properly', async () => {
|
|
||||||
const body = {
|
|
||||||
email: 'email@logto.io',
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await sessionRequest.post(path).send(body);
|
|
||||||
expect(getInteractionStorage).toBeCalled();
|
|
||||||
expect(sendVerificationCodeToIdentifier).toBeCalledWith(
|
|
||||||
{
|
|
||||||
event: InteractionEvent.SignIn,
|
|
||||||
...body,
|
|
||||||
},
|
|
||||||
'jti',
|
|
||||||
createLog,
|
|
||||||
tenantContext.libraries.passcodes
|
|
||||||
);
|
|
||||||
expect(response.status).toEqual(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /verification/social/authorization-uri', () => {
|
|
||||||
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
|
|
||||||
|
|
||||||
it('should throw when redirectURI is invalid', async () => {
|
|
||||||
const response = await sessionRequest.post(path).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the authorization-uri properly', async () => {
|
|
||||||
const response = await sessionRequest.post(path).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo', '');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when sign-in with social but miss state', async () => {
|
|
||||||
const response = await sessionRequest.post(path).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when sign-in with social but miss redirectUri', async () => {
|
|
||||||
const response = await sessionRequest.post(path).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when no social connector is found', async () => {
|
|
||||||
const response = await sessionRequest.post(path).send({
|
|
||||||
connectorId: 'others',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import type { LogtoErrorCode } from '@logto/phrases';
|
import type { LogtoErrorCode } from '@logto/phrases';
|
||||||
import {
|
import { InteractionEvent, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
|
||||||
InteractionEvent,
|
|
||||||
eventGuard,
|
|
||||||
identifierPayloadGuard,
|
|
||||||
profileGuard,
|
|
||||||
requestVerificationCodePayloadGuard,
|
|
||||||
} from '@logto/schemas';
|
|
||||||
import type Router from 'koa-router';
|
import type Router from 'koa-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -18,13 +12,14 @@ import assertThat from '#src/utils/assert-that.js';
|
||||||
import type { AnonymousRouter, RouterInitArgs } from '../types.js';
|
import type { AnonymousRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
import submitInteraction from './actions/submit-interaction.js';
|
import submitInteraction from './actions/submit-interaction.js';
|
||||||
|
import additionalRoutes from './additional.js';
|
||||||
import consentRoutes from './consent.js';
|
import consentRoutes from './consent.js';
|
||||||
import { interactionPrefix, verificationPath } from './const.js';
|
import { interactionPrefix } from './const.js';
|
||||||
|
import mfaRoutes from './mfa.js';
|
||||||
import koaInteractionDetails from './middleware/koa-interaction-details.js';
|
import koaInteractionDetails from './middleware/koa-interaction-details.js';
|
||||||
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||||
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
|
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
|
||||||
import koaInteractionSie from './middleware/koa-interaction-sie.js';
|
import koaInteractionSie from './middleware/koa-interaction-sie.js';
|
||||||
import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
|
||||||
import {
|
import {
|
||||||
getInteractionStorage,
|
getInteractionStorage,
|
||||||
storeInteractionResult,
|
storeInteractionResult,
|
||||||
|
@ -36,14 +31,14 @@ import {
|
||||||
verifyIdentifierSettings,
|
verifyIdentifierSettings,
|
||||||
verifyProfileSettings,
|
verifyProfileSettings,
|
||||||
} from './utils/sign-in-experience-validation.js';
|
} from './utils/sign-in-experience-validation.js';
|
||||||
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
|
|
||||||
import { validatePassword } from './utils/validate-password.js';
|
import { validatePassword } from './utils/validate-password.js';
|
||||||
import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js';
|
|
||||||
import {
|
import {
|
||||||
verifyIdentifierPayload,
|
verifyIdentifierPayload,
|
||||||
verifyIdentifier,
|
verifyIdentifier,
|
||||||
verifyProfile,
|
verifyProfile,
|
||||||
validateMandatoryUserProfile,
|
validateMandatoryUserProfile,
|
||||||
|
validateMandatoryBindMfa,
|
||||||
|
verifyBindMfa,
|
||||||
} from './verifications/index.js';
|
} from './verifications/index.js';
|
||||||
|
|
||||||
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||||
|
@ -340,68 +335,30 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
|
|
||||||
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
|
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
|
||||||
|
|
||||||
const interaction = isForgotPasswordInteractionResult(profileVerifiedInteraction)
|
// TODO @simeng-li: make all these verification steps in a middleware.
|
||||||
|
const mandatoryProfileVerifiedInteraction = isForgotPasswordInteractionResult(
|
||||||
|
profileVerifiedInteraction
|
||||||
|
)
|
||||||
? profileVerifiedInteraction
|
? profileVerifiedInteraction
|
||||||
: await validateMandatoryUserProfile(queries.users, ctx, profileVerifiedInteraction);
|
: await validateMandatoryUserProfile(queries.users, ctx, profileVerifiedInteraction);
|
||||||
|
|
||||||
|
const bindMfaVerifiedInteraction = isForgotPasswordInteractionResult(
|
||||||
|
mandatoryProfileVerifiedInteraction
|
||||||
|
)
|
||||||
|
? mandatoryProfileVerifiedInteraction
|
||||||
|
: await verifyBindMfa(tenant, mandatoryProfileVerifiedInteraction);
|
||||||
|
|
||||||
|
const interaction = isForgotPasswordInteractionResult(bindMfaVerifiedInteraction)
|
||||||
|
? bindMfaVerifiedInteraction
|
||||||
|
: await validateMandatoryBindMfa(tenant, ctx, bindMfaVerifiedInteraction);
|
||||||
|
|
||||||
await submitInteraction(interaction, ctx, tenant, log);
|
await submitInteraction(interaction, ctx, tenant, log);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create social authorization url interaction verification
|
|
||||||
router.post(
|
|
||||||
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
|
|
||||||
koaGuard({
|
|
||||||
body: socialAuthorizationUrlPayloadGuard,
|
|
||||||
status: [200, 400, 404],
|
|
||||||
response: z.object({
|
|
||||||
redirectTo: z.string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
// Check interaction exists
|
|
||||||
const { event } = getInteractionStorage(ctx.interactionDetails.result);
|
|
||||||
const log = ctx.createLog(`Interaction.${event}.Identifier.Social.Create`);
|
|
||||||
|
|
||||||
const { body: payload } = ctx.guard;
|
|
||||||
|
|
||||||
log.append(payload);
|
|
||||||
|
|
||||||
const redirectTo = await createSocialAuthorizationUrl(ctx, tenant, payload);
|
|
||||||
|
|
||||||
ctx.body = { redirectTo };
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create passwordless interaction verification-code
|
|
||||||
router.post(
|
|
||||||
`${interactionPrefix}/${verificationPath}/verification-code`,
|
|
||||||
koaGuard({
|
|
||||||
body: requestVerificationCodePayloadGuard,
|
|
||||||
status: [204, 400, 404],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { interactionDetails, guard, createLog } = ctx;
|
|
||||||
// Check interaction exists
|
|
||||||
const { event } = getInteractionStorage(interactionDetails.result);
|
|
||||||
|
|
||||||
await sendVerificationCodeToIdentifier(
|
|
||||||
// eslint-disable-next-line max-lines -- TODO: refactor @simeng
|
|
||||||
{ event, ...guard.body },
|
|
||||||
interactionDetails.jti,
|
|
||||||
createLog,
|
|
||||||
libraries.passcodes
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
consentRoutes(router, tenant);
|
consentRoutes(router, tenant);
|
||||||
|
additionalRoutes(router, tenant);
|
||||||
|
mfaRoutes(router, tenant);
|
||||||
}
|
}
|
||||||
|
|
111
packages/core/src/routes/interaction/mfa.test.ts
Normal file
111
packages/core/src/routes/interaction/mfa.test.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
|
||||||
|
import { mockTotpBind } from '#src/__mocks__/mfa-verification.js';
|
||||||
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
|
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
|
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||||
|
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||||
|
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
|
import { createRequester } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import { interactionPrefix } from './const.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
|
||||||
|
const { getInteractionStorage, storeInteractionResult } = await mockEsmWithActual(
|
||||||
|
'./utils/interaction.js',
|
||||||
|
() => ({
|
||||||
|
getInteractionStorage: jest.fn().mockReturnValue({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
}),
|
||||||
|
storeInteractionResult: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createLog, prependAllLogEntries } = createMockLogContext();
|
||||||
|
|
||||||
|
await mockEsmWithActual(
|
||||||
|
'#src/middleware/koa-audit-log.js',
|
||||||
|
(): { default: typeof koaAuditLog } => ({
|
||||||
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
|
default: () => async (ctx, next) => {
|
||||||
|
ctx.createLog = createLog;
|
||||||
|
ctx.prependAllLogEntries = prependAllLogEntries;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { verifyMfaSettings } = await mockEsmWithActual(
|
||||||
|
'./utils/sign-in-experience-validation.js',
|
||||||
|
() => ({
|
||||||
|
verifyMfaSettings: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { bindMfaPayloadVerification } = await mockEsmWithActual(
|
||||||
|
'./verifications/mfa-payload-verification.js',
|
||||||
|
() => ({
|
||||||
|
bindMfaPayloadVerification: jest.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseProviderMock = {
|
||||||
|
params: {},
|
||||||
|
jti: 'jti',
|
||||||
|
client_id: demoAppApplicationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantContext = new MockTenant(
|
||||||
|
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||||
|
{
|
||||||
|
signInExperiences: {
|
||||||
|
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { default: interactionRoutes } = await import('./index.js');
|
||||||
|
|
||||||
|
describe('interaction routes (MFA verification)', () => {
|
||||||
|
const sessionRequest = createRequester({
|
||||||
|
anonymousRoutes: interactionRoutes,
|
||||||
|
tenantContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
getInteractionStorage.mockReturnValue({
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /interaction/bind-mfa', () => {
|
||||||
|
const path = `${interactionPrefix}/bind-mfa`;
|
||||||
|
|
||||||
|
it('should return 204 and store results in session', async () => {
|
||||||
|
bindMfaPayloadVerification.mockResolvedValue(mockTotpBind);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
code: '123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await sessionRequest.put(path).send(body);
|
||||||
|
expect(response.status).toEqual(204);
|
||||||
|
expect(getInteractionStorage).toBeCalled();
|
||||||
|
expect(verifyMfaSettings).toBeCalled();
|
||||||
|
expect(bindMfaPayloadVerification).toBeCalled();
|
||||||
|
expect(storeInteractionResult).toBeCalledWith(
|
||||||
|
{ bindMfa: mockTotpBind },
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
50
packages/core/src/routes/interaction/mfa.ts
Normal file
50
packages/core/src/routes/interaction/mfa.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { InteractionEvent, bindMfaPayloadGuard } from '@logto/schemas';
|
||||||
|
import type Router from 'koa-router';
|
||||||
|
import { type IRouterParamContext } from 'koa-router';
|
||||||
|
|
||||||
|
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
|
import { interactionPrefix } from './const.js';
|
||||||
|
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||||
|
import koaInteractionSie from './middleware/koa-interaction-sie.js';
|
||||||
|
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
|
||||||
|
import { verifyMfaSettings } from './utils/sign-in-experience-validation.js';
|
||||||
|
import { bindMfaPayloadVerification } from './verifications/mfa-payload-verification.js';
|
||||||
|
|
||||||
|
export default function mfaRoutes<T extends IRouterParamContext>(
|
||||||
|
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
|
||||||
|
{ provider, queries }: TenantContext
|
||||||
|
) {
|
||||||
|
// Update New MFA
|
||||||
|
router.put(
|
||||||
|
`${interactionPrefix}/bind-mfa`,
|
||||||
|
koaGuard({
|
||||||
|
body: bindMfaPayloadGuard,
|
||||||
|
status: [204, 400, 401, 404, 422],
|
||||||
|
}),
|
||||||
|
koaInteractionSie(queries),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const bindMfaPayload = ctx.guard.body;
|
||||||
|
const { signInExperience, interactionDetails, createLog } = ctx;
|
||||||
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
|
const log = createLog(`Interaction.${interactionStorage.event}.BindMfa.Totp.Submit`);
|
||||||
|
|
||||||
|
if (interactionStorage.event !== InteractionEvent.ForgotPassword) {
|
||||||
|
verifyMfaSettings(bindMfaPayload.type, signInExperience);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage);
|
||||||
|
|
||||||
|
log.append({ bindMfa, interactionStorage });
|
||||||
|
|
||||||
|
await storeInteractionResult({ bindMfa }, ctx, provider, true);
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { socialUserInfoGuard } from '@logto/connector-kit';
|
import { socialUserInfoGuard } from '@logto/connector-kit';
|
||||||
import { validateRedirectUrl } from '@logto/core-kit';
|
import { validateRedirectUrl } from '@logto/core-kit';
|
||||||
import { eventGuard, profileGuard } from '@logto/schemas';
|
import { bindMfaGuard, eventGuard, pendingMfaGuard, profileGuard } from '@logto/schemas';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Social Authorization Uri Route Payload Guard
|
// Social Authorization Uri Route Payload Guard
|
||||||
|
@ -44,6 +44,10 @@ export const anonymousInteractionResultGuard = z.object({
|
||||||
profile: profileGuard.optional(),
|
profile: profileGuard.optional(),
|
||||||
accountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
identifiers: z.array(identifierGuard).optional(),
|
identifiers: z.array(identifierGuard).optional(),
|
||||||
|
// The new mfa to be bound to the account
|
||||||
|
bindMfa: bindMfaGuard.optional(),
|
||||||
|
// The pending mfa info, such as secret of TOTP
|
||||||
|
pendingMfa: pendingMfaGuard.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const forgotPasswordProfileGuard = z.object({
|
export const forgotPasswordProfileGuard = z.object({
|
||||||
|
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
SocialEmailPayload,
|
SocialEmailPayload,
|
||||||
SocialPhonePayload,
|
SocialPhonePayload,
|
||||||
Profile,
|
Profile,
|
||||||
|
BindMfa,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
@ -76,6 +77,7 @@ export type VerifiedRegisterInteractionResult = {
|
||||||
event: InteractionEvent.Register;
|
event: InteractionEvent.Register;
|
||||||
profile?: Profile;
|
profile?: Profile;
|
||||||
identifiers?: Identifier[];
|
identifiers?: Identifier[];
|
||||||
|
bindMfa?: BindMfa;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerifiedSignInInteractionResult = {
|
export type VerifiedSignInInteractionResult = {
|
||||||
|
@ -83,6 +85,7 @@ export type VerifiedSignInInteractionResult = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
identifiers: Identifier[];
|
identifiers: Identifier[];
|
||||||
profile?: Profile;
|
profile?: Profile;
|
||||||
|
bindMfa?: BindMfa;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerifiedForgotPasswordInteractionResult = {
|
export type VerifiedForgotPasswordInteractionResult = {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import type {
|
import {
|
||||||
SocialConnectorPayload,
|
type SocialConnectorPayload,
|
||||||
User,
|
type User,
|
||||||
IdentifierPayload,
|
type IdentifierPayload,
|
||||||
VerifyVerificationCodePayload,
|
type VerifyVerificationCodePayload,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import type { PasswordIdentifierPayload } from '../types/index.js';
|
import type { PasswordIdentifierPayload } from '../types/index.js';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { SignInExperience } from '@logto/schemas';
|
import type { SignInExperience } from '@logto/schemas';
|
||||||
import { SignInIdentifier, SignInMode, InteractionEvent } from '@logto/schemas';
|
import { SignInIdentifier, SignInMode, InteractionEvent, MfaFactor } from '@logto/schemas';
|
||||||
|
|
||||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import {
|
||||||
verifySignInModeSettings,
|
verifySignInModeSettings,
|
||||||
verifyIdentifierSettings,
|
verifyIdentifierSettings,
|
||||||
verifyProfileSettings,
|
verifyProfileSettings,
|
||||||
|
verifyMfaSettings,
|
||||||
} from './sign-in-experience-validation.js';
|
} from './sign-in-experience-validation.js';
|
||||||
|
|
||||||
describe('verifySignInModeSettings', () => {
|
describe('verifySignInModeSettings', () => {
|
||||||
|
@ -375,3 +376,27 @@ describe('profile validation', () => {
|
||||||
}).toThrow();
|
}).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MFA', () => {
|
||||||
|
it('MFA sign-in-experience settings verification', () => {
|
||||||
|
expect(() => {
|
||||||
|
verifyMfaSettings(MfaFactor.TOTP, {
|
||||||
|
...mockSignInExperience,
|
||||||
|
mfa: {
|
||||||
|
...mockSignInExperience.mfa,
|
||||||
|
factors: [MfaFactor.TOTP],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
verifyMfaSettings(MfaFactor.TOTP, {
|
||||||
|
...mockSignInExperience,
|
||||||
|
mfa: {
|
||||||
|
...mockSignInExperience.mfa,
|
||||||
|
factors: [MfaFactor.WebAuthn],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
|
import type { SignInExperience, Profile, IdentifierPayload, MfaFactor } from '@logto/schemas';
|
||||||
import { SignInMode, SignInIdentifier, InteractionEvent } from '@logto/schemas';
|
import { SignInMode, SignInIdentifier, InteractionEvent } from '@logto/schemas';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -127,3 +127,11 @@ export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperi
|
||||||
assertThat(signUp.password, forbiddenIdentifierError());
|
assertThat(signUp.password, forbiddenIdentifierError());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const verifyMfaSettings = (type: MfaFactor, signInExperience: SignInExperience) => {
|
||||||
|
const {
|
||||||
|
mfa: { factors },
|
||||||
|
} = signInExperience;
|
||||||
|
|
||||||
|
assertThat(factors.includes(type), forbiddenIdentifierError());
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
const { jest } = import.meta;
|
||||||
|
|
||||||
|
const { generateTotpSecret, validateTotpToken } = await import('./totp-validation.js');
|
||||||
|
|
||||||
|
describe('generateTotpSecret', () => {
|
||||||
|
it('should generate a secret', () => {
|
||||||
|
expect(typeof generateTotpSecret()).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTotpToken', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(new Date(1_695_010_563_617));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true on valid token', () => {
|
||||||
|
const secret = 'JBSWY3DPEHPK3PXP';
|
||||||
|
const token = '971144';
|
||||||
|
|
||||||
|
expect(validateTotpToken(secret, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on invalid token', () => {
|
||||||
|
const secret = 'JBSWY3DPEHPK3PXP';
|
||||||
|
const token = '123456';
|
||||||
|
|
||||||
|
expect(validateTotpToken(secret, token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { authenticator } from 'otplib';
|
||||||
|
|
||||||
|
export const generateTotpSecret = () => authenticator.generateSecret();
|
||||||
|
|
||||||
|
export const validateTotpToken = (secret: string, token: string) =>
|
||||||
|
authenticator.check(token, secret);
|
|
@ -2,3 +2,4 @@ export { default as verifyProfile } from './profile-verification.js';
|
||||||
export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js';
|
export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js';
|
||||||
export { default as verifyIdentifierPayload } from './identifier-payload-verification.js';
|
export { default as verifyIdentifierPayload } from './identifier-payload-verification.js';
|
||||||
export { default as verifyIdentifier } from './identifier-verification.js';
|
export { default as verifyIdentifier } from './identifier-verification.js';
|
||||||
|
export * from './mfa-verification.js';
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { InteractionEvent, MfaFactor } from '@logto/schemas';
|
||||||
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsm } = createMockUtils(jest);
|
||||||
|
|
||||||
|
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
|
||||||
|
validateTotpToken: jest.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { bindMfaPayloadVerification } = await import('./mfa-payload-verification.js');
|
||||||
|
|
||||||
|
describe('bindMfaPayloadVerification', () => {
|
||||||
|
const baseCtx = {
|
||||||
|
...createContextWithRouteParameters(),
|
||||||
|
...createMockLogContext(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const interaction: IdentifierVerifiedInteractionResult = {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||||
|
accountId: 'foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('totp', () => {
|
||||||
|
it('should return result of BindMfa', async () => {
|
||||||
|
await expect(
|
||||||
|
bindMfaPayloadVerification(
|
||||||
|
baseCtx,
|
||||||
|
{ type: MfaFactor.TOTP, code: '123456' },
|
||||||
|
{
|
||||||
|
...interaction,
|
||||||
|
pendingMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateTotpToken).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when pendingMfa is missing', async () => {
|
||||||
|
await expect(
|
||||||
|
bindMfaPayloadVerification(baseCtx, { type: MfaFactor.TOTP, code: '123456' }, interaction)
|
||||||
|
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when code is invalid', async () => {
|
||||||
|
validateTotpToken.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bindMfaPayloadVerification(
|
||||||
|
baseCtx,
|
||||||
|
{ type: MfaFactor.TOTP, code: '123456' },
|
||||||
|
{
|
||||||
|
...interaction,
|
||||||
|
pendingMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
MfaFactor,
|
||||||
|
type BindTotp,
|
||||||
|
type BindTotpPayload,
|
||||||
|
type BindMfaPayload,
|
||||||
|
type BindMfa,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
|
||||||
|
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import type { AnonymousInteractionResult } from '../types/index.js';
|
||||||
|
import { validateTotpToken } from '../utils/totp-validation.js';
|
||||||
|
|
||||||
|
const verifyBindTotp = async (
|
||||||
|
interactionStorage: AnonymousInteractionResult,
|
||||||
|
payload: BindTotpPayload,
|
||||||
|
ctx: WithLogContext
|
||||||
|
): Promise<BindTotp> => {
|
||||||
|
const { event, pendingMfa } = interactionStorage;
|
||||||
|
ctx.createLog(`Interaction.${event}.BindMfa.Totp.Submit`);
|
||||||
|
|
||||||
|
assertThat(pendingMfa, 'session.mfa.pending_info_not_found');
|
||||||
|
// Will add more type, disable the rule for now, this can be a reminder when adding new type
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
assertThat(pendingMfa.type === MfaFactor.TOTP, 'session.mfa.pending_info_not_found');
|
||||||
|
|
||||||
|
const { code, type } = payload;
|
||||||
|
const { secret } = pendingMfa;
|
||||||
|
|
||||||
|
assertThat(validateTotpToken(secret, code), 'session.mfa.invalid_totp_code');
|
||||||
|
|
||||||
|
return { type, secret };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function bindMfaPayloadVerification(
|
||||||
|
ctx: WithLogContext,
|
||||||
|
bindMfaPayload: BindMfaPayload,
|
||||||
|
interactionStorage: AnonymousInteractionResult
|
||||||
|
): Promise<BindMfa> {
|
||||||
|
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
|
||||||
|
}
|
|
@ -0,0 +1,190 @@
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { PasswordPolicyChecker } from '@logto/core-kit';
|
||||||
|
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||||
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
|
import { mockUser } from '#src/__mocks__/user.js';
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||||
|
|
||||||
|
import { verifyBindMfa } from './mfa-verification.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
|
||||||
|
const findUserById = jest.fn();
|
||||||
|
|
||||||
|
const tenantContext = new MockTenant(undefined, {
|
||||||
|
users: {
|
||||||
|
findUserById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { validateMandatoryBindMfa } = await import('./mfa-verification.js');
|
||||||
|
|
||||||
|
const baseCtx = {
|
||||||
|
...createContextWithRouteParameters(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
|
||||||
|
signInExperience: {
|
||||||
|
...mockSignInExperience,
|
||||||
|
mfa: {
|
||||||
|
factors: [],
|
||||||
|
policy: MfaPolicy.UserControlled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passwordPolicyChecker: new PasswordPolicyChecker(
|
||||||
|
mockSignInExperience.passwordPolicy,
|
||||||
|
crypto.subtle
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mfaRequiredCtx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
|
...mockSignInExperience,
|
||||||
|
mfa: {
|
||||||
|
factors: [MfaFactor.TOTP],
|
||||||
|
policy: MfaPolicy.Mandatory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const interaction: IdentifierVerifiedInteractionResult = {
|
||||||
|
event: InteractionEvent.Register,
|
||||||
|
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const signInInteraction: IdentifierVerifiedInteractionResult = {
|
||||||
|
event: InteractionEvent.SignIn,
|
||||||
|
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||||
|
accountId: 'foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateMandatoryBindMfa', () => {
|
||||||
|
describe('register', () => {
|
||||||
|
it('bindMfa missing but required should throw', async () => {
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, interaction)
|
||||||
|
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindMfa exists should pass', async () => {
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, {
|
||||||
|
...interaction,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'foo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindMfa missing and not required should pass', async () => {
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signIn', () => {
|
||||||
|
it('user mfaVerifications and bindMfa missing but required should throw', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, signInInteraction)
|
||||||
|
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user mfaVerifications and bindMfa missing and not required should pass', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user mfaVerifications missing, bindMfa existing and required should pass', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, {
|
||||||
|
...signInInteraction,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'foo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user mfaVerifications existing, bindMfa missing and required should pass', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce({
|
||||||
|
...mockUser,
|
||||||
|
mfaVerifications: [
|
||||||
|
{
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyBindMfa', () => {
|
||||||
|
it('should pass if bindMfa is missing', async () => {
|
||||||
|
await expect(verifyBindMfa(tenantContext, signInInteraction)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass if event is not sign in', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyBindMfa(tenantContext, {
|
||||||
|
...interaction,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'foo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pass if the user has no TOTP factor', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
|
await expect(
|
||||||
|
verifyBindMfa(tenantContext, {
|
||||||
|
...signInInteraction,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'foo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject if the user already has a TOTP factor', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce({
|
||||||
|
...mockUser,
|
||||||
|
mfaVerifications: [
|
||||||
|
{
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'secret',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
verifyBindMfa(tenantContext, {
|
||||||
|
...signInInteraction,
|
||||||
|
bindMfa: {
|
||||||
|
type: MfaFactor.TOTP,
|
||||||
|
secret: 'foo',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toMatchError(new RequestError({ code: 'user.totp_already_in_use', status: 422 }));
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
||||||
|
import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
|
||||||
|
import {
|
||||||
|
type VerifiedSignInInteractionResult,
|
||||||
|
type VerifiedInteractionResult,
|
||||||
|
type VerifiedRegisterInteractionResult,
|
||||||
|
} from '../types/index.js';
|
||||||
|
|
||||||
|
export const verifyBindMfa = async (
|
||||||
|
tenant: TenantContext,
|
||||||
|
interaction: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult
|
||||||
|
): Promise<VerifiedInteractionResult> => {
|
||||||
|
const { bindMfa, event } = interaction;
|
||||||
|
|
||||||
|
if (!bindMfa || event !== InteractionEvent.SignIn) {
|
||||||
|
return interaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type } = bindMfa;
|
||||||
|
|
||||||
|
// There will be more types later
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (type === MfaFactor.TOTP) {
|
||||||
|
const { accountId } = interaction;
|
||||||
|
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
|
||||||
|
|
||||||
|
// A user can only bind one TOTP factor
|
||||||
|
assertThat(
|
||||||
|
mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.totp_already_in_use',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateMandatoryBindMfa = async (
|
||||||
|
tenant: TenantContext,
|
||||||
|
ctx: WithInteractionSieContext & WithInteractionDetailsContext,
|
||||||
|
interaction: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult
|
||||||
|
): Promise<VerifiedInteractionResult> => {
|
||||||
|
const {
|
||||||
|
mfa: { policy, factors },
|
||||||
|
} = ctx.signInExperience;
|
||||||
|
const { event, bindMfa } = interaction;
|
||||||
|
|
||||||
|
if (policy !== MfaPolicy.Mandatory) {
|
||||||
|
return interaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasEnoughBindFactor = Boolean(bindMfa && factors.includes(bindMfa.type));
|
||||||
|
|
||||||
|
if (event === InteractionEvent.Register) {
|
||||||
|
assertThat(
|
||||||
|
hasEnoughBindFactor,
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.missing_mfa',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === InteractionEvent.SignIn) {
|
||||||
|
const { accountId } = interaction;
|
||||||
|
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
|
||||||
|
assertThat(
|
||||||
|
hasEnoughBindFactor ||
|
||||||
|
factors.some((factor) => mfaVerifications.some(({ type }) => type === factor)),
|
||||||
|
new RequestError({
|
||||||
|
code: 'user.missing_mfa',
|
||||||
|
status: 422,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction;
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { jsonObjectGuard } from '../foundations/index.js';
|
import { MfaFactor, jsonObjectGuard } from '../foundations/index.js';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EmailVerificationCodePayload,
|
EmailVerificationCodePayload,
|
||||||
|
@ -100,3 +100,38 @@ export enum MissingProfile {
|
||||||
password = 'password',
|
password = 'password',
|
||||||
emailOrPhone = 'emailOrPhone',
|
emailOrPhone = 'emailOrPhone',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const bindTotpPayloadGuard = z.object({
|
||||||
|
// Unlike identifier payload which has indicator like "email",
|
||||||
|
// mfa payload must have an additional type field to indicate type
|
||||||
|
type: z.literal(MfaFactor.TOTP),
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BindTotpPayload = z.infer<typeof bindTotpPayloadGuard>;
|
||||||
|
|
||||||
|
export const bindMfaPayloadGuard = bindTotpPayloadGuard;
|
||||||
|
|
||||||
|
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
||||||
|
|
||||||
|
export const pendingTotpGuard = z.object({
|
||||||
|
type: z.literal(MfaFactor.TOTP),
|
||||||
|
secret: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PendingTotp = z.infer<typeof pendingTotpGuard>;
|
||||||
|
|
||||||
|
// Some information like TOTP secret should be generated in the backend
|
||||||
|
// and stored in the interaction temporarily.
|
||||||
|
export const pendingMfaGuard = pendingTotpGuard;
|
||||||
|
|
||||||
|
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
|
||||||
|
|
||||||
|
export const bindTotpGuard = pendingTotpGuard;
|
||||||
|
|
||||||
|
export type BindTotp = z.infer<typeof bindTotpGuard>;
|
||||||
|
|
||||||
|
// The type for binding new mfa verification to a user, not always equals to the pending type.
|
||||||
|
export const bindMfaGuard = bindTotpGuard;
|
||||||
|
|
||||||
|
export type BindMfa = z.infer<typeof bindMfaGuard>;
|
||||||
|
|
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
|
@ -3247,6 +3247,9 @@ importers:
|
||||||
oidc-provider:
|
oidc-provider:
|
||||||
specifier: ^8.2.2
|
specifier: ^8.2.2
|
||||||
version: 8.2.2
|
version: 8.2.2
|
||||||
|
otplib:
|
||||||
|
specifier: ^12.0.1
|
||||||
|
version: 12.0.1
|
||||||
p-retry:
|
p-retry:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
|
@ -7756,6 +7759,39 @@ packages:
|
||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/core@12.0.1:
|
||||||
|
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/plugin-crypto@12.0.1:
|
||||||
|
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/plugin-thirty-two@12.0.1:
|
||||||
|
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
thirty-two: 1.0.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/preset-default@12.0.1:
|
||||||
|
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@otplib/preset-v11@12.0.1:
|
||||||
|
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@parcel/bundler-default@2.9.3(@parcel/core@2.9.3):
|
/@parcel/bundler-default@2.9.3(@parcel/core@2.9.3):
|
||||||
resolution: {integrity: sha512-JjJK8dq39/UO/MWI/4SCbB1t/qgpQRFnFDetAAAezQ8oN++b24u1fkMDa/xqQGjbuPmGeTds5zxGgYs7id7PYg==}
|
resolution: {integrity: sha512-JjJK8dq39/UO/MWI/4SCbB1t/qgpQRFnFDetAAAezQ8oN++b24u1fkMDa/xqQGjbuPmGeTds5zxGgYs7id7PYg==}
|
||||||
engines: {node: '>= 12.0.0', parcel: ^2.9.3}
|
engines: {node: '>= 12.0.0', parcel: ^2.9.3}
|
||||||
|
@ -16637,6 +16673,14 @@ packages:
|
||||||
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
/otplib@12.0.1:
|
||||||
|
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||||
|
dependencies:
|
||||||
|
'@otplib/core': 12.0.1
|
||||||
|
'@otplib/preset-default': 12.0.1
|
||||||
|
'@otplib/preset-v11': 12.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/outdent@0.5.0:
|
/outdent@0.5.0:
|
||||||
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -19655,6 +19699,11 @@ packages:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/thirty-two@1.0.2:
|
||||||
|
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||||
|
engines: {node: '>=0.2.6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/through2@2.0.5:
|
/through2@2.0.5:
|
||||||
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
|
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Add table
Reference in a new issue