0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

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)
This commit is contained in:
Wang Sijie 2022-02-22 16:01:32 +08:00 committed by GitHub
parent d58b29f4a9
commit bdee44a6b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 169 additions and 65 deletions

View file

@ -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<Context>,
provider: Provider,
@ -169,18 +147,9 @@ export const registerWithPhoneAndPasscode = async (
export const registerWithSocial = async (
ctx: WithUserLogContext<Context>,
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,

View file

@ -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<Context>,
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<Context>,
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);
};

View file

@ -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<SocialUserInfo> => {
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<SocialUserInfo> => {
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<null | [string, User]> => {
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;
};

View file

@ -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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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();
}

View file

@ -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.',

View file

@ -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 发生未知错误。',