mirror of
https://github.com/logto-io/logto.git
synced 2025-02-10 21:58:23 -05:00
refactor(core): migrate passcode library to factory mode
This commit is contained in:
parent
9bec890e6f
commit
6abdd05a40
10 changed files with 181 additions and 161 deletions
|
@ -1,15 +1,29 @@
|
|||
import { ConnectorType, VerificationCodeType } from '@logto/connector-kit';
|
||||
import { Passcode } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { any } from 'zod';
|
||||
|
||||
import { mockConnector, mockMetadata } from '#src/__mocks__/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
import { defaultConnectorMethods } from '#src/utils/connectors/consts.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
import {
|
||||
createPasscodeLibrary,
|
||||
passcodeExpiration,
|
||||
passcodeMaxTryCount,
|
||||
passcodeLength,
|
||||
} from './passcode.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const passcodeQueries = {
|
||||
findUnconsumedPasscodesByJtiAndType: jest.fn(),
|
||||
findUnconsumedPasscodeByJtiAndType: jest.fn(),
|
||||
deletePasscodesByIds: jest.fn(),
|
||||
insertPasscode: jest.fn(),
|
||||
consumePasscode: jest.fn(),
|
||||
increasePasscodeTryCount: jest.fn(),
|
||||
};
|
||||
const {
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
|
@ -17,27 +31,15 @@ const {
|
|||
increasePasscodeTryCount,
|
||||
insertPasscode,
|
||||
consumePasscode,
|
||||
} = mockEsm('#src/queries/passcode.js', () => ({
|
||||
findUnconsumedPasscodesByJtiAndType: jest.fn(),
|
||||
findUnconsumedPasscodeByJtiAndType: jest.fn(),
|
||||
deletePasscodesByIds: jest.fn(),
|
||||
insertPasscode: jest.fn(),
|
||||
consumePasscode: jest.fn(),
|
||||
increasePasscodeTryCount: jest.fn(),
|
||||
}));
|
||||
} = passcodeQueries;
|
||||
|
||||
const { getLogtoConnectors } = mockEsm('#src/libraries/connector.js', () => ({
|
||||
getLogtoConnectors: jest.fn(),
|
||||
}));
|
||||
const getLogtoConnectors = jest.fn();
|
||||
|
||||
const {
|
||||
createPasscode,
|
||||
passcodeExpiration,
|
||||
passcodeMaxTryCount,
|
||||
passcodeLength,
|
||||
sendPasscode,
|
||||
verifyPasscode,
|
||||
} = await import('./passcode.js');
|
||||
const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary(
|
||||
new MockQueries({ passcodes: passcodeQueries }),
|
||||
// @ts-expect-error
|
||||
{ getLogtoConnectors }
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
|
||||
|
|
|
@ -8,15 +8,8 @@ import type { Passcode } from '@logto/schemas';
|
|||
import { customAlphabet, nanoid } from 'nanoid';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { getLogtoConnectors } from '#src/libraries/connector.js';
|
||||
import {
|
||||
consumePasscode,
|
||||
deletePasscodesByIds,
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
increasePasscodeTryCount,
|
||||
insertPasscode,
|
||||
} from '#src/queries/passcode.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { ConnectorType } from '#src/utils/connectors/types.js';
|
||||
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
||||
|
@ -24,104 +17,120 @@ import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
|||
export const passcodeLength = 6;
|
||||
const randomCode = customAlphabet('1234567890', passcodeLength);
|
||||
|
||||
export const createPasscode = async (
|
||||
jti: string,
|
||||
type: VerificationCodeType,
|
||||
payload: { phone: string } | { email: string }
|
||||
) => {
|
||||
// Disable existing passcodes.
|
||||
const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type);
|
||||
|
||||
if (passcodes.length > 0) {
|
||||
await deletePasscodesByIds(passcodes.map(({ id }) => id));
|
||||
}
|
||||
|
||||
return insertPasscode({
|
||||
id: nanoid(),
|
||||
interactionJti: jti,
|
||||
type,
|
||||
code: randomCode(),
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendPasscode = async (passcode: Passcode) => {
|
||||
const emailOrPhone = passcode.email ?? passcode.phone;
|
||||
|
||||
if (!emailOrPhone) {
|
||||
throw new RequestError('verification_code.phone_email_empty');
|
||||
}
|
||||
|
||||
const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email;
|
||||
const connectors = await getLogtoConnectors();
|
||||
|
||||
const connector = connectors.find(
|
||||
(connector): connector is LogtoConnector<SmsConnector | EmailConnector> =>
|
||||
connector.type === expectType
|
||||
);
|
||||
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'connector.not_found',
|
||||
type: expectType,
|
||||
})
|
||||
);
|
||||
|
||||
const { dbEntry, metadata, sendMessage } = connector;
|
||||
|
||||
const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type);
|
||||
|
||||
if (!messageTypeResult.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
|
||||
}
|
||||
|
||||
const response = await sendMessage({
|
||||
to: emailOrPhone,
|
||||
type: messageTypeResult.data,
|
||||
payload: {
|
||||
code: passcode.code,
|
||||
},
|
||||
});
|
||||
|
||||
return { dbEntry, metadata, response };
|
||||
};
|
||||
|
||||
export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes.
|
||||
export const passcodeMaxTryCount = 10;
|
||||
|
||||
export const verifyPasscode = async (
|
||||
sessionId: string,
|
||||
type: VerificationCodeType,
|
||||
code: string,
|
||||
payload: { phone: string } | { email: string }
|
||||
): Promise<void> => {
|
||||
const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type);
|
||||
export type PasscodeLibrary = ReturnType<typeof createPasscodeLibrary>;
|
||||
|
||||
if (!passcode) {
|
||||
throw new RequestError('verification_code.not_found');
|
||||
}
|
||||
export const createPasscodeLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => {
|
||||
const {
|
||||
consumePasscode,
|
||||
deletePasscodesByIds,
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
increasePasscodeTryCount,
|
||||
insertPasscode,
|
||||
} = queries.passcodes;
|
||||
const { getLogtoConnectors } = connectorLibrary;
|
||||
|
||||
if ('phone' in payload && passcode.phone !== payload.phone) {
|
||||
throw new RequestError('verification_code.phone_mismatch');
|
||||
}
|
||||
const createPasscode = async (
|
||||
jti: string,
|
||||
type: VerificationCodeType,
|
||||
payload: { phone: string } | { email: string }
|
||||
) => {
|
||||
// Disable existing passcodes.
|
||||
const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type);
|
||||
|
||||
if ('email' in payload && passcode.email !== payload.email) {
|
||||
throw new RequestError('verification_code.email_mismatch');
|
||||
}
|
||||
if (passcodes.length > 0) {
|
||||
await deletePasscodesByIds(passcodes.map(({ id }) => id));
|
||||
}
|
||||
|
||||
if (passcode.createdAt + passcodeExpiration < Date.now()) {
|
||||
throw new RequestError('verification_code.expired');
|
||||
}
|
||||
return insertPasscode({
|
||||
id: nanoid(),
|
||||
interactionJti: jti,
|
||||
type,
|
||||
code: randomCode(),
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
|
||||
if (passcode.tryCount >= passcodeMaxTryCount) {
|
||||
throw new RequestError('verification_code.exceed_max_try');
|
||||
}
|
||||
const sendPasscode = async (passcode: Passcode) => {
|
||||
const emailOrPhone = passcode.email ?? passcode.phone;
|
||||
|
||||
if (code !== passcode.code) {
|
||||
await increasePasscodeTryCount(passcode.id);
|
||||
throw new RequestError('verification_code.code_mismatch');
|
||||
}
|
||||
if (!emailOrPhone) {
|
||||
throw new RequestError('verification_code.phone_email_empty');
|
||||
}
|
||||
|
||||
await consumePasscode(passcode.id);
|
||||
const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email;
|
||||
const connectors = await getLogtoConnectors();
|
||||
|
||||
const connector = connectors.find(
|
||||
(connector): connector is LogtoConnector<SmsConnector | EmailConnector> =>
|
||||
connector.type === expectType
|
||||
);
|
||||
|
||||
assertThat(
|
||||
connector,
|
||||
new RequestError({
|
||||
code: 'connector.not_found',
|
||||
type: expectType,
|
||||
})
|
||||
);
|
||||
|
||||
const { dbEntry, metadata, sendMessage } = connector;
|
||||
|
||||
const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type);
|
||||
|
||||
if (!messageTypeResult.success) {
|
||||
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
|
||||
}
|
||||
|
||||
const response = await sendMessage({
|
||||
to: emailOrPhone,
|
||||
type: messageTypeResult.data,
|
||||
payload: {
|
||||
code: passcode.code,
|
||||
},
|
||||
});
|
||||
|
||||
return { dbEntry, metadata, response };
|
||||
};
|
||||
|
||||
const verifyPasscode = async (
|
||||
sessionId: string,
|
||||
type: VerificationCodeType,
|
||||
code: string,
|
||||
payload: { phone: string } | { email: string }
|
||||
): Promise<void> => {
|
||||
const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type);
|
||||
|
||||
if (!passcode) {
|
||||
throw new RequestError('verification_code.not_found');
|
||||
}
|
||||
|
||||
if ('phone' in payload && passcode.phone !== payload.phone) {
|
||||
throw new RequestError('verification_code.phone_mismatch');
|
||||
}
|
||||
|
||||
if ('email' in payload && passcode.email !== payload.email) {
|
||||
throw new RequestError('verification_code.email_mismatch');
|
||||
}
|
||||
|
||||
if (passcode.createdAt + passcodeExpiration < Date.now()) {
|
||||
throw new RequestError('verification_code.expired');
|
||||
}
|
||||
|
||||
if (passcode.tryCount >= passcodeMaxTryCount) {
|
||||
throw new RequestError('verification_code.exceed_max_try');
|
||||
}
|
||||
|
||||
if (code !== passcode.code) {
|
||||
await increasePasscodeTryCount(passcode.id);
|
||||
throw new RequestError('verification_code.code_mismatch');
|
||||
}
|
||||
|
||||
await consumePasscode(passcode.id);
|
||||
};
|
||||
|
||||
return { createPasscode, sendPasscode, verifyPasscode };
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import resource from '@logto/phrases-ui';
|
||||
import type { CustomPhrase } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
import {
|
||||
|
@ -17,8 +16,6 @@ import { MockQueries } from '#src/test-utils/tenant.js';
|
|||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
const englishBuiltInPhrase = resource[enTag];
|
||||
|
||||
const customOnlyLanguage = zhHkTag;
|
||||
|
|
|
@ -104,29 +104,31 @@ describe('interaction routes', () => {
|
|||
client_id: demoAppApplicationId,
|
||||
};
|
||||
|
||||
const tenantContext = new MockTenant(
|
||||
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||
undefined,
|
||||
{
|
||||
connectors: {
|
||||
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 sessionRequest = createRequester({
|
||||
anonymousRoutes: interactionRoutes,
|
||||
tenantContext: new MockTenant(
|
||||
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
|
||||
undefined,
|
||||
{
|
||||
connectors: {
|
||||
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;
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
tenantContext,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -313,7 +315,8 @@ describe('interaction routes', () => {
|
|||
...body,
|
||||
},
|
||||
'jti',
|
||||
createLog
|
||||
createLog,
|
||||
tenantContext.libraries.passcodes
|
||||
);
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
|
|
@ -45,7 +45,7 @@ export type RouterContext<T> = T extends Router<unknown, infer Context> ? Contex
|
|||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
...[anonymousRouter, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { provider, queries } = tenant;
|
||||
const { provider, queries, libraries } = tenant;
|
||||
const router =
|
||||
// @ts-expect-error for good koa types
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -338,7 +338,8 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
await sendVerificationCodeToIdentifier(
|
||||
{ event, ...guard.body },
|
||||
interactionDetails.jti,
|
||||
createLog
|
||||
createLog,
|
||||
libraries.passcodes
|
||||
);
|
||||
|
||||
ctx.status = 204;
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
import { VerificationCodeType } from '@logto/connector-kit';
|
||||
import { InteractionEvent } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const passcode = {
|
||||
createPasscode: jest.fn(() => ({})),
|
||||
sendPasscode: jest.fn().mockResolvedValue({ dbEntry: { id: 'foo' } }),
|
||||
};
|
||||
|
||||
await mockEsmWithActual('#src/libraries/passcode.js', () => passcode);
|
||||
|
||||
const { sendVerificationCodeToIdentifier } = await import('./verification-code-validation.js');
|
||||
|
||||
const sendVerificationCodeTestCase = [
|
||||
|
@ -53,7 +49,8 @@ describe('verification-code-validation utils', () => {
|
|||
it.each(sendVerificationCodeTestCase)(
|
||||
'send verification code successfully',
|
||||
async ({ payload, createVerificationCodeParams }) => {
|
||||
await sendVerificationCodeToIdentifier(payload, 'jti', log.createLog);
|
||||
// @ts-expect-error
|
||||
await sendVerificationCodeToIdentifier(payload, 'jti', log.createLog, passcode);
|
||||
expect(passcode.createPasscode).toBeCalledWith('jti', ...createVerificationCodeParams);
|
||||
expect(passcode.sendPasscode).toBeCalled();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { VerificationCodeType } from '@logto/connector-kit';
|
||||
import type { InteractionEvent } from '@logto/schemas';
|
||||
|
||||
import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js';
|
||||
import type { PasscodeLibrary } from '#src/libraries/passcode.js';
|
||||
import type { LogContext } from '#src/middleware/koa-audit-log.js';
|
||||
|
||||
import type {
|
||||
|
@ -25,7 +25,8 @@ const getVerificationCodeTypeByEvent = (event: InteractionEvent): VerificationCo
|
|||
export const sendVerificationCodeToIdentifier = async (
|
||||
payload: SendVerificationCodePayload & { event: InteractionEvent },
|
||||
jti: string,
|
||||
createLog: LogContext['createLog']
|
||||
createLog: LogContext['createLog'],
|
||||
{ createPasscode, sendPasscode }: PasscodeLibrary
|
||||
) => {
|
||||
const { event, ...identifier } = payload;
|
||||
const messageType = getVerificationCodeTypeByEvent(event);
|
||||
|
@ -42,7 +43,8 @@ export const sendVerificationCodeToIdentifier = async (
|
|||
export const verifyIdentifierByVerificationCode = async (
|
||||
payload: VerificationCodeIdentifierPayload & { event: InteractionEvent },
|
||||
jti: string,
|
||||
createLog: LogContext['createLog']
|
||||
createLog: LogContext['createLog'],
|
||||
passcodeLibrary: PasscodeLibrary
|
||||
) => {
|
||||
const { event, verificationCode, ...identifier } = payload;
|
||||
const messageType = getVerificationCodeTypeByEvent(event);
|
||||
|
@ -50,5 +52,5 @@ export const verifyIdentifierByVerificationCode = async (
|
|||
const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`);
|
||||
log.append(identifier);
|
||||
|
||||
await verifyPasscode(jti, messageType, verificationCode, identifier);
|
||||
await passcodeLibrary.verifyPasscode(jti, messageType, verificationCode, identifier);
|
||||
};
|
||||
|
|
|
@ -128,7 +128,8 @@ describe('identifier verification', () => {
|
|||
expect(verifyIdentifierByVerificationCode).toBeCalledWith(
|
||||
{ ...identifier, event: interactionStorage.event },
|
||||
'jti',
|
||||
logContext.createLog
|
||||
logContext.createLog,
|
||||
tenant.libraries.passcodes
|
||||
);
|
||||
|
||||
expect(result).toEqual({ key: 'emailVerified', value: identifier.email });
|
||||
|
@ -147,7 +148,8 @@ describe('identifier verification', () => {
|
|||
expect(verifyIdentifierByVerificationCode).toBeCalledWith(
|
||||
{ ...identifier, event: interactionStorage.event },
|
||||
'jti',
|
||||
logContext.createLog
|
||||
logContext.createLog,
|
||||
tenant.libraries.passcodes
|
||||
);
|
||||
|
||||
expect(result).toEqual({ key: 'phoneVerified', value: identifier.phone });
|
||||
|
|
|
@ -54,11 +54,16 @@ const verifyVerificationCodeIdentifier = async (
|
|||
event: InteractionEvent,
|
||||
identifier: VerificationCodeIdentifierPayload,
|
||||
ctx: WithLogContext,
|
||||
{ provider }: TenantContext
|
||||
{ provider, libraries }: TenantContext
|
||||
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
await verifyIdentifierByVerificationCode({ ...identifier, event }, jti, ctx.createLog);
|
||||
await verifyIdentifierByVerificationCode(
|
||||
{ ...identifier, event },
|
||||
jti,
|
||||
ctx.createLog,
|
||||
libraries.passcodes
|
||||
);
|
||||
|
||||
return 'email' in identifier
|
||||
? { key: 'emailVerified', value: identifier.email }
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { createHookLibrary } from '#src/libraries/hook.js';
|
||||
import { createPasscodeLibrary } from '#src/libraries/passcode.js';
|
||||
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
||||
import { createResourceLibrary } from '#src/libraries/resource.js';
|
||||
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
|
||||
|
@ -17,6 +18,7 @@ export default class Libraries {
|
|||
resources = createResourceLibrary(this.queries);
|
||||
hooks = createHookLibrary(this.queries, this.modelRouters);
|
||||
socials = createSocialLibrary(this.queries, this.connectors);
|
||||
passcodes = createPasscodeLibrary(this.queries, this.connectors);
|
||||
|
||||
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue