diff --git a/packages/core/src/lib/passcode.test.ts b/packages/core/src/lib/passcode.test.ts new file mode 100644 index 000000000..740be24e3 --- /dev/null +++ b/packages/core/src/lib/passcode.test.ts @@ -0,0 +1,80 @@ +import { PasscodeType } from '@logto/schemas'; + +import { + deletePasscodesByIds, + findUnconsumedPasscodesBySessionIdAndType, + insertPasscode, +} from '@/queries/passcode'; + +import { createPasscode, passcodeLength } from './passcode'; + +jest.mock('@/queries/passcode'); + +const mockedFindUnconsumedPasscodesBySessionIdAndType = + findUnconsumedPasscodesBySessionIdAndType as jest.MockedFunction< + typeof findUnconsumedPasscodesBySessionIdAndType + >; +const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction< + typeof deletePasscodesByIds +>; +const mockedInsertPasscode = insertPasscode as jest.MockedFunction; + +beforeAll(() => { + mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([]); + mockedInsertPasscode.mockImplementation(async (data) => ({ + ...data, + createdAt: Date.now(), + phone: data.phone ?? null, + email: data.email ?? null, + consumed: data.consumed ?? false, + tryCount: data.tryCount ?? 0, + })); +}); + +afterEach(() => { + mockedFindUnconsumedPasscodesBySessionIdAndType.mockClear(); + mockedDeletePasscodesByIds.mockClear(); + mockedInsertPasscode.mockClear(); +}); + +describe('createPasscode', () => { + it('should generate `passcodeLength` digits code for phone and insert to database', async () => { + const phone = '13000000000'; + const passcode = await createPasscode('sessionId', PasscodeType.SignIn, { + phone, + }); + expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); + expect(passcode.phone).toEqual(phone); + }); + + it('should generate `passcodeLength` digits code for email and insert to database', async () => { + const email = 'jony@example.com'; + const passcode = await createPasscode('sessionId', PasscodeType.SignIn, { + email, + }); + expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); + expect(passcode.email).toEqual(email); + }); + + it('should disable existing passcode', async () => { + const email = 'jony@example.com'; + const sessionId = 'sessonId'; + mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([ + { + id: 'id', + sessionId, + code: '1234', + type: PasscodeType.SignIn, + createdAt: Date.now(), + phone: '', + email, + consumed: false, + tryCount: 0, + }, + ]); + await createPasscode(sessionId, PasscodeType.SignIn, { + email, + }); + expect(mockedDeletePasscodesByIds).toHaveBeenCalledWith(['id']); + }); +}); diff --git a/packages/core/src/lib/passcode.ts b/packages/core/src/lib/passcode.ts new file mode 100644 index 000000000..51ae8ad56 --- /dev/null +++ b/packages/core/src/lib/passcode.ts @@ -0,0 +1,32 @@ +import { PasscodeType } from '@logto/schemas'; +import { customAlphabet, nanoid } from 'nanoid'; + +import { + deletePasscodesByIds, + findUnconsumedPasscodesBySessionIdAndType, + insertPasscode, +} from '@/queries/passcode'; + +export const passcodeLength = 6; +const randomCode = customAlphabet('1234567890', passcodeLength); + +export const createPasscode = async ( + sessionId: string, + type: PasscodeType, + payload: { phone: string } | { email: string } +) => { + // Disable existing passcodes. + const passcodes = await findUnconsumedPasscodesBySessionIdAndType(sessionId, type); + + if (passcodes.length > 0) { + await deletePasscodesByIds(passcodes.map(({ id }) => id)); + } + + return insertPasscode({ + id: nanoid(), + sessionId, + type, + code: randomCode(), + ...payload, + }); +}; diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts new file mode 100644 index 000000000..9b5f1ddc9 --- /dev/null +++ b/packages/core/src/queries/passcode.ts @@ -0,0 +1,58 @@ +import { PasscodeType, Passcode, Passcodes, CreatePasscode } from '@logto/schemas'; +import { sql } from 'slonik'; + +import { buildInsertInto } from '@/database/insert-into'; +import pool from '@/database/pool'; +import { buildUpdateWhere } from '@/database/update-where'; +import { convertToIdentifiers } from '@/database/utils'; +import { DeletionError } from '@/errors/SlonikError'; + +const { table, fields } = convertToIdentifiers(Passcodes); + +export const findUnconsumedPasscodeBySessionIdAndType = async ( + sessionId: string, + type: PasscodeType +) => + pool.one(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.sessionId}=${sessionId} and ${fields.type}=${type} and ${fields.consumed} = false + `); + +export const findUnconsumedPasscodesBySessionIdAndType = async ( + sessionId: string, + type: PasscodeType +) => + pool.many(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.sessionId}=${sessionId} and ${fields.type}=${type} and ${fields.consumed} = false + `); + +export const insertPasscode = buildInsertInto(pool, Passcodes, { + returning: true, +}); + +export const updatePasscode = buildUpdateWhere(pool, Passcodes, true); + +export const deletePasscodeById = async (id: string) => { + const { rowCount } = await pool.query(sql` + delete from ${table} + where id=${id} + `); + + if (rowCount < 1) { + throw new DeletionError(); + } +}; + +export const deletePasscodesByIds = async (ids: string[]) => { + const { rowCount } = await pool.query(sql` + delete from ${table} + where id in (${ids.join(',')}) + `); + + if (rowCount < 1) { + throw new DeletionError(); + } +};