From 75d2bb3e9b0bee732b29ccf54df1c8f9834a4a71 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 24 Feb 2022 18:21:59 +0800 Subject: [PATCH] feat(core): separate passwordless flow routes (#280) * feat(core): separate passwordless flow routes * feat(core): rename routes * feat(core): wrap frequently called methods and rerank log assignment order * feat(core): rerank log assignment order --- packages/core/src/lib/session.ts | 37 ++++++ packages/core/src/routes/session.ts | 171 ++++++++++++++++------------ 2 files changed, 135 insertions(+), 73 deletions(-) diff --git a/packages/core/src/lib/session.ts b/packages/core/src/lib/session.ts index 68550cd21..6b2a84a9b 100644 --- a/packages/core/src/lib/session.ts +++ b/packages/core/src/lib/session.ts @@ -1,6 +1,11 @@ import { Context } from 'koa'; import { InteractionResults, Provider } from 'oidc-provider'; +import RequestError from '@/errors/RequestError'; +import { hasUserWithEmail, hasUserWithPhone } from '@/queries/user'; +import assertThat from '@/utils/assert-that'; +import { emailRegEx, phoneRegEx } from '@/utils/regex'; + // TODO: change this after frontend is ready. // Should combine baseUrl(domain) from database with a 'callback' endpoint. export const connectorRedirectUrl = 'https://logto.dev/callback'; @@ -16,3 +21,35 @@ export const assignInteractionResults = async ( }); ctx.body = { redirectTo }; }; + +export const checkEmailValidityAndAvailability = async (email: string) => { + assertThat(emailRegEx.test(email), new RequestError('user.invalid_email')); + assertThat( + !(await hasUserWithEmail(email)), + new RequestError({ code: 'user.email_exists_register', status: 422 }) + ); +}; + +export const checkEmailValidityAndExistence = async (email: string) => { + assertThat(emailRegEx.test(email), new RequestError('user.invalid_email')); + assertThat( + await hasUserWithEmail(email), + new RequestError({ code: 'user.email_not_exists', status: 422 }) + ); +}; + +export const checkPhoneNumberValidityAndAvailability = async (phone: string) => { + assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone')); + assertThat( + !(await hasUserWithPhone(phone)), + new RequestError({ code: 'user.phone_exists_register', status: 422 }) + ); +}; + +export const checkPhoneNumberValidityAndExistence = async (phone: string) => { + assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone')); + assertThat( + await hasUserWithPhone(phone), + new RequestError({ code: 'user.phone_not_exists', status: 422 }) + ); +}; diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index 648e6929b..0e7387260 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -10,7 +10,14 @@ import { object, string } from 'zod'; import { getSocialConnectorInstanceById } from '@/connectors'; import RequestError from '@/errors/RequestError'; import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode'; -import { assignInteractionResults, connectorRedirectUrl } from '@/lib/session'; +import { + assignInteractionResults, + connectorRedirectUrl, + checkEmailValidityAndAvailability, + checkEmailValidityAndExistence, + checkPhoneNumberValidityAndAvailability, + checkPhoneNumberValidityAndExistence, +} from '@/lib/session'; import { findSocialRelatedUser, getUserInfoByAuthCode, @@ -20,9 +27,7 @@ import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } fr import koaGuard from '@/middleware/koa-guard'; import { hasUser, - hasUserWithEmail, hasUserWithIdentity, - hasUserWithPhone, insertUser, findUserById, updateUserById, @@ -31,7 +36,6 @@ import { findUserByIdentity, } from '@/queries/user'; import assertThat from '@/utils/assert-that'; -import { emailRegEx, phoneRegEx } from '@/utils/regex'; import { AnonymousRouter } from './types'; @@ -70,30 +74,34 @@ export default function sessionRoutes(router: T, prov ); router.post( - '/session/sign-in/passwordless/phone', - koaGuard({ body: object({ phone: string(), code: string().optional() }) }), + '/session/sign-in/passwordless/phone/send-passcode', + koaGuard({ body: object({ phone: string() }) }), async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { phone } = ctx.guard.body; + ctx.userLog.phone = phone; ctx.userLog.type = UserLogType.SignInPhone; + + await checkPhoneNumberValidityAndExistence(phone); + + const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); + await sendPasscode(passcode); + ctx.state = 204; + + return next(); + } + ); + + router.post( + '/session/sign-in/passwordless/phone/verify-passcode', + koaGuard({ body: object({ phone: string(), code: string() }) }), + async (ctx, next) => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { phone, code } = ctx.guard.body; - - assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone')); - assertThat( - await hasUserWithPhone(phone), - new RequestError({ - code: 'user.phone_not_exists', - status: 422, - }) - ); ctx.userLog.phone = phone; + ctx.userLog.type = UserLogType.SignInPhone; - if (!code) { - const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); - await sendPasscode(passcode); - ctx.state = 204; - - return next(); - } + await checkPhoneNumberValidityAndExistence(phone); await verifyPasscode(jti, PasscodeType.SignIn, code, { phone }); const { id } = await findUserByPhone(phone); @@ -106,30 +114,34 @@ export default function sessionRoutes(router: T, prov ); router.post( - '/session/sign-in/passwordless/email', - koaGuard({ body: object({ email: string(), code: string().optional() }) }), + '/session/sign-in/passwordless/email/send-passcode', + koaGuard({ body: object({ email: string() }) }), 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.SignInEmail; + + await checkEmailValidityAndExistence(email); + + const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); + await sendPasscode(passcode); + ctx.state = 204; + + return next(); + } + ); + + router.post( + '/session/sign-in/passwordless/email/verify-passcode', + koaGuard({ body: object({ email: string(), code: string() }) }), + async (ctx, next) => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { email, code } = ctx.guard.body; - - assertThat(emailRegEx.test(email), new RequestError('user.invalid_email')); - assertThat( - await hasUserWithEmail(email), - new RequestError({ - code: 'user.email_not_exists', - status: 422, - }) - ); ctx.userLog.email = email; + ctx.userLog.type = UserLogType.SignInEmail; - if (!code) { - const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); - await sendPasscode(passcode); - ctx.state = 204; - - return next(); - } + await checkEmailValidityAndExistence(email); await verifyPasscode(jti, PasscodeType.SignIn, code, { email }); const { id } = await findUserByEmail(email); @@ -147,10 +159,9 @@ export default function sessionRoutes(router: T, prov body: object({ connectorId: string(), code: string().optional(), state: string() }), }), async (ctx, next) => { - ctx.userLog.type = UserLogType.SignInSocial; const { connectorId, code, state } = ctx.guard.body; - ctx.userLog.connectorId = connectorId; + ctx.userLog.type = UserLogType.SignInSocial; if (!code) { assertThat(state, 'session.insufficient_info'); @@ -298,27 +309,34 @@ export default function sessionRoutes(router: T, prov ); router.post( - '/session/register/passwordless/phone', - koaGuard({ body: object({ phone: string(), code: string().optional() }) }), + '/session/register/passwordless/phone/send-passcode', + koaGuard({ body: object({ phone: string() }) }), async (ctx, next) => { ctx.userLog.type = UserLogType.RegisterPhone; const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { phone, code } = ctx.guard.body; - - assertThat(phoneRegEx.test(phone), 'user.invalid_phone'); - assertThat( - !(await hasUserWithPhone(phone)), - new RequestError({ code: 'user.phone_exists_register', status: 422 }) - ); + const { phone } = ctx.guard.body; ctx.userLog.phone = phone; - if (!code) { - const passcode = await createPasscode(jti, PasscodeType.Register, { phone }); - await sendPasscode(passcode); - ctx.state = 204; + await checkPhoneNumberValidityAndAvailability(phone); - return next(); - } + const passcode = await createPasscode(jti, PasscodeType.Register, { phone }); + await sendPasscode(passcode); + ctx.state = 204; + + return next(); + } + ); + + router.post( + '/session/register/passwordless/phone/verify-passcode', + koaGuard({ body: object({ phone: string(), code: string() }) }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { phone, code } = ctx.guard.body; + ctx.userLog.phone = phone; + ctx.userLog.type = UserLogType.RegisterPhone; + + await checkPhoneNumberValidityAndAvailability(phone); await verifyPasscode(jti, PasscodeType.Register, code, { phone }); const id = await generateUserId(); @@ -332,27 +350,34 @@ export default function sessionRoutes(router: T, prov ); router.post( - '/session/register/passwordless/email', - koaGuard({ body: object({ email: string(), code: string().optional() }) }), + '/session/register/passwordless/email/send-passcode', + koaGuard({ body: object({ email: string() }) }), + 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.RegisterEmail; + + await checkEmailValidityAndAvailability(email); + + const passcode = await createPasscode(jti, PasscodeType.Register, { email }); + await sendPasscode(passcode); + ctx.state = 204; + + return next(); + } + ); + + router.post( + '/session/register/passwordless/email/verify-passcode', + koaGuard({ body: object({ email: string(), code: string() }) }), async (ctx, next) => { - ctx.userLog.type = UserLogType.RegisterPhone; const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { email, code } = ctx.guard.body; - - assertThat(emailRegEx.test(email), 'user.invalid_email'); - assertThat( - !(await hasUserWithEmail(email)), - new RequestError({ code: 'user.email_exists_register', status: 422 }) - ); ctx.userLog.email = email; + ctx.userLog.type = UserLogType.RegisterEmail; - if (!code) { - const passcode = await createPasscode(jti, PasscodeType.Register, { email }); - await sendPasscode(passcode); - ctx.state = 204; - - return next(); - } + await checkEmailValidityAndAvailability(email); await verifyPasscode(jti, PasscodeType.Register, code, { email }); const id = await generateUserId();