From bdee44a6b97ab6f0907fba76fed3ac3ba9a87119 Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Tue, 22 Feb 2022 16:01:32 +0800 Subject: [PATCH] feat: continue to register with social (#249) * feat: continue to register with social * feat: bind social account (#259) * feat: continue to register with social * feat: bind social account * feat: find social related user and sign in (#258) --- packages/core/src/lib/register.ts | 39 ++------------ packages/core/src/lib/sign-in.ts | 71 +++++++++++++++++++++----- packages/core/src/lib/social.ts | 47 +++++++++++++++-- packages/core/src/routes/session.ts | 73 +++++++++++++++++++++++---- packages/phrases/src/locales/en.ts | 2 + packages/phrases/src/locales/zh-cn.ts | 2 + 6 files changed, 169 insertions(+), 65 deletions(-) diff --git a/packages/core/src/lib/register.ts b/packages/core/src/lib/register.ts index 96707715c..243ccd5a4 100644 --- a/packages/core/src/lib/register.ts +++ b/packages/core/src/lib/register.ts @@ -2,20 +2,14 @@ import { PasscodeType, UserLogType } from '@logto/schemas'; import { Context } from 'koa'; import { Provider } from 'oidc-provider'; +import { SocialUserInfo } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; import { WithUserLogContext } from '@/middleware/koa-user-log'; -import { - hasUser, - hasUserWithEmail, - hasUserWithPhone, - hasUserWithIdentity, - insertUser, -} from '@/queries/user'; +import { hasUser, hasUserWithEmail, hasUserWithPhone, insertUser } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { emailRegEx, phoneRegEx } from '@/utils/regex'; import { createPasscode, sendPasscode, verifyPasscode } from './passcode'; -import { getUserInfoByConnectorCode, SocialUserInfoSession } from './social'; import { encryptUserPassword, generateUserId } from './user'; const assignRegistrationResult = async (ctx: Context, provider: Provider, userId: string) => { @@ -28,22 +22,6 @@ const assignRegistrationResult = async (ctx: Context, provider: Provider, userId 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 registerWithUsernameAndPassword = async ( ctx: WithUserLogContext, provider: Provider, @@ -169,18 +147,9 @@ export const registerWithPhoneAndPasscode = async ( export const registerWithSocial = async ( ctx: WithUserLogContext, provider: Provider, - { connectorId, code }: { connectorId: string; code: string } + connectorId: string, + userInfo: SocialUserInfo ) => { - const userInfo = await getUserInfoByConnectorCode(connectorId, code); - - if (await hasUserWithIdentity(connectorId, userInfo.id)) { - await saveUserInfoToSession(ctx, provider, { connectorId, userInfo }); - throw new RequestError({ - code: 'user.identity_exists', - status: 422, - }); - } - const id = await generateUserId(); await insertUser({ id, diff --git a/packages/core/src/lib/sign-in.ts b/packages/core/src/lib/sign-in.ts index 23e7be441..044b4f7dc 100644 --- a/packages/core/src/lib/sign-in.ts +++ b/packages/core/src/lib/sign-in.ts @@ -3,6 +3,7 @@ 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 { @@ -18,7 +19,11 @@ import assertThat from '@/utils/assert-that'; import { emailRegEx, phoneRegEx } from '@/utils/regex'; import { createPasscode, sendPasscode, verifyPasscode } from './passcode'; -import { getUserInfoByConnectorCode, getUserInfoFromInteractionResult } from './social'; +import { + findSocialRelatedUser, + getUserInfoFromInteractionResult, + SocialUserInfoSession, +} from './social'; import { findUserByUsernameAndPassword } from './user'; const assignSignInResult = async (ctx: Context, provider: Provider, userId: string) => { @@ -119,26 +124,42 @@ export const assignRedirectUrlForSocial = async ( 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, code, result }: { connectorId: string; code: string; result?: InteractionResults } + connectorId: string, + userInfo: SocialUserInfo ) => { ctx.userLog.connectorId = connectorId; ctx.userLog.type = UserLogType.SignInSocial; - const userInfo = - code === 'session' - ? await getUserInfoFromInteractionResult(connectorId, result) - : await getUserInfoByConnectorCode(connectorId, code); - - assertThat( - await hasUserWithIdentity(connectorId, userInfo.id), - new RequestError({ - code: 'user.identity_not_exists', - status: 422, - }) - ); + 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 @@ -148,3 +169,25 @@ export const signInWithSocial = async ( 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/lib/social.ts b/packages/core/src/lib/social.ts index ebeec8974..ffe79ddab 100644 --- a/packages/core/src/lib/social.ts +++ b/packages/core/src/lib/social.ts @@ -1,9 +1,16 @@ +import { User } from '@logto/schemas'; import { InteractionResults } from 'oidc-provider'; import { z } from 'zod'; import { getSocialConnectorInstanceById } from '@/connectors'; import { SocialUserInfo, socialUserInfoGuard } from '@/connectors/types'; import RequestError from '@/errors/RequestError'; +import { + findUserByEmail, + findUserByPhone, + hasUserWithEmail, + hasUserWithPhone, +} from '@/queries/user'; import assertThat from '@/utils/assert-that'; export interface SocialUserInfoSession { @@ -27,12 +34,12 @@ const getConnector = async (connectorId: string) => { } }; -export const getUserInfoByConnectorCode = async ( +export const getUserInfoByAuthCode = async ( connectorId: string, - code: string + authCode: string ): Promise => { const connector = await getConnector(connectorId); - const accessToken = await connector.getAccessToken(code); + const accessToken = await connector.getAccessToken(authCode); return connector.getUserInfo(accessToken); }; @@ -41,16 +48,46 @@ export const getUserInfoFromInteractionResult = async ( connectorId: string, interactionResult?: InteractionResults ): Promise => { - const result = z + const parse = z .object({ socialUserInfo: z.object({ connectorId: z.string(), userInfo: socialUserInfoGuard, }), }) - .parse(interactionResult); + .safeParse(interactionResult); + if (!parse.success) { + throw new RequestError('session.connector_session_not_found'); + } + + const result = parse.data; assertThat(result.socialUserInfo.connectorId === connectorId, 'session.connector_id_mismatch'); return result.socialUserInfo.userInfo; }; + +/** + * Find user by phone/email from social user info. + * if both phone and email exist, take phone for priority. + * + * @param info SocialUserInfo + * @returns null | [string, User] the first string idicating phone or email + */ +export const findSocialRelatedUser = async ( + info: SocialUserInfo +): Promise => { + if (info.phone && (await hasUserWithPhone(info.phone))) { + const user = await findUserByPhone(info.phone); + + return [info.phone, user]; + } + + if (info.email && (await hasUserWithEmail(info.email))) { + const user = await findUserByEmail(info.email); + + return [info.email, user]; + } + + return null; +}; diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index eaa4e7e8b..0459b80c5 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -1,7 +1,9 @@ import path from 'path'; import { LogtoErrorCode } from '@logto/phrases'; +import { userInfoSelectFields } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; +import pick from 'lodash.pick'; import { Provider } from 'oidc-provider'; import { object, string } from 'zod'; @@ -21,8 +23,11 @@ import { signInWithEmailAndPasscode, signInWithPhoneAndPasscode, signInWithUsernameAndPassword, + signInWithSocialRelatedUser, } from '@/lib/sign-in'; +import { getUserInfoByAuthCode, getUserInfoFromInteractionResult } from '@/lib/social'; import koaGuard from '@/middleware/koa-guard'; +import { findUserById, hasUserWithIdentity, updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from './types'; @@ -95,7 +100,6 @@ export default function sessionRoutes(router: T, prov body: object({ connectorId: string(), code: string().optional(), state: string() }), }), async (ctx, next) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); const { connectorId, code, state } = ctx.guard.body; if (!code) { @@ -105,7 +109,25 @@ export default function sessionRoutes(router: T, prov return next(); } - await signInWithSocial(ctx, provider, { connectorId, code, result }); + const userInfo = await getUserInfoByAuthCode(connectorId, code); + await signInWithSocial(ctx, provider, connectorId, userInfo); + + return next(); + } + ); + + router.post( + '/session/sign-in/social-related-user', + koaGuard({ + body: object({ connectorId: string() }), + }), + async (ctx, next) => { + 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 }); return next(); } @@ -206,21 +228,50 @@ export default function sessionRoutes(router: T, prov koaGuard({ body: object({ connectorId: string(), - code: string().optional(), - state: string().optional(), }), }), async (ctx, next) => { - const { connectorId, code, state } = ctx.guard.body; + const { connectorId } = ctx.guard.body; + const { result } = await provider.interactionDetails(ctx.req, ctx.res); - if (!code) { - assertThat(state, 'session.insufficient_info'); - await assignRedirectUrlForSocial(ctx, connectorId, state); + // User can not regsiter with social directly, + // need to try to sign in with social first, then confirm to register and continue, + // so the result is expected to be exists. + assertThat(result, 'session.connector_session_not_found'); - return next(); - } + const userInfo = await getUserInfoFromInteractionResult(connectorId, result); + assertThat(!(await hasUserWithIdentity(connectorId, userInfo.id)), 'user.identity_exists'); - await registerWithSocial(ctx, provider, { connectorId, code }); + await registerWithSocial(ctx, provider, connectorId, userInfo); + + return next(); + } + ); + + router.post( + '/session/bind-social', + koaGuard({ + body: object({ + connectorId: string(), + }), + }), + async (ctx, next) => { + const { connectorId } = ctx.guard.body; + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + assertThat(result, 'session.connector_session_not_found'); + assertThat(result.login?.accountId, 'session.unauthorized'); + + const userInfo = await getUserInfoFromInteractionResult(connectorId, result); + const user = await findUserById(result.login.accountId); + + const updatedUser = await updateUserById(user.id, { + identities: { + ...user.identities, + [connectorId]: { userId: userInfo.id, details: userInfo }, + }, + }); + + ctx.body = pick(updatedUser, ...userInfoSelectFields); return next(); } diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index dc8cd86d0..bbd1c2884 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -65,6 +65,8 @@ const errors = { invalid_connector_id: 'Unable to find available connector with id {{connectorId}}.', insufficient_info: 'Insufficent sign-in info.', connector_id_mismatch: 'The connectorId is mismatched with session record.', + connector_session_not_found: 'Connector session not found. Please go back and sign in again.', + unauthorized: 'Please sign in first.', }, connector: { general: 'An unexpected error occured in connector.', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 2c3acd402..fd5ac277a 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -66,6 +66,8 @@ const errors = { insufficient_info: '登录信息缺失,请检查您的输入。', invalid_connector_id: '无法找到 ID 为 {{connectorId}} 的可用连接器。', connector_id_mismatch: '传入的 connectorId 与 session 中保存的记录不一致。', + connector_session_not_found: '无法找到 connector 登录信息,请尝试重新登录。', + unauthorized: '请先登录。', }, connector: { general: 'Connector 发生未知错误。',