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:
parent
750ef0c3bf
commit
66808d6d02
10 changed files with 160 additions and 24 deletions
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface LogContext {
|
|||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
connectorId?: string;
|
||||
payload: UserLogPayload;
|
||||
createdAt: number;
|
||||
}
|
||||
|
|
|
@ -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 () =>
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: '手机号与邮箱地址均为空。',
|
||||
|
|
|
@ -14,6 +14,7 @@ export enum UserLogType {
|
|||
SignInUsernameAndPassword = 'SignInUsernameAndPassword',
|
||||
SignInEmail = 'SignInEmail',
|
||||
SignInSms = 'SignInSms',
|
||||
SignInSocial = 'SignInSocial',
|
||||
ExchangeAccessToken = 'ExchangeAccessToken',
|
||||
}
|
||||
export enum UserLogResult {
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue