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:
parent
af2600d828
commit
1ea39f3463
5 changed files with 166 additions and 2 deletions
1
packages/core/src/routes/session/consts.ts
Normal file
1
packages/core/src/routes/session/consts.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue