From af2600d828bf315ce57de5813168571e7042d8de Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 20 Sep 2022 14:14:27 +0800 Subject: [PATCH] feat(core): add POST /session/forgot-password/{email,sms}/send-passcode (#1963) --- .../routes/session/forgot-password.test.ts | 70 +++++++++++++++++++ .../src/routes/session/forgot-password.ts | 53 ++++++++++++++ packages/core/src/routes/session/index.ts | 3 + packages/core/src/routes/session/utils.ts | 2 +- packages/schemas/src/types/log.ts | 12 ++++ 5 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/routes/session/forgot-password.test.ts create mode 100644 packages/core/src/routes/session/forgot-password.ts diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts new file mode 100644 index 000000000..c02c0c09c --- /dev/null +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -0,0 +1,70 @@ +import { Provider } from 'oidc-provider'; + +import { createRequester } from '@/utils/test-utils'; + +import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password'; + +const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); +jest.mock('@/lib/passcode', () => ({ + createPasscode: async () => ({ id: 'id' }), + sendPasscode: async () => sendPasscode(), +})); + +const interactionResult = jest.fn(async () => 'redirectTo'); +const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails, + interactionResult, + })), +})); + +afterEach(() => { + interactionResult.mockClear(); +}); + +describe('session -> forgotPasswordRoutes', () => { + const sessionRequest = createRequester({ + anonymousRoutes: forgotPasswordRoutes, + provider: new Provider(''), + middlewares: [ + async (ctx, next) => { + ctx.addLogContext = jest.fn(); + ctx.log = jest.fn(); + + return next(); + }, + ], + }); + + describe('POST /session/forgot-password/sms/send-passcode', () => { + beforeAll(() => { + interactionDetails.mockResolvedValueOnce({ + jti: 'jti', + }); + }); + it('should call sendPasscode', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/sms/send-passcode`) + .send({ phone: '13000000000' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + }); + + describe('POST /session/forgot-password/email/send-passcode', () => { + beforeAll(() => { + interactionDetails.mockResolvedValueOnce({ + jti: 'jti', + }); + }); + it('should call sendPasscode', async () => { + const response = await sessionRequest + .post(`${forgotPasswordRoute}/email/send-passcode`) + .send({ email: 'a@a.com' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts new file mode 100644 index 000000000..2ac583089 --- /dev/null +++ b/packages/core/src/routes/session/forgot-password.ts @@ -0,0 +1,53 @@ +import { emailRegEx, phoneRegEx } from '@logto/core-kit'; +import { PasscodeType } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; +import { z } from 'zod'; + +import { createPasscode, sendPasscode } from '@/lib/passcode'; +import koaGuard from '@/middleware/koa-guard'; + +import { AnonymousRouter } from '../types'; +import { getRoutePrefix } from './utils'; + +export const forgotPasswordRoute = getRoutePrefix('forgot-password'); + +export default function forgotPasswordRoutes( + router: T, + provider: Provider +) { + router.post( + `${forgotPasswordRoute}/sms/send-passcode`, + koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx) }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { phone } = ctx.guard.body; + const type = 'ForgotPasswordSmsSendPasscode'; + ctx.log(type, { phone }); + + const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { phone }); + const { dbEntry } = await sendPasscode(passcode); + ctx.log(type, { connectorId: dbEntry.id }); + ctx.status = 204; + + return next(); + } + ); + + router.post( + `${forgotPasswordRoute}/email/send-passcode`, + koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { email } = ctx.guard.body; + const type = 'ForgotPasswordEmailSendPasscode'; + ctx.log(type, { email }); + + const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { email }); + const { dbEntry } = await sendPasscode(passcode); + ctx.log(type, { connectorId: dbEntry.id }); + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts index 63d1943c2..9bbc4709b 100644 --- a/packages/core/src/routes/session/index.ts +++ b/packages/core/src/routes/session/index.ts @@ -10,6 +10,7 @@ import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/ses import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; +import forgotPasswordRoutes from './forgot-password'; import koaGuardSessionAction from './middleware/koa-guard-session-action'; import passwordlessRoutes from './passwordless'; import socialRoutes from './social'; @@ -87,4 +88,6 @@ export default function sessionRoutes(router: T, prov usernamePasswordRoutes(router, provider); passwordlessRoutes(router, provider); socialRoutes(router, provider); + + forgotPasswordRoutes(router, provider); } diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index c8a560391..5aa28665f 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,7 +1,7 @@ import { Truthy } from '@silverhand/essentials'; export const getRoutePrefix = ( - type: 'sign-in' | 'register', + type: 'sign-in' | 'register' | 'forgot-password', method?: 'passwordless' | 'username-password' | 'social' ) => { return ['session', type, method] diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index c65cc07a7..311149dfd 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -96,6 +96,16 @@ type SignInSocialLogPayload = SignInSocialBindLogPayload & { redirectTo?: string; }; +type ForgotPasswordSmsSendPasscodeLogPayload = ArbitraryLogPayload & { + phone?: string; + connectorId?: string; +}; + +type ForgotPasswordEmailSendPasscodeLogPayload = ArbitraryLogPayload & { + email?: string; + connectorId?: string; +}; + export enum TokenType { AccessToken = 'AccessToken', RefreshToken = 'RefreshToken', @@ -131,6 +141,8 @@ export type LogPayloads = { SignInSms: SignInSmsLogPayload; SignInSocialBind: SignInSocialBindLogPayload; SignInSocial: SignInSocialLogPayload; + ForgotPasswordSmsSendPasscode: ForgotPasswordSmsSendPasscodeLogPayload; + ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload; CodeExchangeToken: ExchangeTokenLogPayload; RefreshTokenExchangeToken: ExchangeTokenLogPayload; RevokeToken: RevokeTokenLogPayload;