From 89a185c845cb4dc6d0949900a98525a057932581 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 24 Feb 2022 11:58:52 +0800 Subject: [PATCH] chore(core): refactor sign-in routes (#273) * chore(core): refactor sign-in routes * feat(core): fix order of userLog assignments --- packages/core/src/lib/session.ts | 18 +++ packages/core/src/lib/sign-in.ts | 193 --------------------------- packages/core/src/routes/session.ts | 197 +++++++++++++++++----------- 3 files changed, 137 insertions(+), 271 deletions(-) create mode 100644 packages/core/src/lib/session.ts delete mode 100644 packages/core/src/lib/sign-in.ts diff --git a/packages/core/src/lib/session.ts b/packages/core/src/lib/session.ts new file mode 100644 index 000000000..68550cd21 --- /dev/null +++ b/packages/core/src/lib/session.ts @@ -0,0 +1,18 @@ +import { Context } from 'koa'; +import { InteractionResults, Provider } from 'oidc-provider'; + +// TODO: change this after frontend is ready. +// Should combine baseUrl(domain) from database with a 'callback' endpoint. +export const connectorRedirectUrl = 'https://logto.dev/callback'; + +export const assignInteractionResults = async ( + ctx: Context, + provider: Provider, + result: InteractionResults, + merge = false +) => { + const redirectTo = await provider.interactionResult(ctx.req, ctx.res, result, { + mergeWithLastSubmission: merge, + }); + ctx.body = { redirectTo }; +}; diff --git a/packages/core/src/lib/sign-in.ts b/packages/core/src/lib/sign-in.ts deleted file mode 100644 index e484d4281..000000000 --- a/packages/core/src/lib/sign-in.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { PasscodeType, UserLogType } from '@logto/schemas'; -import { Context } from 'koa'; -import { InteractionResults, Provider } from 'oidc-provider'; - -import { getSocialConnectorInstanceById } from '@/connectors'; -import { SocialUserInfo } from '@/connectors/types'; -import RequestError from '@/errors/RequestError'; -import { WithUserLogContext } from '@/middleware/koa-user-log'; -import { - findUserByEmail, - findUserByPhone, - hasUserWithEmail, - hasUserWithPhone, - hasUserWithIdentity, - findUserByIdentity, - updateUserById, -} from '@/queries/user'; -import assertThat from '@/utils/assert-that'; -import { emailRegEx, phoneRegEx } from '@/utils/regex'; - -import { createPasscode, sendPasscode, verifyPasscode } from './passcode'; -import { - findSocialRelatedUser, - getUserInfoFromInteractionResult, - SocialUserInfoSession, -} from './social'; -import { findUserByUsernameAndPassword } from './user'; - -const assignSignInResult = async (ctx: Context, provider: Provider, userId: string) => { - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { - login: { accountId: userId }, - }, - { mergeWithLastSubmission: false } - ); - ctx.body = { redirectTo }; -}; - -export const sendSignInWithEmailPasscode = async (ctx: Context, jti: string, email: string) => { - assertThat(emailRegEx.test(email), new RequestError('user.invalid_email')); - assertThat( - await hasUserWithEmail(email), - new RequestError({ - code: 'user.email_not_exists', - status: 422, - }) - ); - const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); - await sendPasscode(passcode); - ctx.state = 204; -}; - -export const sendSignInWithPhonePasscode = async (ctx: Context, jti: string, phone: string) => { - assertThat(phoneRegEx.test(phone), new RequestError('user.invalid_phone')); - assertThat( - await hasUserWithPhone(phone), - new RequestError({ - code: 'user.phone_not_exists', - status: 422, - }) - ); - const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); - await sendPasscode(passcode); - ctx.state = 204; -}; - -export const signInWithUsernameAndPassword = async ( - ctx: WithUserLogContext, - provider: Provider, - username: string, - password: string -) => { - assertThat(username && password, 'session.insufficient_info'); - - const { id } = await findUserByUsernameAndPassword(username, password); - await assignSignInResult(ctx, provider, id); - ctx.userLog.userId = id; - ctx.userLog.username = username; - ctx.userLog.type = UserLogType.SignInUsernameAndPassword; -}; - -export const signInWithEmailAndPasscode = async ( - ctx: WithUserLogContext, - provider: Provider, - { jti, email, code }: { jti: string; email: string; code: string } -) => { - await verifyPasscode(jti, PasscodeType.SignIn, code, { email }); - const { id } = await findUserByEmail(email); - - await assignSignInResult(ctx, provider, id); - ctx.userLog.userId = id; - ctx.userLog.email = email; - ctx.userLog.type = UserLogType.SignInEmail; -}; - -export const signInWithPhoneAndPasscode = async ( - ctx: WithUserLogContext, - provider: Provider, - { jti, phone, code }: { jti: string; phone: string; code: string } -) => { - await verifyPasscode(jti, PasscodeType.SignIn, code, { phone }); - const { id } = await findUserByPhone(phone); - - await assignSignInResult(ctx, provider, id); - ctx.userLog.userId = id; - ctx.userLog.phone = phone; - ctx.userLog.type = UserLogType.SignInPhone; -}; - -// TODO: change this after frontend is ready. -// Should combine baseUrl(domain) from database with a 'callback' endpoint. -const connectorRedirectUrl = 'https://logto.dev/callback'; - -export const assignRedirectUrlForSocial = async ( - ctx: WithUserLogContext, - connectorId: string, - state: string -) => { - const connector = await getSocialConnectorInstanceById(connectorId); - assertThat(connector.connector.enabled, 'connector.not_enabled'); - const redirectTo = await connector.getAuthorizationUri(connectorRedirectUrl, state); - ctx.body = { redirectTo }; -}; - -const saveUserInfoToSession = async ( - ctx: Context, - provider: Provider, - socialUserInfo: SocialUserInfoSession -) => { - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { - socialUserInfo, - }, - { mergeWithLastSubmission: true } - ); - ctx.body = { redirectTo }; -}; - -export const signInWithSocial = async ( - ctx: WithUserLogContext, - provider: Provider, - connectorId: string, - userInfo: SocialUserInfo -) => { - ctx.userLog.connectorId = connectorId; - ctx.userLog.type = UserLogType.SignInSocial; - - if (!(await hasUserWithIdentity(connectorId, userInfo.id))) { - await saveUserInfoToSession(ctx, provider, { connectorId, userInfo }); - const relatedInfo = await findSocialRelatedUser(userInfo); - throw new RequestError( - { - code: 'user.identity_not_exists', - status: 422, - }, - relatedInfo && { relatedUser: relatedInfo[0] } - ); - } - - const { id, identities } = await findUserByIdentity(connectorId, userInfo.id); - // Update social connector's user info - await updateUserById(id, { - identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, - }); - ctx.userLog.userId = id; - await assignSignInResult(ctx, provider, id); -}; - -export const signInWithSocialRelatedUser = async ( - ctx: WithUserLogContext, - provider: Provider, - { connectorId, result }: { connectorId: string; result: InteractionResults } -) => { - ctx.userLog.connectorId = connectorId; - ctx.userLog.type = UserLogType.SignInSocial; - - const userInfo = await getUserInfoFromInteractionResult(connectorId, result); - const relatedInfo = await findSocialRelatedUser(userInfo); - - assertThat(relatedInfo, 'session.connector_session_not_found'); - - const { id, identities } = relatedInfo[1]; - - await updateUserById(id, { - identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, - }); - ctx.userLog.userId = id; - await assignSignInResult(ctx, provider, id); -}; diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index cd967a3b8..648e6929b 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -7,20 +7,16 @@ import pick from 'lodash.pick'; import { Provider } from 'oidc-provider'; 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 { - assignRedirectUrlForSocial, - sendSignInWithEmailPasscode, - sendSignInWithPhonePasscode, - signInWithSocial, - signInWithEmailAndPasscode, - signInWithPhoneAndPasscode, - signInWithUsernameAndPassword, - signInWithSocialRelatedUser, -} from '@/lib/sign-in'; -import { getUserInfoByAuthCode, getUserInfoFromInteractionResult } from '@/lib/social'; -import { encryptUserPassword, generateUserId } from '@/lib/user'; + findSocialRelatedUser, + getUserInfoByAuthCode, + getUserInfoFromInteractionResult, +} from '@/lib/social'; +import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { hasUser, @@ -30,6 +26,9 @@ import { insertUser, findUserById, updateUserById, + findUserByEmail, + findUserByPhone, + findUserByIdentity, } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { emailRegEx, phoneRegEx } from '@/utils/regex'; @@ -55,8 +54,16 @@ export default function sessionRoutes(router: T, prov '/session/sign-in/username-password', koaGuard({ body: object({ username: string(), password: string() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.SignInUsernameAndPassword; const { username, password } = ctx.guard.body; - await signInWithUsernameAndPassword(ctx, provider, username, password); + + assertThat(username && password, 'session.insufficient_info'); + ctx.userLog.username = username; + + const { id } = await findUserByUsernameAndPassword(username, password); + ctx.userLog.userId = id; + + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -66,16 +73,33 @@ export default function sessionRoutes(router: T, prov '/session/sign-in/passwordless/phone', koaGuard({ body: object({ phone: string(), code: string().optional() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.SignInPhone; 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; + if (!code) { - await sendSignInWithPhonePasscode(ctx, jti, phone); + const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); + await sendPasscode(passcode); + ctx.state = 204; return next(); } - await signInWithPhoneAndPasscode(ctx, provider, { jti, phone, code }); + await verifyPasscode(jti, PasscodeType.SignIn, code, { phone }); + const { id } = await findUserByPhone(phone); + ctx.userLog.userId = id; + + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -85,16 +109,33 @@ export default function sessionRoutes(router: T, prov '/session/sign-in/passwordless/email', koaGuard({ body: object({ email: string(), code: string().optional() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.SignInEmail; 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; + if (!code) { - await sendSignInWithEmailPasscode(ctx, jti, email); + const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); + await sendPasscode(passcode); + ctx.state = 204; return next(); } - await signInWithEmailAndPasscode(ctx, provider, { jti, email, code }); + await verifyPasscode(jti, PasscodeType.SignIn, code, { email }); + const { id } = await findUserByEmail(email); + ctx.userLog.userId = id; + + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -106,17 +147,43 @@ 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; + if (!code) { assertThat(state, 'session.insufficient_info'); - await assignRedirectUrlForSocial(ctx, connectorId, state); + const connector = await getSocialConnectorInstanceById(connectorId); + assertThat(connector.connector.enabled, 'connector.not_enabled'); + const redirectTo = await connector.getAuthorizationUri(connectorRedirectUrl, state); + ctx.body = { redirectTo }; return next(); } const userInfo = await getUserInfoByAuthCode(connectorId, code); - await signInWithSocial(ctx, provider, connectorId, userInfo); + + if (!(await hasUserWithIdentity(connectorId, userInfo.id))) { + await assignInteractionResults(ctx, provider, { connectorId, userInfo }, true); + const relatedInfo = await findSocialRelatedUser(userInfo); + throw new RequestError( + { + code: 'user.identity_not_exists', + status: 422, + }, + relatedInfo && { relatedUser: relatedInfo[0] } + ); + } + + const { id, identities } = await findUserByIdentity(connectorId, userInfo.id); + ctx.userLog.userId = id; + + // Update social connector's user info + await updateUserById(id, { + identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, + }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -128,12 +195,26 @@ export default function sessionRoutes(router: T, prov body: object({ connectorId: string() }), }), async (ctx, next) => { + ctx.userLog.type = UserLogType.SignInSocial; const { connectorId } = ctx.guard.body; - const { result } = await provider.interactionDetails(ctx.req, ctx.res); + assertThat(result, 'session.connector_session_not_found'); - await signInWithSocialRelatedUser(ctx, provider, { connectorId, result }); + ctx.userLog.connectorId = connectorId; + + const userInfo = await getUserInfoFromInteractionResult(connectorId, result); + const relatedInfo = await findSocialRelatedUser(userInfo); + + assertThat(relatedInfo, 'session.connector_session_not_found'); + + const { id, identities } = relatedInfo[1]; + ctx.userLog.userId = id; + + await updateUserById(id, { + identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, + }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -169,13 +250,7 @@ export default function sessionRoutes(router: T, prov const finalGrantId = await grant.save(); // V2: configure consent - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { consent: { grantId: finalGrantId } }, - { mergeWithLastSubmission: true } - ); - ctx.body = { redirectTo }; + await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true); return next(); }); @@ -184,7 +259,9 @@ export default function sessionRoutes(router: T, prov '/session/register/username-password', koaGuard({ body: object({ username: string(), password: string() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.RegisterUsernameAndPassword; const { username, password } = ctx.guard.body; + assertThat( username && password, new RequestError({ @@ -199,8 +276,10 @@ export default function sessionRoutes(router: T, prov status: 422, }) ); + ctx.userLog.username = username; const id = await generateUserId(); + ctx.userLog.userId = id; const { passwordEncryptionSalt, passwordEncrypted, passwordEncryptionMethod } = encryptUserPassword(id, password); @@ -212,20 +291,7 @@ export default function sessionRoutes(router: T, prov passwordEncryptionMethod, passwordEncryptionSalt, }); - - ctx.userLog.userId = id; - ctx.userLog.username = username; - ctx.userLog.type = UserLogType.RegisterUsernameAndPassword; - - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { login: { accountId: id } }, - { - mergeWithLastSubmission: false, - } - ); - ctx.body = { redirectTo }; + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -235,6 +301,7 @@ export default function sessionRoutes(router: T, prov '/session/register/passwordless/phone', koaGuard({ body: object({ phone: string(), code: string().optional() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.RegisterPhone; const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { phone, code } = ctx.guard.body; @@ -243,6 +310,7 @@ export default function sessionRoutes(router: T, prov !(await hasUserWithPhone(phone)), new RequestError({ code: 'user.phone_exists_register', status: 422 }) ); + ctx.userLog.phone = phone; if (!code) { const passcode = await createPasscode(jti, PasscodeType.Register, { phone }); @@ -254,20 +322,10 @@ export default function sessionRoutes(router: T, prov await verifyPasscode(jti, PasscodeType.Register, code, { phone }); const id = await generateUserId(); + ctx.userLog.userId = id; + await insertUser({ id, primaryPhone: phone }); - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { login: { accountId: id } }, - { mergeWithLastSubmission: false } - ); - ctx.body = { redirectTo }; - ctx.userLog = { - ...ctx.userLog, - type: UserLogType.RegisterPhone, - userId: id, - phone, - }; + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -277,6 +335,7 @@ export default function sessionRoutes(router: T, prov '/session/register/passwordless/email', koaGuard({ body: object({ email: string(), code: string().optional() }) }), async (ctx, next) => { + ctx.userLog.type = UserLogType.RegisterPhone; const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { email, code } = ctx.guard.body; @@ -285,6 +344,7 @@ export default function sessionRoutes(router: T, prov !(await hasUserWithEmail(email)), new RequestError({ code: 'user.email_exists_register', status: 422 }) ); + ctx.userLog.email = email; if (!code) { const passcode = await createPasscode(jti, PasscodeType.Register, { email }); @@ -296,20 +356,10 @@ export default function sessionRoutes(router: T, prov await verifyPasscode(jti, PasscodeType.Register, code, { email }); const id = await generateUserId(); + ctx.userLog.userId = id; + await insertUser({ id, primaryEmail: email }); - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { login: { accountId: id } }, - { mergeWithLastSubmission: false } - ); - ctx.body = { redirectTo }; - ctx.userLog = { - ...ctx.userLog, - type: UserLogType.RegisterPhone, - userId: id, - email, - }; + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -347,13 +397,7 @@ export default function sessionRoutes(router: T, prov }, }); - const redirectTo = await provider.interactionResult( - ctx.req, - ctx.res, - { login: { accountId: id } }, - { mergeWithLastSubmission: false } - ); - ctx.body = { redirectTo }; + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); } @@ -391,10 +435,7 @@ export default function sessionRoutes(router: T, prov router.delete('/session', async (ctx, next) => { await provider.interactionDetails(ctx.req, ctx.res); const error: LogtoErrorCode = 'oidc.aborted'; - const redirectTo = await provider.interactionResult(ctx.req, ctx.res, { - error, - }); - ctx.body = { redirectTo }; + await assignInteractionResults(ctx, provider, { error }); return next(); });