From 9dba4b14a041f93c8c2eb071a5dcee7869e4fc59 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Tue, 11 Oct 2022 14:31:26 +0800 Subject: [PATCH] refactor(ui,phrases,core): forgot password reuse verification flow (#2105) --- .../routes/session/forgot-password.test.ts | 162 ++++++------------ .../src/routes/session/forgot-password.ts | 144 ++-------------- .../src/routes/session/passwordless.test.ts | 74 ++++++++ .../core/src/routes/session/passwordless.ts | 38 +++- packages/core/src/routes/session/types.ts | 28 ++- packages/core/src/routes/session/utils.ts | 58 +++++-- packages/phrases/src/locales/en/errors.ts | 1 + packages/phrases/src/locales/fr/errors.ts | 1 + packages/phrases/src/locales/ko-kr/errors.ts | 1 + packages/phrases/src/locales/pt-pt/errors.ts | 1 + packages/phrases/src/locales/tr-tr/errors.ts | 1 + packages/phrases/src/locales/zh-cn/errors.ts | 1 + packages/ui/src/apis/forgot-password.ts | 14 +- packages/ui/src/apis/index.test.ts | 12 +- 14 files changed, 263 insertions(+), 273 deletions(-) diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index 8ceec189b..7503478fa 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -1,4 +1,4 @@ -import { User } from '@logto/schemas'; +import { PasscodeType, User } from '@logto/schemas'; import dayjs from 'dayjs'; import { Provider } from 'oidc-provider'; @@ -6,7 +6,6 @@ import { mockPasswordEncrypted, mockUserWithPassword } from '@/__mocks__'; import RequestError from '@/errors/RequestError'; import { createRequester } from '@/utils/test-utils'; -import { forgotPasswordVerificationTimeout } from './consts'; import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password'; const encryptUserPassword = jest.fn(async (password: string) => ({ @@ -75,108 +74,6 @@ describe('session -> forgotPasswordRoutes', () => { ], }); - 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/sms/verify-passcode', () => { - it('assign result and redirect', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/sms/verify-passcode`) - .send({ phone: '13000000000', code: '1234' }); - expect(response.statusCode).toEqual(204); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - forgotPassword: { - userId: 'id', - expiresAt: dayjs(fakeTime) - .add(forgotPasswordVerificationTimeout, 'second') - .toISOString(), - }, - }) - ); - jest.useRealTimers(); - }); - 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({ - 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(); - }); - }); - - describe('POST /session/forgot-password/email/verify-passcode', () => { - it('assign result and redirect', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/email/verify-passcode`) - .send({ email: 'a@a.com', code: '1234' }); - expect(response.statusCode).toEqual(204); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - forgotPassword: { - userId: 'id', - expiresAt: dayjs(fakeTime) - .add(forgotPasswordVerificationTimeout, 'second') - .toISOString(), - }, - }) - ); - jest.useRealTimers(); - }); - 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); - }); - }); - describe('POST /session/forgot-password/reset', () => { afterEach(() => { jest.clearAllMocks(); @@ -184,7 +81,11 @@ describe('session -> forgotPasswordRoutes', () => { it('assign result and redirect', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() }, + verification: { + id: 'id', + expiresAt: dayjs().add(1, 'day').toISOString(), + flow: PasscodeType.ForgotPassword, + }, }, }); const response = await sessionRequest @@ -199,10 +100,13 @@ describe('session -> forgotPasswordRoutes', () => { ); expect(response.statusCode).toEqual(204); }); - it('should throw when `accountId` is missing', async () => { + it('should throw when `id` is missing', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() }, + verification: { + expiresAt: dayjs().add(1, 'day').toISOString(), + flow: PasscodeType.ForgotPassword, + }, }, }); const response = await sessionRequest @@ -211,10 +115,26 @@ describe('session -> forgotPasswordRoutes', () => { expect(response).toHaveProperty('status', 404); expect(updateUserById).toBeCalledTimes(0); }); - it('should throw when `forgotPassword.expiresAt` is not string', async () => { + it('should throw when flow is not `forgot-password`', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: 0 }, + verification: { + id: 'id', + expiresAt: dayjs().add(1, 'day').toISOString(), + flow: PasscodeType.SignIn, + }, + }, + }); + const response = await sessionRequest + .post(`${forgotPasswordRoute}/reset`) + .send({ password: mockPasswordEncrypted }); + expect(response).toHaveProperty('status', 404); + expect(updateUserById).toBeCalledTimes(0); + }); + it('should throw when `verification.expiresAt` is not string', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { id: 'id', expiresAt: 0, flow: PasscodeType.ForgotPassword }, }, }); const response = await sessionRequest @@ -226,7 +146,11 @@ describe('session -> forgotPasswordRoutes', () => { it('should throw when `expiresAt` is not a valid date string', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: 'invalid date string' }, + verification: { + id: 'id', + expiresAt: 'invalid date string', + flow: PasscodeType.ForgotPassword, + }, }, }); const response = await sessionRequest @@ -238,7 +162,11 @@ describe('session -> forgotPasswordRoutes', () => { it('should throw when verification expires', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: dayjs().subtract(1, 'day').toISOString() }, + verification: { + id: 'id', + expiresAt: dayjs().subtract(1, 'day').toISOString(), + flow: PasscodeType.ForgotPassword, + }, }, }); const response = await sessionRequest @@ -250,7 +178,11 @@ describe('session -> forgotPasswordRoutes', () => { it('should throw when new password is the same as old one', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() }, + verification: { + id: 'id', + expiresAt: dayjs().add(1, 'day').toISOString(), + flow: PasscodeType.ForgotPassword, + }, }, }); mockArgon2Verify.mockResolvedValueOnce(true); @@ -263,7 +195,11 @@ describe('session -> forgotPasswordRoutes', () => { it('should redirect when there was no old password', async () => { interactionDetails.mockResolvedValueOnce({ result: { - forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() }, + verification: { + id: 'id', + expiresAt: dayjs().add(1, 'day').toISOString(), + flow: PasscodeType.ForgotPassword, + }, }, }); findUserById.mockResolvedValueOnce({ diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts index fb3e11d11..0b8c88479 100644 --- a/packages/core/src/routes/session/forgot-password.ts +++ b/packages/core/src/routes/session/forgot-password.ts @@ -1,153 +1,47 @@ -import { emailRegEx, passwordRegEx, phoneRegEx } from '@logto/core-kit'; -import { PasscodeType } from '@logto/schemas'; -import dayjs from 'dayjs'; +import { passwordRegEx } from '@logto/core-kit'; import { argon2Verify } from 'hash-wasm'; import { Provider } from 'oidc-provider'; import { z } from 'zod'; import RequestError from '@/errors/RequestError'; -import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode'; import { encryptUserPassword } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; -import { - findUserByEmail, - findUserById, - findUserByPhone, - hasUserWithEmail, - hasUserWithPhone, - updateUserById, -} from '@/queries/user'; +import { findUserById, updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; -import { forgotPasswordVerificationTimeout } from './consts'; -import { getRoutePrefix } from './utils'; +import { forgotPasswordSessionResultGuard } from './types'; +import { + clearVerificationResult, + getRoutePrefix, + getVerificationStorageFromInteraction, + checkValidateExpiration, +} from './utils'; export const forgotPasswordRoute = getRoutePrefix('forgot-password'); -const forgotPasswordVerificationGuard = z.object({ - forgotPassword: z.object({ userId: z.string(), expiresAt: z.string() }), -}); - export default function forgotPasswordRoutes( 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}/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 provider.interactionResult(ctx.req, ctx.res, { - forgotPassword: { - userId: id, - expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(), - }, - }); - 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(); - } - ); - - 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 provider.interactionResult(ctx.req, ctx.res, { - forgotPassword: { - userId: id, - expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(), - }, - }); - ctx.status = 204; - - return next(); - } - ); - router.post( `${forgotPasswordRoute}/reset`, koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }), async (ctx, next) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); const { password } = ctx.guard.body; - const forgotPasswordVerificationResult = forgotPasswordVerificationGuard.safeParse(result); - assertThat( - forgotPasswordVerificationResult.success, - new RequestError({ code: 'session.forgot_password_session_not_found', status: 404 }) + const verificationStorage = await getVerificationStorageFromInteraction( + ctx, + provider, + forgotPasswordSessionResultGuard ); - const { - forgotPassword: { userId: id, expiresAt }, - } = forgotPasswordVerificationResult.data; + const type = 'ForgotPasswordReset'; + ctx.log(type, verificationStorage); - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.forgot_password_verification_expired', status: 401 }) - ); + const { id, expiresAt } = verificationStorage; + + checkValidateExpiration(expiresAt); const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(id); @@ -159,10 +53,10 @@ export default function forgotPasswordRoutes( const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); - const type = 'ForgotPasswordReset'; ctx.log(type, { userId: id }); await updateUserById(id, { passwordEncrypted, passwordEncryptionMethod }); + await clearVerificationResult(ctx, provider); ctx.status = 204; return next(); diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index 1af81ea46..e81b17381 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -99,6 +99,16 @@ describe('session -> passwordlessRoutes', () => { }); expect(sendPasscode).toHaveBeenCalled(); }); + it('should call sendPasscode (with flow `forgot-password`)', async () => { + const response = await sessionRequest + .post('/session/passwordless/sms/send') + .send({ phone: '13000000000', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(204); + expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, { + phone: '13000000000', + }); + expect(sendPasscode).toHaveBeenCalled(); + }); it('throw when phone not given in input params', async () => { const response = await sessionRequest .post('/session/passwordless/sms/send') @@ -137,6 +147,16 @@ describe('session -> passwordlessRoutes', () => { }); expect(sendPasscode).toHaveBeenCalled(); }); + it('should call sendPasscode (with flow `forgot-password`)', async () => { + const response = await sessionRequest + .post('/session/passwordless/email/send') + .send({ email: 'a@a.com', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(204); + expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, { + email: 'a@a.com', + }); + expect(sendPasscode).toHaveBeenCalled(); + }); it('throw when email not given in input params', async () => { const response = await sessionRequest .post('/session/passwordless/email/send') @@ -194,6 +214,32 @@ describe('session -> passwordlessRoutes', () => { }) ); }); + it('should call interactionResult (with flow `forgot-password`)', async () => { + const fakeTime = new Date(); + jest.useFakeTimers().setSystemTime(fakeTime); + const response = await sessionRequest + .post('/session/passwordless/sms/verify') + .send({ phone: '13000000000', code: '1234', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(204); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + verification: { + id: 'id', + expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + flow: PasscodeType.ForgotPassword, + }, + }) + ); + }); + it('throw 404 (with flow `forgot-password`)', async () => { + const response = await sessionRequest + .post('/session/passwordless/sms/verify') + .send({ phone: '13000000001', code: '1234', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(404); + expect(interactionResult).toHaveBeenCalledTimes(0); + }); it('throw when code is wrong', async () => { const response = await sessionRequest .post('/session/passwordless/sms/verify') @@ -251,6 +297,34 @@ describe('session -> passwordlessRoutes', () => { }) ); }); + it('should call interactionResult (with flow `forgot-password`)', async () => { + const fakeTime = new Date(); + jest.useFakeTimers().setSystemTime(fakeTime); + const response = await sessionRequest + .post('/session/passwordless/email/verify') + .send({ email: 'a@a.com', code: '1234', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(204); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + verification: { + id: 'id', + expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + flow: PasscodeType.ForgotPassword, + }, + }) + ); + }); + it('throw 404 (with flow `forgot-password`)', async () => { + const fakeTime = new Date(); + jest.useFakeTimers().setSystemTime(fakeTime); + const response = await sessionRequest + .post('/session/passwordless/email/verify') + .send({ email: 'b@a.com', code: '1234', flow: PasscodeType.ForgotPassword }); + expect(response.statusCode).toEqual(404); + expect(interactionResult).toHaveBeenCalledTimes(0); + }); it('throw when code is wrong', async () => { const response = await sessionRequest .post('/session/passwordless/email/verify') diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index 76a855f1c..fb84eec9c 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -30,7 +30,7 @@ import { getPasswordlessRelatedLogType, getRoutePrefix, getVerificationStorageFromInteraction, - validateAndCheckWhetherVerificationExpires, + checkValidateExpiration, } from './utils'; export const registerRoute = getRoutePrefix('register', 'passwordless'); @@ -112,6 +112,20 @@ export default function passwordlessRoutes( await verifyPasscode(jti, flow, code, { phone }); + if (flow === PasscodeType.ForgotPassword) { + assertThat( + await hasUserWithPhone(phone), + new RequestError({ code: 'user.phone_not_exists', status: 404 }) + ); + + const { id } = await findUserByPhone(phone); + + await assignVerificationResult(ctx, provider, flow, { id }); + ctx.status = 204; + + return next(); + } + await assignVerificationResult(ctx, provider, flow, { phone }); ctx.status = 204; @@ -139,6 +153,20 @@ export default function passwordlessRoutes( await verifyPasscode(jti, flow, code, { email }); + if (flow === PasscodeType.ForgotPassword) { + assertThat( + await hasUserWithEmail(email), + new RequestError({ code: 'user.email_not_exists', status: 404 }) + ); + + const { id } = await findUserByEmail(email); + + await assignVerificationResult(ctx, provider, flow, { id }); + ctx.status = 204; + + return next(); + } + await assignVerificationResult(ctx, provider, flow, { email }); ctx.status = 204; @@ -158,7 +186,7 @@ export default function passwordlessRoutes( const { phone, expiresAt } = verificationStorage; - validateAndCheckWhetherVerificationExpires(expiresAt); + checkValidateExpiration(expiresAt); assertThat( await hasUserWithPhone(phone), @@ -185,7 +213,7 @@ export default function passwordlessRoutes( const { email, expiresAt } = verificationStorage; - validateAndCheckWhetherVerificationExpires(expiresAt); + checkValidateExpiration(expiresAt); assertThat( await hasUserWithEmail(email), @@ -212,7 +240,7 @@ export default function passwordlessRoutes( const { phone, expiresAt } = verificationStorage; - validateAndCheckWhetherVerificationExpires(expiresAt); + checkValidateExpiration(expiresAt); assertThat( !(await hasUserWithPhone(phone)), @@ -239,7 +267,7 @@ export default function passwordlessRoutes( const { email, expiresAt } = verificationStorage; - validateAndCheckWhetherVerificationExpires(expiresAt); + checkValidateExpiration(expiresAt); assertThat( !(await hasUserWithEmail(email)), diff --git a/packages/core/src/routes/session/types.ts b/packages/core/src/routes/session/types.ts index cbb53cc9c..6e8513507 100644 --- a/packages/core/src/routes/session/types.ts +++ b/packages/core/src/routes/session/types.ts @@ -11,16 +11,14 @@ export const operationGuard = z.enum(['send', 'verify']); export type Operation = z.infer; -export type VerifiedIdentity = { email: string } | { phone: string }; +export type VerifiedIdentity = { email: string } | { phone: string } | { id: string }; -export const verificationStorageGuard = z.object({ - email: z.string().optional(), - phone: z.string().optional(), - flow: passcodeTypeGuard, - expiresAt: z.string(), -}); - -export type VerificationStorage = z.infer; +export type VerificationStorage = + | SmsSignInSessionStorage + | EmailSignInSessionStorage + | SmsRegisterSessionStorage + | EmailRegisterSessionStorage + | ForgotPasswordSessionStorage; export type VerificationResult = { verification: T }; @@ -69,3 +67,15 @@ export type EmailRegisterSessionStorage = z.infer; + +export const forgotPasswordSessionResultGuard = z.object({ + verification: forgotPasswordSessionStorageGuard, +}); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 15a6cf7e7..f346956dd 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -3,13 +3,23 @@ import { Truthy } from '@silverhand/essentials'; import dayjs from 'dayjs'; import { Context } from 'koa'; import { Provider } from 'oidc-provider'; -import { ZodType, ZodTypeDef } from 'zod'; +import { z, ZodType } from 'zod'; import RequestError from '@/errors/RequestError'; import assertThat from '@/utils/assert-that'; import { verificationTimeout } from './consts'; -import { Method, Operation, VerificationResult, VerifiedIdentity } from './types'; +import { + emailRegisterSessionResultGuard, + emailSignInSessionResultGuard, + forgotPasswordSessionResultGuard, + Method, + Operation, + smsRegisterSessionResultGuard, + smsSignInSessionResultGuard, + VerificationResult, + VerifiedIdentity, +} from './types'; export const getRoutePrefix = ( type: 'sign-in' | 'register' | 'forgot-password', @@ -37,7 +47,7 @@ export const getPasswordlessRelatedLogType = ( const parseVerificationStorage = ( data: unknown, - resultGuard: ZodType, ZodTypeDef, unknown> + resultGuard: ZodType> ): T => { const verificationResult = resultGuard.safeParse(data); @@ -54,35 +64,57 @@ const parseVerificationStorage = ( return verificationResult.data.verification; }; -export const validateAndCheckWhetherVerificationExpires = (expiresAt: string) => { - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); -}; - export const getVerificationStorageFromInteraction = async ( ctx: Context, provider: Provider, - resultGuard: ZodType, ZodTypeDef, unknown> + resultGuard: ZodType> ): Promise => { const { result } = await provider.interactionDetails(ctx.req, ctx.res); return parseVerificationStorage(result, resultGuard); }; +export const checkValidateExpiration = (expiresAt: string) => { + assertThat( + dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), + new RequestError({ code: 'session.verification_expired', status: 401 }) + ); +}; + export const assignVerificationResult = async ( ctx: Context, provider: Provider, flow: PasscodeType, identity: VerifiedIdentity ) => { - const verificationStorage: VerificationResult = { + const verificationResult = { verification: { flow, expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), ...identity, }, }; - await provider.interactionResult(ctx.req, ctx.res, verificationStorage); + + assertThat( + smsSignInSessionResultGuard.safeParse(verificationResult).success || + emailSignInSessionResultGuard.safeParse(verificationResult).success || + smsRegisterSessionResultGuard.safeParse(verificationResult).success || + emailRegisterSessionResultGuard.safeParse(verificationResult).success || + forgotPasswordSessionResultGuard.safeParse(verificationResult).success, + new RequestError({ code: 'session.invalid_verification' }) + ); + + await provider.interactionResult(ctx.req, ctx.res, verificationResult); +}; + +export const clearVerificationResult = async (ctx: Context, provider: Provider) => { + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + + const verificationGuard = z.object({ verification: z.unknown() }); + const verificationGuardResult = verificationGuard.safeParse(result); + + if (result && verificationGuardResult.success) { + const { verification, ...rest } = result; + await provider.interactionResult(ctx.req, ctx.res, rest); + } }; diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 6c07fe41c..c3012733a 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -63,6 +63,7 @@ const errors = { verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', verification_expired: 'Passwordless verification has expired. Please go back and verify again.', + invalid_verification: 'Can not store invalid passwordless verification.', unauthorized: 'Please sign in first.', unsupported_prompt_name: 'Unsupported prompt name.', }, diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 42dc411f4..9c8a17705 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -68,6 +68,7 @@ const errors = { verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED + invalid_verification: 'Can not store invalid passwordless verification.', // UNTRANSLATED unauthorized: "Veuillez vous enregistrer d'abord.", unsupported_prompt_name: "Nom d'invite non supporté.", }, diff --git a/packages/phrases/src/locales/ko-kr/errors.ts b/packages/phrases/src/locales/ko-kr/errors.ts index 094b4b4d4..ea8fcb3f6 100644 --- a/packages/phrases/src/locales/ko-kr/errors.ts +++ b/packages/phrases/src/locales/ko-kr/errors.ts @@ -62,6 +62,7 @@ const errors = { verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED + invalid_verification: 'Can not store invalid passwordless verification.', // UNTRANSLATED unauthorized: '로그인을 먼저 해주세요.', unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.', }, diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 092339aed..bfece3fb0 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -64,6 +64,7 @@ const errors = { verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED + invalid_verification: 'Can not store invalid passwordless verification.', // UNTRANSLATED unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Nome de prompt não suportado.', }, diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 8544fcd74..9efaf1f15 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -64,6 +64,7 @@ const errors = { verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED + invalid_verification: 'Can not store invalid passwordless verification.', // UNTRANSLATED unauthorized: 'Lütfen önce oturum açın.', unsupported_prompt_name: 'Desteklenmeyen prompt adı.', }, diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index ef3168bb7..3f3f88ce6 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -59,6 +59,7 @@ const errors = { forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。', verification_session_not_found: '无法找到无密码流程验证信息,请尝试重新验证。', verification_expired: '无密码验证已过期。请返回重新验证。', + invalid_verification: '不要保存无效的无密码验证信息。', unauthorized: '请先登录', unsupported_prompt_name: '不支持的 prompt name', }, diff --git a/packages/ui/src/apis/forgot-password.ts b/packages/ui/src/apis/forgot-password.ts index b5d1daf3d..d9226f595 100644 --- a/packages/ui/src/apis/forgot-password.ts +++ b/packages/ui/src/apis/forgot-password.ts @@ -1,3 +1,5 @@ +import { PasscodeType } from '@logto/schemas'; + import api from './api'; type Response = { @@ -8,9 +10,10 @@ const forgotPasswordApiPrefix = '/api/session/forgot-password'; export const sendForgotPasswordSmsPasscode = async (phone: string) => { await api - .post(`${forgotPasswordApiPrefix}/sms/send-passcode`, { + .post('/api/session/passwordless/sms/send', { json: { phone, + flow: PasscodeType.ForgotPassword, }, }) .json(); @@ -20,19 +23,21 @@ export const sendForgotPasswordSmsPasscode = async (phone: string) => { export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => api - .post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, { + .post('/api/session/passwordless/sms/verify', { json: { phone, code, + flow: PasscodeType.ForgotPassword, }, }) .json(); export const sendForgotPasswordEmailPasscode = async (email: string) => { await api - .post(`${forgotPasswordApiPrefix}/email/send-passcode`, { + .post('/api/session/passwordless/email/send', { json: { email, + flow: PasscodeType.ForgotPassword, }, }) .json(); @@ -42,10 +47,11 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => { export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => api - .post(`${forgotPasswordApiPrefix}/email/verify-passcode`, { + .post('/api/session/passwordless/email/verify', { json: { email, code, + flow: PasscodeType.ForgotPassword, }, }) .json(); diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 1392088e6..f5ed44d29 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -201,38 +201,42 @@ describe('api', () => { it('sendForgotPasswordSmsPasscode', async () => { await sendForgotPasswordSmsPasscode(phone); - expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', { json: { phone, + flow: PasscodeType.ForgotPassword, }, }); }); it('verifyForgotPasswordSmsPasscode', async () => { await verifyForgotPasswordSmsPasscode(phone, code); - expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', { json: { phone, code, + flow: PasscodeType.ForgotPassword, }, }); }); it('sendForgotPasswordEmailPasscode', async () => { await sendForgotPasswordEmailPasscode(email); - expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', { json: { email, + flow: PasscodeType.ForgotPassword, }, }); }); it('verifyForgotPasswordEmailPasscode', async () => { await verifyForgotPasswordEmailPasscode(email, code); - expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', { json: { email, code, + flow: PasscodeType.ForgotPassword, }, }); });