0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat: social sign in (#218)

This commit is contained in:
Wang Sijie 2022-02-11 15:19:18 +08:00 committed by GitHub
parent 750ef0c3bf
commit 66808d6d02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 160 additions and 24 deletions

View file

@ -1,7 +1,9 @@
import got from 'got';
import got, { RequestError as GotRequestError } from 'got';
import { stringify } from 'query-string';
import { z } from 'zod';
import RequestError from '@/errors/RequestError';
import {
ConnectorConfigError,
ConnectorMetadata,
@ -67,6 +69,7 @@ export const getAccessToken: GetAccessToken = async (code) => {
const { clientId: client_id, clientSecret: client_secret } =
await getConnectorConfig<GithubConfig>(metadata.id);
const { access_token: accessToken } = await got
.post({
url: accessTokenEndpoint,
@ -78,6 +81,13 @@ export const getAccessToken: GetAccessToken = async (code) => {
})
.json<AccessTokenResponse>();
if (!accessToken) {
throw new RequestError({
code: 'connector.oauth_code_invalid',
status: 401,
});
}
return accessToken;
};
@ -89,23 +99,33 @@ export const getUserInfo: GetUserInfo = async (accessToken: string) => {
name: string;
};
const {
id,
avatar_url: avatar,
email,
name,
} = await got
.get(userInfoEndpoint, {
headers: {
authorization: `token ${accessToken}`,
},
})
.json<UserInfoResponse>();
try {
const {
id,
avatar_url: avatar,
email,
name,
} = await got
.get(userInfoEndpoint, {
headers: {
authorization: `token ${accessToken}`,
},
})
.json<UserInfoResponse>();
return {
id: String(id),
avatar,
email,
name,
};
return {
id: String(id),
avatar,
email,
name,
};
} catch (error: unknown) {
if (error instanceof GotRequestError && error.response?.statusCode === 401) {
throw new RequestError({
code: 'connector.access_token_invalid',
status: 401,
});
}
throw error;
}
};

View file

@ -4,7 +4,7 @@ import { findConnectorById, hasConnector, insertConnector } from '@/queries/conn
import * as AliyunDM from './aliyun-dm';
import * as AliyunSMS from './aliyun-sms';
import * as GitHub from './github';
import { ConnectorInstance, ConnectorType } from './types';
import { ConnectorInstance, ConnectorType, SocialConector } from './types';
const allConnectors: ConnectorInstance[] = [AliyunDM, AliyunSMS, GitHub];
@ -34,6 +34,24 @@ export const getConnectorInstanceById = async (id: string): Promise<ConnectorIns
return { connector, ...found };
};
const isSocialConnectorInstance = (connector: ConnectorInstance): connector is SocialConector => {
return connector.metadata.type === ConnectorType.Social;
};
export const getSocialConnectorInstanceById = async (id: string): Promise<SocialConector> => {
const connector = await getConnectorInstanceById(id);
if (!isSocialConnectorInstance(connector)) {
throw new RequestError({
code: 'entity.not_found',
id,
status: 404,
});
}
return connector;
};
export const getConnectorInstanceByType = async <T extends ConnectorInstance>(
type: ConnectorType
): Promise<T> => {

View file

@ -2,6 +2,7 @@ import { PasscodeType, UserLogType } from '@logto/schemas';
import { Context } from 'koa';
import { Provider } from 'oidc-provider';
import { getSocialConnectorInstanceById } from '@/connectors';
import RequestError from '@/errors/RequestError';
import { WithUserLogContext } from '@/middleware/koa-user-log';
import {
@ -9,6 +10,8 @@ import {
findUserByPhone,
hasUserWithEmail,
hasUserWithPhone,
hasUserWithIdentity,
findUserByIdentity,
} from '@/queries/user';
import assertThat from '@/utils/assert-that';
import { emailRegEx, phoneRegEx } from '@/utils/regex';
@ -98,3 +101,60 @@ export const signInWithPhoneAndPasscode = async (
await assignSignInResult(ctx, provider, id);
};
// 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<Context>,
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 getConnector = async (connectorId: string) => {
try {
return await getSocialConnectorInstanceById(connectorId);
} catch (error: unknown) {
// Throw a new error with status 422 when connector not found.
if (error instanceof RequestError && error.code === 'entity.not_found') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
data: { connectorId },
});
}
throw error;
}
};
export const signInWithSocial = async (
ctx: WithUserLogContext<Context>,
provider: Provider,
{ connectorId, code }: { connectorId: string; code: string }
) => {
ctx.userLog.connectorId = connectorId;
ctx.userLog.type = UserLogType.SignInSocial;
const connector = await getConnector(connectorId);
const accessToken = await connector.getAccessToken(code);
const userInfo = await connector.getUserInfo(accessToken);
assertThat(
await hasUserWithIdentity(connectorId, userInfo.id),
new RequestError({
code: 'user.identity_not_exists',
status: 422,
})
);
const { id } = await findUserByIdentity(connectorId, userInfo.id);
ctx.userLog.userId = id;
await assignSignInResult(ctx, provider, id);
};

View file

