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

refactor(core,phrases): add send/verify passcode via email/sms routes and corresponding UTs (#2035)

* refactor(core,phrases): add send/verify passcode via email/sms routes and corresponding UTs

* refactor(core,phrases): flow to reuse PasscodeType
This commit is contained in:
Darcy Ye 2022-09-30 16:55:55 +08:00 committed by GitHub
parent 753e8ebdfd
commit 0960afc97d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 449 additions and 3 deletions

View file

@ -89,6 +89,7 @@
"@types/rimraf": "^3.0.2",
"@types/supertest": "^2.0.11",
"@types/tar": "^6.1.2",
"camelcase": "^6.2.0",
"copyfiles": "^2.4.1",
"eslint": "^8.21.0",
"http-errors": "^1.6.3",

View file

@ -1 +1,2 @@
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
export const verificationTimeout = 10 * 60; // 10 mins.

View file

@ -1,10 +1,13 @@
import { User } from '@logto/schemas';
/* eslint-disable max-lines */
import { PasscodeType, User } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { mockUser } from '@/__mocks__';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import { verificationTimeout } from './consts';
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
@ -27,8 +30,9 @@ jest.mock('@/queries/user', () => ({
}));
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
createPasscode: async (..._args: unknown[]) => createPasscode(..._args),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
@ -65,6 +69,196 @@ describe('session -> passwordlessRoutes', () => {
],
});
describe('POST /session/passwordless/sms/send', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it('should call sendPasscode (with flow `sign-in`)', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/send')
.send({ phone: '13000000000', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
phone: '13000000000',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('should call sendPasscode (with flow `register`)', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/send')
.send({ phone: '13000000000', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
phone: '13000000000',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw when phone not given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/send')
.send({ flow: PasscodeType.Register });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/email/send', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
afterEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it('should call sendPasscode (with flow `sign-in`)', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
email: 'a@a.com',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('should call sendPasscode (with flow `register`)', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/send')
.send({ email: 'a@a.com', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
email: 'a@a.com',
});
expect(sendPasscode).toHaveBeenCalled();
});
it('throw when email not given in input params', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/send')
.send({ flow: PasscodeType.Register });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/sms/verify', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
jest.resetModules();
});
it('should call interactionResult (with flow `sign-in`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.SignIn,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
});
it('should call interactionResult (with flow `register`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.Register,
phone: '13000000000',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
});
it('throw when code is wrong', async () => {
const response = await sessionRequest
.post('/session/passwordless/sms/verify')
.send({ phone: '13000000000', code: '1231', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/passwordless/email/verify', () => {
beforeEach(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
jest.resetModules();
});
it('should call interactionResult (with flow `sign-in`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.SignIn });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.SignIn,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
});
it('should call interactionResult (with flow `register`)', async () => {
const fakeTime = new Date();
jest.useFakeTimers().setSystemTime(fakeTime);
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.Register });
expect(response.statusCode).toEqual(204);
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
verification: {
flow: PasscodeType.Register,
email: 'a@a.com',
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
},
})
);
});
it('throw when code is wrong', async () => {
const response = await sessionRequest
.post('/session/passwordless/email/verify')
.send({ email: 'a@a.com', code: '1231', flow: 'sign-in' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/sign-in/passwordless/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
@ -317,3 +511,4 @@ describe('session -> passwordlessRoutes', () => {
});
});
});
/* eslint-enable max-lines */

View file

@ -1,5 +1,6 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { object, string } from 'zod';
@ -15,10 +16,12 @@ import {
findUserByEmail,
findUserByPhone,
} from '@/queries/user';
import { passcodeTypeGuard } from '@/routes/session/types';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils';
import { verificationTimeout } from './consts';
import { getPasswordlessRelatedLogType, getRoutePrefix } from './utils';
export const registerRoute = getRoutePrefix('register', 'passwordless');
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
@ -27,6 +30,124 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
'/session/passwordless/sms/send',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { phone, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'sms', 'send');
ctx.log(type, { phone });
const passcode = await createPasscode(jti, flow, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/passwordless/email/send',
koaGuard({
body: object({
email: string().regex(emailRegEx),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { email, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'email', 'send');
ctx.log(type, { email });
const passcode = await createPasscode(jti, flow, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
'/session/passwordless/sms/verify',
koaGuard({
body: object({
phone: string().regex(phoneRegEx),
code: string(),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { phone, code, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify');
ctx.log(type, { phone });
await verifyPasscode(jti, flow, code, { phone });
await provider.interactionResult(ctx.req, ctx.res, {
verification: {
flow,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
phone,
},
});
ctx.status = 204;
return next();
}
);
router.post(
'/session/passwordless/email/verify',
koaGuard({
body: object({
email: string().regex(emailRegEx),
code: string(),
flow: passcodeTypeGuard,
}),
}),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const {
body: { email, code, flow },
} = ctx.guard;
const type = getPasswordlessRelatedLogType(flow, 'email', 'verify');
ctx.log(type, { email });
await verifyPasscode(jti, flow, code, { email });
await provider.interactionResult(ctx.req, ctx.res, {
verification: {
flow,
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
email,
},
});
ctx.status = 204;
return next();
}
);
router.post(
`${signInRoute}/sms/send-passcode`,
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),

View file

@ -0,0 +1,23 @@
import { PasscodeType } from '@logto/schemas';
import { z } from 'zod';
export const passcodeTypeGuard = z.nativeEnum(PasscodeType);
export const viaGuard = z.enum(['email', 'sms']);
export type Via = z.infer<typeof viaGuard>;
export const operationGuard = z.enum(['send', 'verify']);
export type Operation = z.infer<typeof operationGuard>;
export type PasscodePayload = { email: string } | { phone: string };
export const verificationStorageGuard = z.object({
email: z.string().optional(),
phone: z.string().optional(),
flow: passcodeTypeGuard,
expiresAt: z.string(),
});
export type VerificationStorage = z.infer<typeof verificationStorageGuard>;

View file

@ -1,4 +1,12 @@
import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas';
import { Truthy } from '@silverhand/essentials';
import dayjs from 'dayjs';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import assertThat from '@/utils/assert-that';
import { verificationStorageGuard, Operation, VerificationStorage, Via } from './types';
export const getRoutePrefix = (
type: 'sign-in' | 'register' | 'forgot-password',
@ -9,3 +17,52 @@ export const getRoutePrefix = (
.map((value) => '/' + value)
.join('');
};
export const getPasswordlessRelatedLogType = (
flow: PasscodeType,
via: Via,
operation?: Operation
): LogType => {
const body = via === 'email' ? 'Email' : 'Sms';
const suffix = operation === 'send' ? 'SendPasscode' : '';
const result = logTypeGuard.safeParse(flow + body + suffix);
assertThat(result.success, new RequestError('log.invalid_type'));
return result.data;
};
export const parseVerificationStorage = (data: unknown): VerificationStorage => {
const verificationResult = z
.object({
verification: verificationStorageGuard,
})
.safeParse(data);
assertThat(
verificationResult.success,
new RequestError({
code: 'session.verification_session_not_found',
status: 404,
})
);
return verificationResult.data.verification;
};
export const verificationSessionCheckByFlow = (
currentFlow: PasscodeType,
payload: Pick<VerificationStorage, 'flow' | 'expiresAt'>
) => {
const { flow, expiresAt } = payload;
assertThat(
flow === currentFlow,
new RequestError({ code: 'session.passwordless_not_verified', status: 401 })
);
assertThat(
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
new RequestError({ code: 'session.verification_expired', status: 401 })
);
};

View file

@ -60,6 +60,11 @@ const errors = {
'Forgot password session not found. Please go back and verify.',
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.',
verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.',
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.',
verification_expired: 'Passwordless verification has expired. Please go back and verify again.',
unauthorized: 'Please sign in first.',
unsupported_prompt_name: 'Unsupported prompt name.',
},
@ -121,6 +126,9 @@ const errors = {
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
not_found: 'The resource does not exist.',
},
log: {
invalid_type: 'The log type is invalid.',
},
};
export default errors;

View file

@ -65,6 +65,11 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: "Veuillez vous enregistrer d'abord.",
unsupported_prompt_name: "Nom d'invite non supporté.",
},
@ -129,6 +134,9 @@ const errors = {
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
not_found: "La ressource n'existe pas.",
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -59,6 +59,11 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: '로그인을 먼저 해주세요.',
unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.',
},
@ -118,6 +123,9 @@ const errors = {
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
not_found: '리소스가 존재하지 않아요.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -61,6 +61,11 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Faça login primeiro.',
unsupported_prompt_name: 'Nome de prompt não suportado.',
},
@ -124,6 +129,9 @@ const errors = {
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
not_found: 'O recurso não existe.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -61,6 +61,11 @@ const errors = {
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
forgot_password_verification_expired:
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
verification_session_not_found:
'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED
passwordless_not_verified:
'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED
verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED
unauthorized: 'Lütfen önce oturum açın.',
unsupported_prompt_name: 'Desteklenmeyen prompt adı.',
},
@ -123,6 +128,9 @@ const errors = {
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
not_found: 'Kaynak mevcut değil.',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

View file

@ -57,6 +57,9 @@ const errors = {
connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。',
forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。',
forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。',
verification_session_not_found: '无法找到无密码流程验证信息,请尝试重新验证。',
passwordless_not_verified: '无密码验证 {{flow}} 流程没找到。请返回并验证。',
verification_expired: '无密码验证已过期。请返回重新验证。',
unauthorized: '请先登录',
unsupported_prompt_name: '不支持的 prompt name',
},
@ -112,6 +115,9 @@ const errors = {
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在',
not_found: '该资源不存在',
},
log: {
invalid_type: 'The log type is invalid.', // UNTRANSLATED
},
};
export default errors;

2
pnpm-lock.yaml generated
View file

@ -177,6 +177,7 @@ importers:
'@types/rimraf': ^3.0.2
'@types/supertest': ^2.0.11
'@types/tar': ^6.1.2
camelcase: ^6.2.0
chalk: ^4
copyfiles: ^2.4.1
dayjs: ^1.10.5
@ -294,6 +295,7 @@ importers:
'@types/rimraf': 3.0.2
'@types/supertest': 2.0.11
'@types/tar': 6.1.2
camelcase: 6.3.0
copyfiles: 2.4.1
eslint: 8.21.0
http-errors: 1.8.1