From 1ea39f346367d9f300be7281a65e689bf198a65c Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 20 Sep 2022 16:38:08 +0800 Subject: [PATCH] feat(core): add POST /session/forgot-password/{email,sms}/verify-passcode (#1968) --- packages/core/src/routes/session/consts.ts | 1 + .../routes/session/forgot-password.test.ts | 82 +++++++++++++++++++ .../src/routes/session/forgot-password.ts | 69 +++++++++++++++- .../routes/session/username-password.test.ts | 2 +- packages/schemas/src/types/log.ts | 14 ++++ 5 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/routes/session/consts.ts diff --git a/packages/core/src/routes/session/consts.ts b/packages/core/src/routes/session/consts.ts new file mode 100644 index 000000000..8b88eec03 --- /dev/null +++ b/packages/core/src/routes/session/consts.ts @@ -0,0 +1 @@ +export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins. diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index c02c0c09c..cb9ab6202 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -1,13 +1,27 @@ +import dayjs from 'dayjs'; import { Provider } from 'oidc-provider'; +import RequestError from '@/errors/RequestError'; import { createRequester } from '@/utils/test-utils'; import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password'; +jest.mock('@/queries/user', () => ({ + hasUserWithPhone: async (phone: string) => phone === '13000000000', + findUserByPhone: async () => ({ id: 'id' }), + hasUserWithEmail: async (email: string) => email === 'a@a.com', + findUserByEmail: async () => ({ id: 'id' }), +})); + const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); jest.mock('@/lib/passcode', () => ({ createPasscode: async () => ({ id: 'id' }), sendPasscode: async () => sendPasscode(), + verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { + if (code !== '1234') { + throw new RequestError('passcode.code_mismatch'); + } + }, })); const interactionResult = jest.fn(async () => 'redirectTo'); @@ -53,6 +67,40 @@ describe('session -> forgotPasswordRoutes', () => { }); }); + describe('POST /session/forgot-password/sms/verify-passcode', () => { + it('assign result and redirect', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/sms/verify-passcode`) + .send({ phone: '13000000000', code: '1234' }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { accountId: 'id' }, + forgotPassword: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expiresAt: expect.any(dayjs), + }, + }), + expect.anything() + ); + }); + it('throw error if phone number does not exist', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/sms/verify-passcode`) + .send({ phone: '13000000001', code: '1234' }); + expect(response.statusCode).toEqual(422); + }); + it('throw error if verifyPasscode failed', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/sms/verify-passcode`) + .send({ phone: '13000000000', code: '1231' }); + expect(response.statusCode).toEqual(400); + }); + }); + describe('POST /session/forgot-password/email/send-passcode', () => { beforeAll(() => { interactionDetails.mockResolvedValueOnce({ @@ -67,4 +115,38 @@ describe('session -> forgotPasswordRoutes', () => { expect(sendPasscode).toHaveBeenCalled(); }); }); + + describe('POST /session/forgot-password/email/verify-passcode', () => { + it('assign result and redirect', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/email/verify-passcode`) + .send({ email: 'a@a.com', code: '1234' }); + expect(response.statusCode).toEqual(200); + expect(response.body).toHaveProperty('redirectTo'); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { accountId: 'id' }, + forgotPassword: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expiresAt: expect.any(dayjs), + }, + }), + expect.anything() + ); + }); + it('throw error if email does not exist', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/email/verify-passcode`) + .send({ email: 'b@a.com', code: '1234' }); + expect(response.statusCode).toEqual(422); + }); + it('throw error if verifyPasscode failed', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/sms/verify-passcode`) + .send({ email: 'a@a.com', code: '1231' }); + expect(response.statusCode).toEqual(400); + }); + }); }); diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index 2ac583089..559ae81b0 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -1,12 +1,23 @@ import { emailRegEx, phoneRegEx } from '@logto/core-kit'; import { PasscodeType } from '@logto/schemas'; +import dayjs from 'dayjs'; import { Provider } from 'oidc-provider'; import { z } from 'zod'; -import { createPasscode, sendPasscode } from '@/lib/passcode'; +import RequestError from '@/errors/RequestError'; +import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode'; +import { assignInteractionResults } from '@/lib/session'; import koaGuard from '@/middleware/koa-guard'; +import { + findUserByEmail, + findUserByPhone, + hasUserWithEmail, + hasUserWithPhone, +} from '@/queries/user'; +import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; +import { forgotPasswordVerificationTimeout } from './consts'; import { getRoutePrefix } from './utils'; export const forgotPasswordRoute = getRoutePrefix('forgot-password'); @@ -33,6 +44,35 @@ export default function forgotPasswordRoutes( } ); + router.post( + `${forgotPasswordRoute}/sms/verify-passcode`, + koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx), code: z.string() }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { phone, code } = ctx.guard.body; + const type = 'ForgotPasswordSms'; + ctx.log(type, { phone, code }); + + assertThat( + await hasUserWithPhone(phone), + new RequestError({ code: 'user.phone_not_exists', status: 422 }) + ); + + await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { phone }); + const { id } = await findUserByPhone(phone); + ctx.log(type, { userId: id }); + + await assignInteractionResults(ctx, provider, { + login: { accountId: id }, + forgotPassword: { + expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'), + }, + }); + + return next(); + } + ); + router.post( `${forgotPasswordRoute}/email/send-passcode`, koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }), @@ -50,4 +90,31 @@ export default function forgotPasswordRoutes( return next(); } ); + + router.post( + `${forgotPasswordRoute}/email/verify-passcode`, + koaGuard({ body: z.object({ email: z.string().regex(emailRegEx), code: z.string() }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { email, code } = ctx.guard.body; + const type = 'ForgotPasswordEmail'; + ctx.log(type, { email, code }); + + assertThat( + await hasUserWithEmail(email), + new RequestError({ code: 'user.email_not_exists', status: 422 }) + ); + + await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { email }); + const { id } = await findUserByEmail(email); + await assignInteractionResults(ctx, provider, { + login: { accountId: id }, + forgotPassword: { + expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'), + }, + }); + + return next(); + } + ); } diff --git a/packages/core/src/routes/session/username-password.test.ts b/packages/core/src/routes/session/username-password.test.ts index d241ac5a9..7fe31c2f2 100644 --- a/packages/core/src/routes/session/username-password.test.ts +++ b/packages/core/src/routes/session/username-password.test.ts @@ -147,7 +147,7 @@ describe('sessionRoutes', () => { console.log(response); }); - it('should throw if admin user sign in to AC', async () => { + it('should not throw if admin user sign in to AC', async () => { interactionDetails.mockResolvedValueOnce({ params: { client_id: adminConsoleApplicationId }, }); diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 311149dfd..8ee45546a 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -101,11 +101,23 @@ type ForgotPasswordSmsSendPasscodeLogPayload = ArbitraryLogPayload & { connectorId?: string; }; +type ForgotPasswordSmsLogPayload = ArbitraryLogPayload & { + phone?: string; + code?: string; + userId?: string; +}; + type ForgotPasswordEmailSendPasscodeLogPayload = ArbitraryLogPayload & { email?: string; connectorId?: string; }; +type ForgotPasswordEmailLogPayload = ArbitraryLogPayload & { + email?: string; + code?: string; + userId?: string; +}; + export enum TokenType { AccessToken = 'AccessToken', RefreshToken = 'RefreshToken', @@ -142,7 +154,9 @@ export type LogPayloads = { SignInSocialBind: SignInSocialBindLogPayload; SignInSocial: SignInSocialLogPayload; ForgotPasswordSmsSendPasscode: ForgotPasswordSmsSendPasscodeLogPayload; + ForgotPasswordSms: ForgotPasswordSmsLogPayload; ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload; + ForgotPasswordEmail: ForgotPasswordEmailLogPayload; CodeExchangeToken: ExchangeTokenLogPayload; RefreshTokenExchangeToken: ExchangeTokenLogPayload; RevokeToken: RevokeTokenLogPayload;