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:
parent
02491adb5d
commit
ddb9968c98
5 changed files with 163 additions and 4 deletions
|
@ -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 },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 } });
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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 配置。',
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue