mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
fix(passcode): use interaction_jti instead of session_id (#207)
This commit is contained in:
parent
2b55c85b5a
commit
ffeabbedcf
5 changed files with 53 additions and 55 deletions
|
@ -5,8 +5,8 @@ import { ConnectorType } from '@/connectors/types';
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import {
|
||||
deletePasscodesByIds,
|
||||
findUnconsumedPasscodeBySessionIdAndType,
|
||||
findUnconsumedPasscodesBySessionIdAndType,
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
insertPasscode,
|
||||
updatePasscode,
|
||||
} from '@/queries/passcode';
|
||||
|
@ -23,13 +23,13 @@ import {
|
|||
jest.mock('@/queries/passcode');
|
||||
jest.mock('@/connectors');
|
||||
|
||||
const mockedFindUnconsumedPasscodesBySessionIdAndType =
|
||||
findUnconsumedPasscodesBySessionIdAndType as jest.MockedFunction<
|
||||
typeof findUnconsumedPasscodesBySessionIdAndType
|
||||
const mockedFindUnconsumedPasscodesByJtiAndType =
|
||||
findUnconsumedPasscodesByJtiAndType as jest.MockedFunction<
|
||||
typeof findUnconsumedPasscodesByJtiAndType
|
||||
>;
|
||||
const mockedFindUnconsumedPasscodeBySessionIdAndType =
|
||||
findUnconsumedPasscodeBySessionIdAndType as jest.MockedFunction<
|
||||
typeof findUnconsumedPasscodeBySessionIdAndType
|
||||
const mockedFindUnconsumedPasscodeByJtiAndType =
|
||||
findUnconsumedPasscodeByJtiAndType as jest.MockedFunction<
|
||||
typeof findUnconsumedPasscodeByJtiAndType
|
||||
>;
|
||||
const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction<
|
||||
typeof deletePasscodesByIds
|
||||
|
@ -41,7 +41,7 @@ const mockedGetConnectorInstanceByType = getConnectorInstanceByType as jest.Mock
|
|||
const mockedUpdatePasscode = updatePasscode as jest.MockedFunction<typeof updatePasscode>;
|
||||
|
||||
beforeAll(() => {
|
||||
mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([]);
|
||||
mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
|
||||
mockedInsertPasscode.mockImplementation(async (data) => ({
|
||||
...data,
|
||||
createdAt: Date.now(),
|
||||
|
@ -53,7 +53,7 @@ beforeAll(() => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedFindUnconsumedPasscodesBySessionIdAndType.mockClear();
|
||||
mockedFindUnconsumedPasscodesByJtiAndType.mockClear();
|
||||
mockedDeletePasscodesByIds.mockClear();
|
||||
mockedInsertPasscode.mockClear();
|
||||
mockedGetConnectorInstanceByType.mockClear();
|
||||
|
@ -62,7 +62,7 @@ afterEach(() => {
|
|||
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, {
|
||||
const passcode = await createPasscode('jti', PasscodeType.SignIn, {
|
||||
phone,
|
||||
});
|
||||
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
|
||||
|
@ -71,7 +71,7 @@ describe('createPasscode', () => {
|
|||
|
||||
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, {
|
||||
const passcode = await createPasscode('jti', PasscodeType.SignIn, {
|
||||
email,
|
||||
});
|
||||
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
|
||||
|
@ -80,11 +80,11 @@ describe('createPasscode', () => {
|
|||
|
||||
it('should disable existing passcode', async () => {
|
||||
const email = 'jony@example.com';
|
||||
const sessionId = 'sessonId';
|
||||
mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([
|
||||
const jti = 'jti';
|
||||
mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([
|
||||
{
|
||||
id: 'id',
|
||||
sessionId,
|
||||
interactionJti: jti,
|
||||
code: '1234',
|
||||
type: PasscodeType.SignIn,
|
||||
createdAt: Date.now(),
|
||||
|
@ -94,7 +94,7 @@ describe('createPasscode', () => {
|
|||
tryCount: 0,
|
||||
},
|
||||
]);
|
||||
await createPasscode(sessionId, PasscodeType.SignIn, {
|
||||
await createPasscode(jti, PasscodeType.SignIn, {
|
||||
email,
|
||||
});
|
||||
expect(mockedDeletePasscodesByIds).toHaveBeenCalledWith(['id']);
|
||||
|
@ -105,7 +105,7 @@ describe('sendPasscode', () => {
|
|||
it('should throw error when email and phone are both empty', async () => {
|
||||
const passcode: Passcode = {
|
||||
id: 'id',
|
||||
sessionId: 'sessionId',
|
||||
interactionJti: 'jti',
|
||||
phone: null,
|
||||
email: null,
|
||||
type: PasscodeType.SignIn,
|
||||
|
@ -134,7 +134,7 @@ describe('sendPasscode', () => {
|
|||
});
|
||||
const passcode: Passcode = {
|
||||
id: 'id',
|
||||
sessionId: 'sessionId',
|
||||
interactionJti: 'jti',
|
||||
phone: 'phone',
|
||||
email: null,
|
||||
type: PasscodeType.SignIn,
|
||||
|
@ -153,7 +153,7 @@ describe('sendPasscode', () => {
|
|||
describe('verifyPasscode', () => {
|
||||
const passcode: Passcode = {
|
||||
id: 'id',
|
||||
sessionId: 'sessionId',
|
||||
interactionJti: 'jti',
|
||||
phone: 'phone',
|
||||
email: null,
|
||||
type: PasscodeType.SignIn,
|
||||
|
@ -164,8 +164,8 @@ describe('verifyPasscode', () => {
|
|||
};
|
||||
|
||||
it('should mark as consumed on successful verification', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(passcode);
|
||||
await verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' });
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
|
||||
await verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' });
|
||||
expect(mockedUpdatePasscode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: { consumed: true },
|
||||
|
@ -174,54 +174,58 @@ describe('verifyPasscode', () => {
|
|||
});
|
||||
|
||||
it('should fail when passcode not found', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(null);
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(null);
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
|
||||
).rejects.toThrow(new RequestError('passcode.not_found'));
|
||||
});
|
||||
|
||||
it('should fail when phone mismatch', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(passcode);
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'invalid_phone' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, {
|
||||
phone: 'invalid_phone',
|
||||
})
|
||||
).rejects.toThrow(new RequestError('passcode.phone_mismatch'));
|
||||
});
|
||||
|
||||
it('should fail when email mismatch', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
|
||||
...passcode,
|
||||
phone: null,
|
||||
email: 'email',
|
||||
});
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { email: 'invalid_email' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, {
|
||||
email: 'invalid_email',
|
||||
})
|
||||
).rejects.toThrow(new RequestError('passcode.email_mismatch'));
|
||||
});
|
||||
|
||||
it('should fail when expired', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
|
||||
...passcode,
|
||||
createdAt: Date.now() - passcodeExpiration - 100,
|
||||
});
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
|
||||
).rejects.toThrow(new RequestError('passcode.expired'));
|
||||
});
|
||||
|
||||
it('should fail when exceed max count', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
|
||||
...passcode,
|
||||
tryCount: passcodeMaxTryCount,
|
||||
});
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
|
||||
).rejects.toThrow(new RequestError('passcode.exceed_max_try'));
|
||||
});
|
||||
|
||||
it('should fail when invalid code, and should increase try_count', async () => {
|
||||
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(passcode);
|
||||
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
|
||||
await expect(
|
||||
verifyPasscode(passcode.sessionId, passcode.type, 'invalid', { phone: 'phone' })
|
||||
verifyPasscode(passcode.interactionJti, passcode.type, 'invalid', { phone: 'phone' })
|
||||
).rejects.toThrow(new RequestError('passcode.code_mismatch'));
|
||||
expect(mockedUpdatePasscode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
|
@ -6,8 +6,8 @@ import { ConnectorType, EmailConector, SmsConnector } from '@/connectors/types';
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import {
|
||||
deletePasscodesByIds,
|
||||
findUnconsumedPasscodeBySessionIdAndType,
|
||||
findUnconsumedPasscodesBySessionIdAndType,
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
insertPasscode,
|
||||
updatePasscode,
|
||||
} from '@/queries/passcode';
|
||||
|
@ -16,12 +16,12 @@ export const passcodeLength = 6;
|
|||
const randomCode = customAlphabet('1234567890', passcodeLength);
|
||||
|
||||
export const createPasscode = async (
|
||||
sessionId: string,
|
||||
jti: string,
|
||||
type: PasscodeType,
|
||||
payload: { phone: string } | { email: string }
|
||||
) => {
|
||||
// Disable existing passcodes.
|
||||
const passcodes = await findUnconsumedPasscodesBySessionIdAndType(sessionId, type);
|
||||
const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type);
|
||||
|
||||
if (passcodes.length > 0) {
|
||||
await deletePasscodesByIds(passcodes.map(({ id }) => id));
|
||||
|
@ -29,7 +29,7 @@ export const createPasscode = async (
|
|||
|
||||
return insertPasscode({
|
||||
id: nanoid(),
|
||||
sessionId,
|
||||
interactionJti: jti,
|
||||
type,
|
||||
code: randomCode(),
|
||||
...payload,
|
||||
|
@ -61,7 +61,7 @@ export const verifyPasscode = async (
|
|||
code: string,
|
||||
payload: { phone: string } | { email: string }
|
||||
): Promise<void> => {
|
||||
const passcode = await findUnconsumedPasscodeBySessionIdAndType(sessionId, type);
|
||||
const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type);
|
||||
|
||||
if (!passcode) {
|
||||
throw new RequestError('passcode.not_found');
|
||||
|
|
|
@ -9,24 +9,18 @@ import { DeletionError } from '@/errors/SlonikError';
|
|||
|
||||
const { table, fields } = convertToIdentifiers(Passcodes);
|
||||
|
||||
export const findUnconsumedPasscodeBySessionIdAndType = async (
|
||||
sessionId: string,
|
||||
type: PasscodeType
|
||||
) =>
|
||||
export const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: PasscodeType) =>
|
||||
pool.maybeOne<Passcode>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.sessionId}=${sessionId} and ${fields.type}=${type} and ${fields.consumed} = false
|
||||
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false
|
||||
`);
|
||||
|
||||
export const findUnconsumedPasscodesBySessionIdAndType = async (
|
||||
sessionId: string,
|
||||
type: PasscodeType
|
||||
) =>
|
||||
export const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: PasscodeType) =>
|
||||
pool.many<Passcode>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.sessionId}=${sessionId} and ${fields.type}=${type} and ${fields.consumed} = false
|
||||
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false
|
||||
`);
|
||||
|
||||
export const insertPasscode = buildInsertInto<CreatePasscode, Passcode>(pool, Passcodes, {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { PasscodeType } from './custom-types';
|
|||
|
||||
export type CreatePasscode = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
interactionJti: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
type: PasscodeType;
|
||||
|
@ -19,7 +19,7 @@ export type CreatePasscode = {
|
|||
|
||||
export type Passcode = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
interactionJti: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
type: PasscodeType;
|
||||
|
@ -31,7 +31,7 @@ export type Passcode = {
|
|||
|
||||
const createGuard: Guard<CreatePasscode> = z.object({
|
||||
id: z.string(),
|
||||
sessionId: z.string(),
|
||||
interactionJti: z.string(),
|
||||
phone: z.string().nullable().optional(),
|
||||
email: z.string().nullable().optional(),
|
||||
type: z.nativeEnum(PasscodeType),
|
||||
|
@ -46,7 +46,7 @@ export const Passcodes: GeneratedSchema<CreatePasscode> = Object.freeze({
|
|||
tableSingular: 'passcode',
|
||||
fields: {
|
||||
id: 'id',
|
||||
sessionId: 'session_id',
|
||||
interactionJti: 'interaction_jti',
|
||||
phone: 'phone',
|
||||
email: 'email',
|
||||
type: 'type',
|
||||
|
@ -57,7 +57,7 @@ export const Passcodes: GeneratedSchema<CreatePasscode> = Object.freeze({
|
|||
},
|
||||
fieldKeys: [
|
||||
'id',
|
||||
'sessionId',
|
||||
'interactionJti',
|
||||
'phone',
|
||||
'email',
|
||||
'type',
|
||||
|
|
|
@ -2,7 +2,7 @@ create type passcode_type as enum ('SignIn', 'Register', 'ForgotPassword');
|
|||
|
||||
create table passcodes (
|
||||
id varchar(128) not null,
|
||||
session_id varchar(128) not null,
|
||||
interaction_jti varchar(128) not null,
|
||||
phone varchar(32),
|
||||
email varchar(128),
|
||||
type passcode_type not null,
|
||||
|
|
Loading…
Add table
Reference in a new issue