0
Fork 0
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:
Gao Sun 2023-01-10 18:28:19 +08:00
parent 9bec890e6f
commit 6abdd05a40
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
10 changed files with 181 additions and 161 deletions

View file

@ -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([]);

View file

@ -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 };
};

View file

@ -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;

View file

@ -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);
});

View file

@ -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;

View file

@ -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();
}

View file

@ -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);
};

View file

@ -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 });

View file

@ -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 }

View file

@ -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) {}
}