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:
parent
c67cf2b2bd
commit
811fe39852
2 changed files with 130 additions and 38 deletions
|
@ -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({});
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Reference in a new issue