0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

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
This commit is contained in:
Darcy Ye 2022-03-10 14:15:20 +08:00 committed by GitHub
parent c67cf2b2bd
commit 811fe39852
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 130 additions and 38 deletions

View file

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

View file

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