From 811fe39852ab4ff9aad8b05b356f0c277707cad6 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 10 Mar 2022 14:15:20 +0800 Subject: [PATCH] feat(core): add email forgot password flow (send/verify passcode) (#336) * feat(core): add email forgot password flow (send/verify passcode) * feat(core): reset password once passcode verification succeed * feat(core): remove username+password existence check * feat(core): fix phone not exist error code --- packages/core/src/routes/session.test.ts | 93 ++++++++++++++++-------- packages/core/src/routes/session.ts | 75 ++++++++++++++++--- 2 files changed, 130 insertions(+), 38 deletions(-) diff --git a/packages/core/src/routes/session.test.ts b/packages/core/src/routes/session.test.ts index 0c282e2de..5d365afb5 100644 --- a/packages/core/src/routes/session.test.ts +++ b/packages/core/src/routes/session.test.ts @@ -2,14 +2,10 @@ import { Provider } from 'oidc-provider'; import { ConnectorType } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; -import { findUserSignInMethodsById } from '@/lib/user'; import { createRequester } from '@/utils/test-utils'; import sessionRoutes from './session'; -const findUserSignInMethodsByIdPlaceHolder = jest.fn() as jest.MockedFunction< - typeof findUserSignInMethodsById ->; jest.mock('@/lib/user', () => ({ async findUserByUsernameAndPassword(username: string, password: string) { if (username !== 'username') { @@ -22,7 +18,6 @@ jest.mock('@/lib/user', () => ({ return { id: 'user1' }; }, - findUserSignInMethodsById: async (userId: string) => findUserSignInMethodsByIdPlaceHolder(userId), generateUserId: () => 'user1', encryptUserPassword: (userId: string, password: string) => ({ passwordEncrypted: userId + '_' + password + '_user1', @@ -735,10 +730,6 @@ describe('sessionRoutes', () => { }); describe('POST /session/forgot-password/phone/send-passcode', () => { - afterEach(() => { - findUserSignInMethodsByIdPlaceHolder.mockClear(); - }); - beforeAll(() => { interactionDetails.mockResolvedValueOnce({ jti: 'jti', @@ -749,29 +740,10 @@ describe('sessionRoutes', () => { const response = await sessionRequest .post('/session/forgot-password/phone/send-passcode') .send({ phone: '13000000001' }); - expect(response).toHaveProperty('statusCode', 400); - }); - - it('throw if found user can not sign-in with username and password', async () => { - findUserSignInMethodsByIdPlaceHolder.mockResolvedValue({ - usernameAndPassword: false, - emailPasswordless: false, - phonePasswordless: false, - social: false, - }); - const response = await sessionRequest - .post('/session/forgot-password/phone/send-passcode') - .send({ phone: '13000000000' }); - expect(response).toHaveProperty('statusCode', 400); + expect(response).toHaveProperty('statusCode', 422); }); it('create and send passcode', async () => { - findUserSignInMethodsByIdPlaceHolder.mockResolvedValue({ - usernameAndPassword: true, - emailPasswordless: false, - phonePasswordless: false, - social: false, - }); const response = await sessionRequest .post('/session/forgot-password/phone/send-passcode') .send({ phone: '13000000000' }); @@ -820,6 +792,69 @@ describe('sessionRoutes', () => { }); }); + describe('POST /session/forgot-password/email/send-passcode', () => { + beforeAll(() => { + interactionDetails.mockResolvedValueOnce({ + jti: 'jti', + }); + }); + + it('throw if no user can be found with email', async () => { + const response = await sessionRequest + .post('/session/forgot-password/email/send-passcode') + .send({ email: 'b@a.com' }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('create and send passcode', async () => { + const response = await sessionRequest + .post('/session/forgot-password/email/send-passcode') + .send({ email: 'a@a.com' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + }); + + describe('POST /session/forgot-password/email/verify-passcode-and-reset-password', () => { + beforeAll(() => { + interactionDetails.mockResolvedValueOnce({ + jti: 'jti', + }); + }); + + it('throw if no user can be found with email', async () => { + const response = await sessionRequest + .post('/session/forgot-password/email/verify-passcode-and-reset-password') + .send({ email: 'b@a.com', code: '1234', password: '123456' }); + expect(response).toHaveProperty('statusCode', 422); + }); + + it('fail to verify passcode', async () => { + const response = await sessionRequest + .post('/session/forgot-password/email/verify-passcode-and-reset-password') + .send({ email: 'a@a.com', code: '1231', password: '123456' }); + expect(response).toHaveProperty('statusCode', 400); + }); + + it('verify passcode, reset password and assign result', async () => { + const response = await sessionRequest + .post('/session/forgot-password/email/verify-passcode-and-reset-password') + .send({ email: 'a@a.com', code: '1234', password: '123456' }); + expect(response).toHaveProperty('statusCode', 200); + expect(updateUserById).toHaveBeenCalledWith('id', { + passwordEncryptionSalt: 'user1', + passwordEncrypted: 'id_123456_user1', + passwordEncryptionMethod: 'SaltAndPepper', + }); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ login: { accountId: 'id' } }), + expect.anything() + ); + }); + }); + describe('POST /session/bind-social', () => { it('throw if session is not authorized', async () => { interactionDetails.mockResolvedValueOnce({}); diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index 84e78633d..b9937a05a 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -16,12 +16,7 @@ import { getUserInfoByAuthCode, getUserInfoFromInteractionResult, } from '@/lib/social'; -import { - generateUserId, - encryptUserPassword, - findUserSignInMethodsById, - findUserByUsernameAndPassword, -} from '@/lib/user'; +import { generateUserId, encryptUserPassword, findUserByUsernameAndPassword } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { hasUserWithEmail, @@ -489,11 +484,12 @@ export default function sessionRoutes(router: T, prov ctx.userLog.phone = phone; ctx.userLog.type = UserLogType.ForgotPasswordPhone; - assertThat(await hasUserWithPhone(phone), 'user.phone_not_exists'); + assertThat( + await hasUserWithPhone(phone), + new RequestError({ code: 'user.phone_not_exists', status: 422 }) + ); const { id } = await findUserByPhone(phone); ctx.userLog.userId = id; - const { usernameAndPassword } = await findUserSignInMethodsById(id); - assertThat(usernameAndPassword, 'user.username_password_signin_not_exists'); const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { phone }); await sendPasscode(passcode); @@ -540,6 +536,67 @@ export default function sessionRoutes(router: T, prov } ); + router.post( + '/session/forgot-password/email/send-passcode', + koaGuard({ body: object({ email: string().regex(emailRegEx) }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { email } = ctx.guard.body; + ctx.userLog.email = email; + ctx.userLog.type = UserLogType.ForgotPasswordEmail; + + assertThat( + await hasUserWithEmail(email), + new RequestError({ code: 'user.email_not_exists', status: 422 }) + ); + const { id } = await findUserByEmail(email); + ctx.userLog.userId = id; + + const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { email }); + await sendPasscode(passcode); + ctx.status = 204; + + return next(); + } + ); + + router.post( + '/session/forgot-password/email/verify-passcode-and-reset-password', + koaGuard({ + body: object({ + email: string().regex(emailRegEx), + code: string(), + password: string().regex(passwordRegEx), + }), + }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { email, code, password } = ctx.guard.body; + ctx.userLog.email = email; + ctx.userLog.type = UserLogType.ForgotPasswordEmail; + + 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); + ctx.userLog.userId = id; + + const { passwordEncryptionSalt, passwordEncrypted, passwordEncryptionMethod } = + encryptUserPassword(id, password); + await updateUserById(id, { + passwordEncryptionSalt, + passwordEncrypted, + passwordEncryptionMethod, + }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); + + return next(); + } + ); + router.post( '/session/bind-social', koaGuard({