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:
parent
d58b29f4a9
commit
bdee44a6b9
6 changed files with 169 additions and 65 deletions
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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 发生未知错误。',
|
||||
|
|
Loading…
Add table
Reference in a new issue