0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat: verifyPasscode (#201)

* fix: consume

* feat: sendPasscode

* feat: verifyPasscode
This commit is contained in:
Wang Sijie 2022-01-29 14:02:54 +08:00 committed by GitHub
parent 02491adb5d
commit ddb9968c98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 163 additions and 4 deletions

View file

@ -2,13 +2,23 @@ import { Passcode, PasscodeType } from '@logto/schemas';
import { getConnectorInstanceByType } from '@/connectors';
import { ConnectorType } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
deletePasscodesByIds,
findUnconsumedPasscodeBySessionIdAndType,
findUnconsumedPasscodesBySessionIdAndType,
insertPasscode,
updatePasscode,
} from '@/queries/passcode';
import { createPasscode, passcodeLength, sendPasscode } from './passcode';
import {
createPasscode,
passcodeExpiration,
passcodeMaxTryCount,
passcodeLength,
sendPasscode,
verifyPasscode,
} from './passcode';
jest.mock('@/queries/passcode');
jest.mock('@/connectors');
@ -17,6 +27,10 @@ const mockedFindUnconsumedPasscodesBySessionIdAndType =
findUnconsumedPasscodesBySessionIdAndType as jest.MockedFunction<
typeof findUnconsumedPasscodesBySessionIdAndType
>;
const mockedFindUnconsumedPasscodeBySessionIdAndType =
findUnconsumedPasscodeBySessionIdAndType as jest.MockedFunction<
typeof findUnconsumedPasscodeBySessionIdAndType
>;
const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction<
typeof deletePasscodesByIds
>;
@ -24,6 +38,7 @@ const mockedInsertPasscode = insertPasscode as jest.MockedFunction<typeof insert
const mockedGetConnectorInstanceByType = getConnectorInstanceByType as jest.MockedFunction<
typeof getConnectorInstanceByType
>;
const mockedUpdatePasscode = updatePasscode as jest.MockedFunction<typeof updatePasscode>;
beforeAll(() => {
mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([]);
@ -99,7 +114,9 @@ describe('sendPasscode', () => {
tryCount: 0,
createdAt: Date.now(),
};
await expect(sendPasscode(passcode)).rejects.toThrowError('Both email and phone are empty.');
await expect(sendPasscode(passcode)).rejects.toThrowError(
new RequestError('passcode.phone_email_empty')
);
});
it('should call sendPasscode with params matching', async () => {
@ -132,3 +149,84 @@ describe('sendPasscode', () => {
});
});
});
describe('verifyPasscode', () => {
const passcode: Passcode = {
id: 'id',
sessionId: 'sessionId',
phone: 'phone',
email: null,
type: PasscodeType.SignIn,
code: '1234',
consumed: false,
tryCount: 0,
createdAt: Date.now(),
};
it('should mark as consumed on successful verification', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(passcode);
await verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' });
expect(mockedUpdatePasscode).toHaveBeenCalledWith(
expect.objectContaining({
set: { consumed: true },
})
);
});
it('should fail when passcode not found', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(null);
await expect(
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('passcode.not_found'));
});
it('should fail when phone mismatch', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue(passcode);
await expect(
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'invalid_phone' })
).rejects.toThrow(new RequestError('passcode.phone_mismatch'));
});
it('should fail when email mismatch', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
...passcode,
phone: null,
email: 'email',
});
await expect(
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { email: 'invalid_email' })
).rejects.toThrow(new RequestError('passcode.email_mismatch'));
});
it('should fail when expired', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
...passcode,
createdAt: Date.now() - passcodeExpiration - 100,
});
await expect(
verifyPasscode(passcode.sessionId, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('passcode.expired'));
});
it('should fail when exceed max count', async () => {
mockedFindUnconsumedPasscodeBySessionIdAndType.mockResolvedValue({
...passcode,
tryCount: passcodeMaxTryCount,
});
await expect(
verifyPasscode(passcode.sessionId, 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);
await expect(
verifyPasscode(passcode.sessionId, passcode.type, 'invalid', { phone: 'phone' })
).rejects.toThrow(new RequestError('passcode.code_mismatch'));
expect(mockedUpdatePasscode).toHaveBeenCalledWith(
expect.objectContaining({
set: { tryCount: passcode.tryCount + 1 },
})
);
});
});

