diff --git a/packages/core/package.json b/packages/core/package.json index 8b7344b5b..e66c074ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,6 +69,7 @@ "lru-cache": "^10.0.0", "nanoid": "^4.0.0", "oidc-provider": "^8.2.2", + "otplib": "^12.0.1", "p-retry": "^6.0.0", "pg-protocol": "^1.6.0", "redis": "^4.6.5", diff --git a/packages/core/src/__mocks__/mfa-verification.ts b/packages/core/src/__mocks__/mfa-verification.ts new file mode 100644 index 000000000..a88749021 --- /dev/null +++ b/packages/core/src/__mocks__/mfa-verification.ts @@ -0,0 +1,6 @@ +import { MfaFactor, type BindMfa } from '@logto/schemas'; + +export const mockTotpBind: BindMfa = { + type: MfaFactor.TOTP, + secret: 'secret', +}; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index f26f7fcea..a82e41821 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -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 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', () => ({ getTenantId: () => adminTenantId, })); const userQueries = { - findUserById: jest - .fn() - .mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }), + findUserById: jest.fn().mockResolvedValue({ + identities: { google: { userId: 'googleId', details: {} } }, + mfaVerifications: [], + }), updateUserById: jest.fn(), 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 () => { hasActiveUsers.mockResolvedValueOnce(false); 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 () => { getLogtoConnectorById.mockResolvedValueOnce({ metadata: { target: 'logto' }, diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 0f156dff7..9aebb5a41 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -11,7 +11,7 @@ import { InteractionEvent, adminConsoleApplicationId, } from '@logto/schemas'; -import { type OmitAutoSetFields } from '@logto/shared'; +import { generateStandardId, type OmitAutoSetFields } from '@logto/shared'; import { conditional, conditionalArray, trySafe } from '@silverhand/essentials'; 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 = ( isInAdminTenant: boolean, isCreatingFirstAdminUser: boolean, @@ -220,6 +237,7 @@ export default async function submitInteraction( if (event === InteractionEvent.Register) { const id = await generateUserId(); const userProfile = await parseUserProfile(connectors, interaction); + const mfaVerification = parseBindMfa(interaction); const { client_id } = ctx.interactionDetails.params; @@ -234,6 +252,7 @@ export default async function submitInteraction( { id, ...userProfile, + ...conditional(mfaVerification && { mfaVerifications: [mfaVerification] }), }, getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud) ); @@ -265,8 +284,14 @@ export default async function submitInteraction( if (event === InteractionEvent.SignIn) { const user = await findUserById(accountId); 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 } }); ctx.assignInteractionHookResult({ userId: accountId }); diff --git a/packages/core/src/routes/interaction/additional.test.ts b/packages/core/src/routes/interaction/additional.test.ts new file mode 100644 index 000000000..b974813f1 --- /dev/null +++ b/packages/core/src/routes/interaction/additional.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/additional.ts b/packages/core/src/routes/interaction/additional.ts new file mode 100644 index 000000000..89623be97 --- /dev/null +++ b/packages/core/src/routes/interaction/additional.ts @@ -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( + router: Router>>, + 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(); + } + ); +} diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index b96158bbd..46ee6a4a1 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -11,10 +11,10 @@ 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'; +import { interactionPrefix } from './const.js'; const { jest } = import.meta; -const { mockEsm, mockEsmDefault, mockEsmWithActual } = createMockUtils(jest); +const { mockEsmDefault, mockEsmWithActual } = createMockUtils(jest); // FIXME @Darcy: no more `enabled` for `connectors` table const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { @@ -39,24 +39,30 @@ const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/ses assignInteractionResults: jest.fn(), })); -const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = mockEsm( - './utils/sign-in-experience-validation.js', - () => ({ +const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = + await mockEsmWithActual('./utils/sign-in-experience-validation.js', () => ({ verifySignInModeSettings: jest.fn(), verifyIdentifierSettings: jest.fn(), verifyProfileSettings: jest.fn(), - }) -); + })); const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn()); -const { verifyIdentifierPayload, verifyIdentifier, verifyProfile, validateMandatoryUserProfile } = - await mockEsmWithActual('./verifications/index.js', () => ({ - verifyIdentifierPayload: jest.fn(), - verifyIdentifier: jest.fn().mockResolvedValue({}), - verifyProfile: jest.fn(), - validateMandatoryUserProfile: jest.fn(), - })); +const { + verifyIdentifierPayload, + verifyIdentifier, + verifyProfile, + validateMandatoryUserProfile, + validateMandatoryBindMfa, + verifyBindMfa, +} = await mockEsmWithActual('./verifications/index.js', () => ({ + verifyIdentifierPayload: jest.fn(), + verifyIdentifier: jest.fn().mockResolvedValue({}), + verifyProfile: jest.fn(), + validateMandatoryUserProfile: jest.fn(), + validateMandatoryBindMfa: jest.fn(), + verifyBindMfa: jest.fn(), +})); const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual( './utils/interaction.js', @@ -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', () => ({ validatePassword: jest.fn(), })); @@ -175,20 +174,28 @@ describe('interaction routes', () => { jest.clearAllMocks(); }); - it('should call identifier and profile verification properly', async () => { + it('should call identifier, profile and bindMfa verification properly', async () => { verifyProfile.mockReturnValueOnce({ event: InteractionEvent.SignIn, }); + validateMandatoryUserProfile.mockReturnValueOnce({ + event: InteractionEvent.SignIn, + }); + verifyBindMfa.mockReturnValueOnce({ + event: InteractionEvent.SignIn, + }); await sessionRequest.post(path).send(); expect(getInteractionStorage).toBeCalled(); expect(verifyIdentifier).toBeCalled(); expect(verifyProfile).toBeCalled(); expect(validateMandatoryUserProfile).toBeCalled(); + expect(verifyBindMfa).toBeCalled(); + expect(validateMandatoryBindMfa).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({ event: InteractionEvent.ForgotPassword, }); @@ -196,12 +203,19 @@ describe('interaction routes', () => { verifyProfile.mockReturnValueOnce({ event: InteractionEvent.ForgotPassword, }); + validateMandatoryUserProfile.mockReturnValueOnce({ + event: InteractionEvent.ForgotPassword, + }); + verifyBindMfa.mockReturnValueOnce({ + event: InteractionEvent.ForgotPassword, + }); await sessionRequest.post(path).send(); expect(getInteractionStorage).toBeCalled(); expect(verifyIdentifier).toBeCalled(); expect(verifyProfile).toBeCalled(); expect(validateMandatoryUserProfile).not.toBeCalled(); + expect(validateMandatoryBindMfa).not.toBeCalled(); expect(submitInteraction).toBeCalled(); }); }); @@ -309,76 +323,4 @@ describe('interaction routes', () => { 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); - }); - }); }); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 28ab09fc9..9a7ecc808 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -1,11 +1,5 @@ import type { LogtoErrorCode } from '@logto/phrases'; -import { - InteractionEvent, - eventGuard, - identifierPayloadGuard, - profileGuard, - requestVerificationCodePayloadGuard, -} from '@logto/schemas'; +import { InteractionEvent, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; @@ -18,13 +12,14 @@ import assertThat from '#src/utils/assert-that.js'; import type { AnonymousRouter, RouterInitArgs } from '../types.js'; import submitInteraction from './actions/submit-interaction.js'; +import additionalRoutes from './additional.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 type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import koaInteractionHooks from './middleware/koa-interaction-hooks.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js'; -import { socialAuthorizationUrlPayloadGuard } from './types/guard.js'; import { getInteractionStorage, storeInteractionResult, @@ -36,14 +31,14 @@ import { verifyIdentifierSettings, verifyProfileSettings, } from './utils/sign-in-experience-validation.js'; -import { createSocialAuthorizationUrl } from './utils/social-verification.js'; import { validatePassword } from './utils/validate-password.js'; -import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js'; import { verifyIdentifierPayload, verifyIdentifier, verifyProfile, validateMandatoryUserProfile, + validateMandatoryBindMfa, + verifyBindMfa, } from './verifications/index.js'; export type RouterContext = T extends Router ? Context : never; @@ -340,68 +335,30 @@ export default function interactionRoutes( 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 : 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); 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); + additionalRoutes(router, tenant); + mfaRoutes(router, tenant); } diff --git a/packages/core/src/routes/interaction/mfa.test.ts b/packages/core/src/routes/interaction/mfa.test.ts new file mode 100644 index 000000000..7d7fcff9e --- /dev/null +++ b/packages/core/src/routes/interaction/mfa.test.ts @@ -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() + ); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/mfa.ts b/packages/core/src/routes/interaction/mfa.ts new file mode 100644 index 000000000..9ea1f76a0 --- /dev/null +++ b/packages/core/src/routes/interaction/mfa.ts @@ -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( + router: Router>>, + { 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(); + } + ); +} diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts index 57f52d9aa..24aef9e15 100644 --- a/packages/core/src/routes/interaction/types/guard.ts +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -1,6 +1,6 @@ import { socialUserInfoGuard } from '@logto/connector-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'; // Social Authorization Uri Route Payload Guard @@ -44,6 +44,10 @@ export const anonymousInteractionResultGuard = z.object({ profile: profileGuard.optional(), accountId: z.string().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({ diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index 7fd92d561..7f56b3f79 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -7,6 +7,7 @@ import type { SocialEmailPayload, SocialPhonePayload, Profile, + BindMfa, } from '@logto/schemas'; import type { z } from 'zod'; @@ -76,6 +77,7 @@ export type VerifiedRegisterInteractionResult = { event: InteractionEvent.Register; profile?: Profile; identifiers?: Identifier[]; + bindMfa?: BindMfa; }; export type VerifiedSignInInteractionResult = { @@ -83,6 +85,7 @@ export type VerifiedSignInInteractionResult = { accountId: string; identifiers: Identifier[]; profile?: Profile; + bindMfa?: BindMfa; }; export type VerifiedForgotPasswordInteractionResult = { diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts index abd157ee4..be0b00526 100644 --- a/packages/core/src/routes/interaction/utils/index.ts +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -1,8 +1,8 @@ -import type { - SocialConnectorPayload, - User, - IdentifierPayload, - VerifyVerificationCodePayload, +import { + type SocialConnectorPayload, + type User, + type IdentifierPayload, + type VerifyVerificationCodePayload, } from '@logto/schemas'; import type { PasswordIdentifierPayload } from '../types/index.js'; diff --git a/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts b/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts index 23c3e2810..9343068d8 100644 --- a/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts +++ b/packages/core/src/routes/interaction/utils/sign-in-experience-valiation.test.ts @@ -1,5 +1,5 @@ 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'; @@ -7,6 +7,7 @@ import { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings, + verifyMfaSettings, } from './sign-in-experience-validation.js'; describe('verifySignInModeSettings', () => { @@ -375,3 +376,27 @@ describe('profile validation', () => { }).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(); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts index 670991f7f..de98a1656 100644 --- a/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts +++ b/packages/core/src/routes/interaction/utils/sign-in-experience-validation.ts @@ -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 RequestError from '#src/errors/RequestError/index.js'; @@ -127,3 +127,11 @@ export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperi assertThat(signUp.password, forbiddenIdentifierError()); } }; + +export const verifyMfaSettings = (type: MfaFactor, signInExperience: SignInExperience) => { + const { + mfa: { factors }, + } = signInExperience; + + assertThat(factors.includes(type), forbiddenIdentifierError()); +}; diff --git a/packages/core/src/routes/interaction/utils/totp-validation.test.ts b/packages/core/src/routes/interaction/utils/totp-validation.test.ts new file mode 100644 index 000000000..ebb4994d2 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/totp-validation.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/totp-validation.ts b/packages/core/src/routes/interaction/utils/totp-validation.ts new file mode 100644 index 000000000..26d52c6da --- /dev/null +++ b/packages/core/src/routes/interaction/utils/totp-validation.ts @@ -0,0 +1,6 @@ +import { authenticator } from 'otplib'; + +export const generateTotpSecret = () => authenticator.generateSecret(); + +export const validateTotpToken = (secret: string, token: string) => + authenticator.check(token, secret); diff --git a/packages/core/src/routes/interaction/verifications/index.ts b/packages/core/src/routes/interaction/verifications/index.ts index 032721b05..403aa0655 100644 --- a/packages/core/src/routes/interaction/verifications/index.ts +++ b/packages/core/src/routes/interaction/verifications/index.ts @@ -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 verifyIdentifierPayload } from './identifier-payload-verification.js'; export { default as verifyIdentifier } from './identifier-verification.js'; +export * from './mfa-verification.js'; diff --git a/packages/core/src/routes/interaction/verifications/mfa-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.test.ts new file mode 100644 index 000000000..4a244ddf0 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.test.ts @@ -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>, + }; + + 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')); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts new file mode 100644 index 000000000..a38ad675c --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mfa-payload-verification.ts @@ -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 => { + 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 { + return verifyBindTotp(interactionStorage, bindMfaPayload, ctx); +} diff --git a/packages/core/src/routes/interaction/verifications/mfa-verification.test.ts b/packages/core/src/routes/interaction/verifications/mfa-verification.test.ts new file mode 100644 index 000000000..2ad15d46f --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mfa-verification.test.ts @@ -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>, + 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 })); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/mfa-verification.ts b/packages/core/src/routes/interaction/verifications/mfa-verification.ts new file mode 100644 index 000000000..fa8e2ac88 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/mfa-verification.ts @@ -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 => { + 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 => { + 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; +}; diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 6f3ed2cf9..5e001d7db 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -1,7 +1,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { z } from 'zod'; -import { jsonObjectGuard } from '../foundations/index.js'; +import { MfaFactor, jsonObjectGuard } from '../foundations/index.js'; import type { EmailVerificationCodePayload, @@ -100,3 +100,38 @@ export enum MissingProfile { password = 'password', 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; + +export const bindMfaPayloadGuard = bindTotpPayloadGuard; + +export type BindMfaPayload = z.infer; + +export const pendingTotpGuard = z.object({ + type: z.literal(MfaFactor.TOTP), + secret: z.string(), +}); + +export type PendingTotp = z.infer; + +// 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; + +export const bindTotpGuard = pendingTotpGuard; + +export type BindTotp = z.infer; + +// 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4877e8031..89b3db83c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3247,6 +3247,9 @@ importers: oidc-provider: specifier: ^8.2.2 version: 8.2.2 + otplib: + specifier: ^12.0.1 + version: 12.0.1 p-retry: specifier: ^6.0.0 version: 6.0.0 @@ -7756,6 +7759,39 @@ packages: tslib: 2.5.0 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): resolution: {integrity: sha512-JjJK8dq39/UO/MWI/4SCbB1t/qgpQRFnFDetAAAezQ8oN++b24u1fkMDa/xqQGjbuPmGeTds5zxGgYs7id7PYg==} engines: {node: '>= 12.0.0', parcel: ^2.9.3} @@ -16637,6 +16673,14 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} 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: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} dev: true @@ -19655,6 +19699,11 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} 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: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: