0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

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

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

View file

@ -0,0 +1,70 @@
import { Provider } from 'oidc-provider';
import { createRequester } from '@/utils/test-utils';
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password';
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
jest.mock('@/lib/passcode', () => ({
createPasscode: async () => ({ id: 'id' }),
sendPasscode: async () => sendPasscode(),
}));
const interactionResult = jest.fn(async () => 'redirectTo');
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
interactionResult,
})),
}));
afterEach(() => {
interactionResult.mockClear();
});
describe('session -> forgotPasswordRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: forgotPasswordRoutes,
provider: new Provider(''),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
ctx.log = jest.fn();
return next();
},
],
});
describe('POST /session/forgot-password/sms/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/sms/send-passcode`)
.send({ phone: '13000000000' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
});
describe('POST /session/forgot-password/email/send-passcode', () => {
beforeAll(() => {
interactionDetails.mockResolvedValueOnce({
jti: 'jti',
});
});
it('should call sendPasscode', async () => {
const response = await sessionRequest
.post(`${forgotPasswordRoute}/email/send-passcode`)
.send({ email: 'a@a.com' });
expect(response.statusCode).toEqual(204);
expect(sendPasscode).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,53 @@
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
import { PasscodeType } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { z } from 'zod';
import { createPasscode, sendPasscode } from '@/lib/passcode';
import koaGuard from '@/middleware/koa-guard';
import { AnonymousRouter } from '../types';
import { getRoutePrefix } from './utils';
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
export default function forgotPasswordRoutes<T extends AnonymousRouter>(
router: T,
provider: Provider
) {
router.post(
`${forgotPasswordRoute}/sms/send-passcode`,
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { phone } = ctx.guard.body;
const type = 'ForgotPasswordSmsSendPasscode';
ctx.log(type, { phone });
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { phone });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
router.post(
`${forgotPasswordRoute}/email/send-passcode`,
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }),
async (ctx, next) => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
const { email } = ctx.guard.body;
const type = 'ForgotPasswordEmailSendPasscode';
ctx.log(type, { email });
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { email });
const { dbEntry } = await sendPasscode(passcode);
ctx.log(type, { connectorId: dbEntry.id });
ctx.status = 204;
return next();
}
);
}

View file

@ -10,6 +10,7 @@ import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/ses
import assertThat from '@/utils/assert-that';
import { AnonymousRouter } from '../types';
import forgotPasswordRoutes from './forgot-password';
import koaGuardSessionAction from './middleware/koa-guard-session-action';
import passwordlessRoutes from './passwordless';
import socialRoutes from './social';
@ -87,4 +88,6 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
usernamePasswordRoutes(router, provider);
passwordlessRoutes(router, provider);
socialRoutes(router, provider);
forgotPasswordRoutes(router, provider);
}

View file

@ -1,7 +1,7 @@
import { Truthy } from '@silverhand/essentials';
export const getRoutePrefix = (
type: 'sign-in' | 'register',
type: 'sign-in' | 'register' | 'forgot-password',
method?: 'passwordless' | 'username-password' | 'social'
) => {
return ['session', type, method]

View file

@ -96,6 +96,16 @@ type SignInSocialLogPayload = SignInSocialBindLogPayload & {
redirectTo?: string;
};
type ForgotPasswordSmsSendPasscodeLogPayload = ArbitraryLogPayload & {
phone?: string;
connectorId?: string;
};
type ForgotPasswordEmailSendPasscodeLogPayload = ArbitraryLogPayload & {
email?: string;
connectorId?: string;
};
export enum TokenType {
AccessToken = 'AccessToken',
RefreshToken = 'RefreshToken',
@ -131,6 +141,8 @@ export type LogPayloads = {
SignInSms: SignInSmsLogPayload;
SignInSocialBind: SignInSocialBindLogPayload;
SignInSocial: SignInSocialLogPayload;
ForgotPasswordSmsSendPasscode: ForgotPasswordSmsSendPasscodeLogPayload;
ForgotPasswordEmailSendPasscode: ForgotPasswordEmailSendPasscodeLogPayload;
CodeExchangeToken: ExchangeTokenLogPayload;
RefreshTokenExchangeToken: ExchangeTokenLogPayload;
RevokeToken: RevokeTokenLogPayload;