View file

@ -3,10 +3,13 @@ import { customAlphabet, nanoid } from 'nanoid';
import { getConnectorInstanceByType } from '@/connectors';
import { ConnectorType, EmailConector, SmsConnector } from '@/connectors/types';
import RequestError from '@/errors/RequestError';
import {
deletePasscodesByIds,
findUnconsumedPasscodeBySessionIdAndType,
findUnconsumedPasscodesBySessionIdAndType,
insertPasscode,
updatePasscode,
} from '@/queries/passcode';
export const passcodeLength = 6;
@ -37,7 +40,7 @@ export const sendPasscode = async (passcode: Passcode) => {
const emailOrPhone = passcode.email ?? passcode.phone;
if (!emailOrPhone) {
throw new Error('Both email and phone are empty.');
throw new RequestError('passcode.phone_email_empty');
}
const connector = passcode.email
@ -48,3 +51,43 @@ export const sendPasscode = async (passcode: Passcode) => {
code: passcode.code,
});
};
export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes.
export const passcodeMaxTryCount = 10;
export const verifyPasscode = async (
sessionId: string,
type: PasscodeType,
code: string,
payload: { phone: string } | { email: string }
): Promise<void> => {
const passcode = await findUnconsumedPasscodeBySessionIdAndType(sessionId, type);
if (!passcode) {
throw new RequestError('passcode.not_found');
}
if ('phone' in payload && passcode.phone !== payload.phone) {
throw new RequestError('passcode.phone_mismatch');
}
if ('email' in payload && passcode.email !== payload.email) {
throw new RequestError('passcode.email_mismatch');
}
if (passcode.createdAt + passcodeExpiration < Date.now()) {
throw new RequestError('passcode.expired');
}
if (passcode.tryCount >= passcodeMaxTryCount) {
throw new RequestError('passcode.exceed_max_try');
}
if (code !== passcode.code) {
// TODO use SQL's native +1
await updatePasscode({ where: { id: passcode.id }, set: { tryCount: passcode.tryCount + 1 } });
throw new RequestError('passcode.code_mismatch');
}
await updatePasscode({ where: { id: passcode.id }, set: { consumed: true } });
};

View file

@ -13,7 +13,7 @@ export const findUnconsumedPasscodeBySessionIdAndType = async (
sessionId: string,
type: PasscodeType
) =>
pool.one<Passcode>(sql`
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

View file

@ -46,6 +46,15 @@ const errors = {
connector: {
not_found: 'Can not find any available connector for type: {{type}}.',
},
passcode: {
phone_email_empty: 'Both phone and email are empty.',
not_found: 'Passcode not found. Please send passcode first.',
phone_mismatch: 'Phone mismatch. Please request a new passcode.',
email_mismatch: 'Email mismatch. Please request a new passcode.',
code_mismatch: 'Invalid passcode.',
expired: 'Passcode has expired. Please request a new passcode.',
exceed_max_try: 'Passcode verification limitaton exeeded. Please request a new passcode.',
},
swagger: {
invalid_zod_type: 'Invalid Zod type, please check route guard config.',
},

View file

@ -48,6 +48,15 @@ const errors = {
connector: {
not_found: '找不到可用的 {{type}} 类型的连接器.',
},
passcode: {
phone_email_empty: '手机号与邮箱地址均为空。',
not_found: '验证码不存在,请先请求发送验证码。',
phone_mismatch: '手机号码不匹配. 请尝试请求新的验证码。',
email_mismatch: '邮箱地址不匹配. 请尝试请求新的验证码。',
code_mismatch: '验证码不正确。',
expired: '验证码已过期. 请尝试请求新的验证码。',
exceed_max_try: '超过最大验证次数. 请尝试请求新的验证码。',
},
swagger: {
invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',
},