@ -14,6 +14,7 @@ export interface LogContext {
username?: string;
email?: string;
phone?: string;
connectorId?: string;
payload: UserLogPayload;
createdAt: number;
}

View file

@ -37,6 +37,15 @@ export const findUserById = async (id: string) =>
where ${fields.id}=${id}
`);
export const findUserByIdentity = async (connectorId: string, userId: string) =>
pool.one<User>(
sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.identities}::json#>>'{${sql.identifier([connectorId])},userId}' = ${userId}
`
);
export const hasUser = async (username: string) =>
pool.exists(sql`
select ${fields.id}
@ -65,6 +74,15 @@ export const hasUserWithPhone = async (phone: string) =>
where ${fields.primaryPhone}=${phone}
`);
export const hasUserWithIdentity = async (connectorId: string, userId: string) =>
pool.exists(
sql`
select ${fields.id}
from ${table}
where ${fields.identities}::json#>>'{${sql.identifier([connectorId])},userId}' = ${userId}
`
);
export const insertUser = buildInsertInto<CreateUser, User>(pool, Users, { returning: true });
export const findAllUsers = async () =>

View file

@ -10,8 +10,10 @@ import {
sendPasscodeToEmail,
} from '@/lib/register';
import {
assignRedirectUrlForSocial,
sendSignInWithEmailPasscode,
sendSignInWithPhonePasscode,
signInWithSocial,
signInWithEmailAndPasscode,
signInWithPhoneAndPasscode,
signInWithUsernameAndPassword,
@ -31,6 +33,8 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
email: string().optional(),
phone: string().optional(),
code: string().optional(),
connectorId: string().optional(),
state: string().optional(),
}),
}),
async (ctx, next) => {
@ -50,9 +54,13 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
}
if (name === 'login') {
const { username, password, email, phone, code } = ctx.guard.body;
const { username, password, email, phone, code, connectorId, state } = ctx.guard.body;
if (email && !code) {
if (connectorId && state && !code) {
await assignRedirectUrlForSocial(ctx, connectorId, state);
} else if (connectorId && code) {
await signInWithSocial(ctx, provider, { connectorId, code });
} else if (email && !code) {
await sendSignInWithEmailPasscode(ctx, jti, email);
} else if (email && code) {
await signInWithEmailAndPasscode(ctx, provider, { jti, email, code });

View file

@ -50,6 +50,7 @@ const errors = {
invalid_phone: 'Invalid phone number.',
email_not_exists: 'The email address has not been registered yet.',
phone_not_exists: 'The phone number has not been registered yet.',
identity_not_exists: 'The social account has not been registered yet.',
},
password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
@ -59,10 +60,14 @@ const errors = {
not_found: 'Session not found. Please go back and sign in again.',
invalid_credentials: 'Invalid credentials. Please check your input.',
invalid_sign_in_method: 'Current sign-in method is not available.',
invalid_connector_id: 'Unable to find available connector with id {{connectorId}}.',
insufficient_info: 'Insufficent sign-in info.',
},
connector: {
not_found: 'Cannot find any available connector for type: {{type}}.',
not_enabled: 'The connector is not enabled.',
access_token_invalid: "Connector's access token is invalid.",
oauth_code_invalid: 'Unable to get access token, please check authorization code.',
},
passcode: {
phone_email_empty: 'Both phone and email are empty.',

View file

@ -51,6 +51,7 @@ const errors = {
invalid_phone: '手机号码不正确。',
email_not_exists: '邮箱地址尚未注册。',
phone_not_exists: '手机号码尚未注册。',
identity_not_exists: '该社交账号尚未注册。',
},
password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}。',
@ -61,9 +62,13 @@ const errors = {
invalid_credentials: '用户名或密码错误,请检查您的输入。',
invalid_sign_in_method: '当前登录方式不可用。',
insufficient_info: '登录信息缺失,请检查您的输入。',
invalid_connector_id: '无法找到 ID 为 {{connectorId}} 的可用连接器。',
},
connector: {
not_found: '找不到可用的 {{type}} 类型的连接器.',
not_found: '找不到可用的 {{type}} 类型的连接器。',
not_enabled: '连接器尚未启用。',
access_token_invalid: '当前连接器的 access_token 无效。',
oauth_code_invalid: '无法获取 access_token请检查授权 code 是否有效。',
},
passcode: {
phone_email_empty: '手机号与邮箱地址均为空。',

View file

@ -14,6 +14,7 @@ export enum UserLogType {
SignInUsernameAndPassword = 'SignInUsernameAndPassword',
SignInEmail = 'SignInEmail',
SignInSms = 'SignInSms',
SignInSocial = 'SignInSocial',
ExchangeAccessToken = 'ExchangeAccessToken',
}
export enum UserLogResult {

View file

@ -1,4 +1,4 @@
create type user_log_type as enum ('SignInUsernameAndPassword', 'SignInEmail', 'SignInSms', 'ExchangeAccessToken');
create type user_log_type as enum ('SignInUsernameAndPassword', 'SignInEmail', 'SignInSms', 'SignInSocial', 'ExchangeAccessToken');
create type user_log_result as enum ('Success', 'Failed');