From b14c30becae1d3fc8154de1d0d8fc7907c01b768 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 7 Mar 2022 15:10:36 +0800 Subject: [PATCH] feat(core): add forgot password send a passcode to phone route (#326) * feat(core): add forgot password send a passcode to phone route * feat(core): add UT for forget password send passcode to phone flow --- packages/core/src/lib/user.test.ts | 101 +++++++++++++++--- packages/core/src/lib/user.ts | 32 +++++- packages/core/src/routes/session.test.ts | 51 +++++++++ packages/core/src/routes/session.ts | 30 +++++- packages/phrases/src/locales/en.ts | 2 + packages/phrases/src/locales/zh-cn.ts | 1 + .../schemas/src/db-entries/custom-types.ts | 2 + packages/schemas/tables/user_logs.sql | 2 +- 8 files changed, 205 insertions(+), 16 deletions(-) diff --git a/packages/core/src/lib/user.test.ts b/packages/core/src/lib/user.test.ts index 34723dd5a..898e96236 100644 --- a/packages/core/src/lib/user.test.ts +++ b/packages/core/src/lib/user.test.ts @@ -1,20 +1,22 @@ import { UsersPasswordEncryptionMethod } from '@logto/schemas'; -import { hasUserWithId } from '@/queries/user'; +import { hasUserWithId, findUserById } from '@/queries/user'; -import { encryptUserPassword, generateUserId } from './user'; +import { encryptUserPassword, generateUserId, findUserSignInMethodsById } from './user'; -jest.mock('@/queries/user'); +jest.mock('@/queries/user', () => ({ + findUserById: jest.fn(), + hasUserWithId: jest.fn(), +})); describe('generateUserId()', () => { afterEach(() => { - (hasUserWithId as jest.MockedFunction).mockClear(); + jest.clearAllMocks(); }); it('generates user ID with correct length when no conflict found', async () => { - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementationOnce(async () => false); + const mockedHasUserWithId = hasUserWithId as jest.Mock; + mockedHasUserWithId.mockImplementationOnce(async () => false); await expect(generateUserId()).resolves.toHaveLength(12); expect(mockedHasUserWithId).toBeCalledTimes(1); @@ -23,9 +25,8 @@ describe('generateUserId()', () => { it('generates user ID with correct length when retry limit is not reached', async () => { // eslint-disable-next-line @silverhand/fp/no-let let tried = 0; - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementation(async () => { + const mockedHasUserWithId = hasUserWithId as jest.Mock; + mockedHasUserWithId.mockImplementation(async () => { if (tried) { return false; } @@ -41,9 +42,8 @@ describe('generateUserId()', () => { }); it('rejects with correct error message when retry limit is reached', async () => { - const mockedHasUserWithId = ( - hasUserWithId as jest.MockedFunction - ).mockImplementation(async () => true); + const mockedHasUserWithId = hasUserWithId as jest.Mock; + mockedHasUserWithId.mockImplementation(async () => true); await expect(generateUserId(10)).rejects.toThrow( 'Cannot generate user ID in reasonable retries' @@ -61,3 +61,78 @@ describe('encryptUserPassword()', () => { expect(passwordEncryptionSalt).toHaveLength(21); }); }); + +describe('findUserSignInMethodsById()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('generate and test user with username and password sign-in method', async () => { + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockResolvedValue({ + username: 'abcd', + passwordEncrypted: '1234567890', + passwordEncryptionMethod: UsersPasswordEncryptionMethod.SaltAndPepper, + passwordEncryptionSalt: '123456790', + }); + const { usernameAndPassword, emailPasswordless, phonePasswordless, social } = + await findUserSignInMethodsById(''); + expect(usernameAndPassword).toEqual(true); + expect(emailPasswordless).toBeFalsy(); + expect(phonePasswordless).toBeFalsy(); + expect(social).toBeFalsy(); + }); + + it('generate and test user with email passwordless sign-in method', async () => { + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockResolvedValue({ + primaryEmail: 'b@a.com', + identities: {}, + }); + const { usernameAndPassword, emailPasswordless, phonePasswordless, social } = + await findUserSignInMethodsById(''); + expect(usernameAndPassword).toBeFalsy(); + expect(emailPasswordless).toEqual(true); + expect(phonePasswordless).toBeFalsy(); + expect(social).toBeFalsy(); + }); + + it('generate and test user with phone passwordless sign-in method', async () => { + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockResolvedValue({ + primaryPhone: '13000000000', + }); + const { usernameAndPassword, emailPasswordless, phonePasswordless, social } = + await findUserSignInMethodsById(''); + expect(usernameAndPassword).toBeFalsy(); + expect(emailPasswordless).toBeFalsy(); + expect(phonePasswordless).toEqual(true); + expect(social).toBeFalsy(); + }); + + it('generate and test user with social sign-in method (single social connector information in record)', async () => { + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockResolvedValue({ + identities: { connector1: { userId: 'foo1' } }, + }); + const { usernameAndPassword, emailPasswordless, phonePasswordless, social } = + await findUserSignInMethodsById(''); + expect(usernameAndPassword).toBeFalsy(); + expect(emailPasswordless).toBeFalsy(); + expect(phonePasswordless).toBeFalsy(); + expect(social).toEqual(true); + }); + + it('generate and test user with social sign-in method (multiple social connectors information in record)', async () => { + const mockFindUserById = findUserById as jest.Mock; + mockFindUserById.mockResolvedValue({ + identities: { connector1: { userId: 'foo1' }, connector2: { userId: 'foo2' } }, + }); + const { usernameAndPassword, emailPasswordless, phonePasswordless, social } = + await findUserSignInMethodsById(''); + expect(usernameAndPassword).toBeFalsy(); + expect(emailPasswordless).toBeFalsy(); + expect(phonePasswordless).toBeFalsy(); + expect(social).toEqual(true); + }); +}); diff --git a/packages/core/src/lib/user.ts b/packages/core/src/lib/user.ts index 3b5349378..fad38bdcc 100644 --- a/packages/core/src/lib/user.ts +++ b/packages/core/src/lib/user.ts @@ -2,7 +2,7 @@ import { UsersPasswordEncryptionMethod, User } from '@logto/schemas'; import { nanoid } from 'nanoid'; import pRetry from 'p-retry'; -import { findUserByUsername, hasUserWithId } from '@/queries/user'; +import { findUserById, findUserByUsername, hasUserWithId } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { buildIdGenerator } from '@/utils/id'; import { encryptPassword } from '@/utils/password'; @@ -43,6 +43,36 @@ export const encryptUserPassword = ( return { passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt }; }; +export const findUserSignInMethodsById = async ( + id: string +): Promise<{ + usernameAndPassword: boolean; + emailPasswordless: boolean; + phonePasswordless: boolean; + social: boolean; +}> => { + const user = await findUserById(id); + const { + username, + passwordEncrypted, + passwordEncryptionMethod, + passwordEncryptionSalt, + primaryEmail, + primaryPhone, + identities, + } = user; + + const usernameAndPassword = Boolean( + username && passwordEncrypted && passwordEncryptionMethod && passwordEncryptionSalt + ); + const emailPasswordless = Boolean(primaryEmail); + const phonePasswordless = Boolean(primaryPhone); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const social = identities && Object.keys(identities).length > 0; + + return { usernameAndPassword, emailPasswordless, phonePasswordless, social }; +}; + export const findUserByUsernameAndPassword = async ( username: string, password: string diff --git a/packages/core/src/routes/session.test.ts b/packages/core/src/routes/session.test.ts index 840ddcdf5..3f479c46d 100644 --- a/packages/core/src/routes/session.test.ts +++ b/packages/core/src/routes/session.test.ts @@ -2,10 +2,14 @@ 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') { @@ -18,6 +22,7 @@ jest.mock('@/lib/user', () => ({ return { id: 'user1' }; }, + findUserSignInMethodsById: async (userId: string) => findUserSignInMethodsByIdPlaceHolder(userId), generateUserId: () => 'user1', encryptUserPassword: (userId: string, password: string) => ({ passwordEncrypted: userId + '_' + password + '_user1', @@ -729,6 +734,52 @@ describe('sessionRoutes', () => { }); }); + describe('POST /session/forgot-password/phone/send-passcode', () => { + afterEach(() => { + findUserSignInMethodsByIdPlaceHolder.mockClear(); + }); + + beforeAll(() => { + interactionDetails.mockResolvedValueOnce({ + jti: 'jti', + }); + }); + + it('throw if no user can be found with phone', async () => { + 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); + }); + + 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' }); + expect(response.statusCode).toEqual(204); + expect(sendPasscode).toHaveBeenCalled(); + }); + }); + 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 bead1ed94..b4a1a858c 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -16,7 +16,12 @@ import { getUserInfoByAuthCode, getUserInfoFromInteractionResult, } from '@/lib/social'; -import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } from '@/lib/user'; +import { + generateUserId, + encryptUserPassword, + findUserSignInMethodsById, + findUserByUsernameAndPassword, +} from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { hasUserWithEmail, @@ -475,6 +480,29 @@ export default function sessionRoutes(router: T, prov } ); + router.post( + '/session/forgot-password/phone/send-passcode', + koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { phone } = ctx.guard.body; + ctx.userLog.phone = phone; + ctx.userLog.type = UserLogType.ForgotPasswordPhone; + + assertThat(await hasUserWithPhone(phone), 'user.phone_not_exists'); + 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); + ctx.status = 204; + + return next(); + } + ); + router.post( '/session/bind-social', koaGuard({ diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 216116f9a..943f8ab51 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -95,6 +95,8 @@ const errors = { phone_not_exists: 'The phone number has not been registered yet.', identity_not_exists: 'The social account has not been registered yet.', identity_exists: 'The social account has been registered.', + username_password_signin_not_exists: + 'Signing in with username and password has not been enabled for this user.', }, password: { unsupported_encryption_method: 'The encryption method {{name}} is not supported.', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 2587bf5ad..602411939 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -96,6 +96,7 @@ const errors = { phone_not_exists: '手机号码尚未注册。', identity_not_exists: '该社交账号尚未注册。', identity_exists: '该社交账号已被注册。', + username_password_signin_not_exists: '该账号暂未开通账号密码登录方式。', }, password: { unsupported_encryption_method: '不支持的加密方法 {{name}}。', diff --git a/packages/schemas/src/db-entries/custom-types.ts b/packages/schemas/src/db-entries/custom-types.ts index a87dbc9f9..2091defc6 100644 --- a/packages/schemas/src/db-entries/custom-types.ts +++ b/packages/schemas/src/db-entries/custom-types.ts @@ -19,6 +19,8 @@ export enum UserLogType { RegisterEmail = 'RegisterEmail', RegisterPhone = 'RegisterPhone', RegisterSocial = 'RegisterSocial', + ForgotPasswordEmail = 'ForgotPasswordEmail', + ForgotPasswordPhone = 'ForgotPasswordPhone', ExchangeAccessToken = 'ExchangeAccessToken', } export enum UserLogResult { diff --git a/packages/schemas/tables/user_logs.sql b/packages/schemas/tables/user_logs.sql index d8a578fc2..4b21c3d2b 100644 --- a/packages/schemas/tables/user_logs.sql +++ b/packages/schemas/tables/user_logs.sql @@ -1,4 +1,4 @@ -create type user_log_type as enum ('SignInUsernameAndPassword', 'SignInEmail', 'SignInPhone', 'SignInSocial', 'RegisterUsernameAndPassword', 'RegisterEmail', 'RegisterPhone', 'RegisterSocial', 'ExchangeAccessToken'); +create type user_log_type as enum ('SignInUsernameAndPassword', 'SignInEmail', 'SignInPhone', 'SignInSocial', 'RegisterUsernameAndPassword', 'RegisterEmail', 'RegisterPhone', 'RegisterSocial', 'ForgotPasswordEmail', 'ForgotPasswordPhone', 'ExchangeAccessToken'); create type user_log_result as enum ('Success', 'Failed');