0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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
This commit is contained in:
Darcy Ye 2022-03-07 15:10:36 +08:00 committed by GitHub
parent 12769e277b
commit b14c30beca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 205 additions and 16 deletions

View file

@ -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<typeof hasUserWithId>).mockClear();
jest.clearAllMocks();
});
it('generates user ID with correct length when no conflict found', async () => {
const mockedHasUserWithId = (
hasUserWithId as jest.MockedFunction<typeof hasUserWithId>
).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<typeof hasUserWithId>
).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<typeof hasUserWithId>
).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);
});
});

View file

@ -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

View file

@ -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({});

View file

@ -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<T extends AnonymousRouter>(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({

View file

@ -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.',

View file

@ -96,6 +96,7 @@ const errors = {
phone_not_exists: '手机号码尚未注册。',
identity_not_exists: '该社交账号尚未注册。',
identity_exists: '该社交账号已被注册。',
username_password_signin_not_exists: '该账号暂未开通账号密码登录方式。',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}。',

View file

@ -19,6 +19,8 @@ export enum UserLogType {
RegisterEmail = 'RegisterEmail',
RegisterPhone = 'RegisterPhone',
RegisterSocial = 'RegisterSocial',
ForgotPasswordEmail = 'ForgotPasswordEmail',
ForgotPasswordPhone = 'ForgotPasswordPhone',
ExchangeAccessToken = 'ExchangeAccessToken',
}
export enum UserLogResult {

View file

@ -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');