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

feat(core): add POST /session/forgot-password/{email,sms}/verify-passcode (#1968)

This commit is contained in:
Darcy Ye 2022-09-20 16:38:08 +08:00 committed by GitHub
parent af2600d828
commit 1ea39f3463
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 166 additions and 2 deletions

View file

@ -0,0 +1 @@
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.

View file

@ -1,13 +1,27 @@
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import RequestError from '@/errors/RequestError';
import { createRequester } from '@/utils/test-utils';
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password';
jest.mock('@/queries/user', () => ({
hasUserWithPhone: async (phone: string) => phone === '13000000000',
findUserByPhone: async () => ({ id: 'id' }),
hasUserWithEmail: async (email: string) => email === 'a@a.com',
findUserByEmail: async () => ({ id: 'id' }),
}));
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
sendPasscode: async () => sendPasscode(),
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
if (code !== '1234') {
throw new RequestError('passcode.code_mismatch');
}
},
}));
const interactionResult = jest.fn(async () => 'redirectTo');
@ -53,6 +67,40 @@ describe('session -> forgotPasswordRoutes', () => {
});
});
describe('POST /session/forgot-password/sms/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
forgotPassword: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresAt: expect.any(dayjs),
},
}),
expect.anything()
);
});
it('throw error if phone number does not exist', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000001', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ phone: '13000000000', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
describe('POST /session/forgot-password/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
@ -67,4 +115,38 @@ describe('session -> forgotPasswordRoutes', () => {
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/forgot-password/email/verify-passcode', () => {
it('assign result and redirect', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/verify-passcode`)
.send({ email: 'a@a.com', code: '1234' });
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo');
expect(interactionResult).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
login: { accountId: 'id' },
forgotPassword: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresAt: expect.any(dayjs),
},
}),
expect.anything()
);
});
it('throw error if email does not exist', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/verify-passcode`)
.send({ email: 'b@a.com', code: '1234' });
expect(response.statusCode).toEqual(422);
});
it('throw error if verifyPasscode failed', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
.send({ email: 'a@a.com', code: '1231' });
expect(response.statusCode).toEqual(400);
});
});
});

View file

@ -1,12 +1,23 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import dayjs from 'dayjs';
import { Provider } from 'oidc-provider';
import { z } from 'zod';
import { createPasscode, sendPasscode } from '@/lib/passcode';
import RequestError from '@/errors/RequestError';
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
import { assignInteractionResults } from '@/lib/session';
import koaGuard from '@/middleware/koa-guard';
import {
findUserByEmail,
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import { forgotPasswordVerificationTimeout } from './consts';
import { getRoutePrefix } from './utils';
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
@ -33,6 +44,35 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
}
);
router.post(
`${forgotPasswordRoute}/sms/verify-passcode`,
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx), code: z.string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone, code } = ctx.guard.body;
const type = 'ForgotPasswordSms';
ctx.log(type, { phone, code });
assertThat(
await hasUserWithPhone(phone),
new RequestError({ code: 'user.phone_not_exists', status: 422 })
);
await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { phone });
const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id });
await assignInteractionResults(ctx, provider, {
login: { accountId: id },
forgotPassword: {
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'),
},
});
return next();
}
);
router.post(
`${forgotPasswordRoute}/email/send-passcode`,
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }),
@ -50,4 +90,31 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
return next();
}
);
router.post(
`${forgotPasswordRoute}/email/verify-passcode`,
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx), code: z.string() }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email, code } = ctx.guard.body;
const type = 'ForgotPasswordEmail';
ctx.log(type, { email, code });
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);
await assignInteractionResults(ctx, provider, {
login: { accountId: id },
forgotPassword: {
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second'),
},
});
return next();
}
);
}

View file

@ -147,7 +147,7 @@ describe('sessionRoutes', () => {
console.log(response);
});
it('should throw if admin user sign in to AC', async () => {
it('should not throw if admin user sign in to AC', async () => {
interactionDetails.mockResolvedValueOnce({
params: { client_id: adminConsoleApplicationId },
});

View file

@ -101,11 +101,23 @@ type ForgotPasswordSmsSendPasscodeLogPayload = ArbitraryLogPayload & {
connectorId?: string;
};
type ForgotPasswordSmsLogPayload = ArbitraryLogPayload & {
phone?: string;
code?: string;
userId?: string;
};
type ForgotPasswordEmailSendPasscodeLogPayload = ArbitraryLogPayload & {
email?: string;
connectorId?: string;
};
type ForgotPasswordEmailLogPayload = ArbitraryLogPayload & {
email?: string;
code?: string;
userId?: string;
};
export enum TokenType {
AccessToken = 'AccessToken',
RefreshToken = 'RefreshToken',
@ -142,7 +154,9 @@ export type LogPayloads = {
SignInSocialBind: SignInSocialBindLogPayload;
SignInSocial: SignInSocialLogPayload;
ForgotPasswordSmsSendPasscode: ForgotPasswordSmsSendPasscodeLogPayload;
ForgotPasswordSms: ForgotPasswordSmsLogPayload;
ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload;
ForgotPasswordEmail: ForgotPasswordEmailLogPayload;
CodeExchangeToken: ExchangeTokenLogPayload;
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
RevokeToken: RevokeTokenLogPayload;