diff --git a/packages/types/model/user.ts b/packages/types/model/user.ts index 0c755a84..f8ab7ae6 100644 --- a/packages/types/model/user.ts +++ b/packages/types/model/user.ts @@ -1,10 +1,45 @@ +export const userType = ['normalUser', 'pluginBot', 'openapiBot'] as const; +export type UserType = (typeof userType)[number]; + export interface UserBaseInfo { _id: string; + /** + * Username cannot modify + * + * There must be one with email + */ + username?: string; + + /** + * E-mail cannot be modified + * required + */ email: string; + /** + * display name that can be modified + */ nickname: string; + /** + * Identifier, together with username constitutes a globally unique username + * use for search + * # + */ discriminator: string; avatar: string | null; + /** + * Is it a temporary user + * @default false + */ temporary: boolean; + type: UserType; emailVerified: boolean; extra?: Record; } + +export interface UserInfoWithPassword extends UserBaseInfo { + password: string; +} + +export interface UserInfoWithToken extends UserBaseInfo { + token: string; +} diff --git a/server/packages/sdk/src/index.ts b/server/packages/sdk/src/index.ts index d212ed4d..f99896dc 100644 --- a/server/packages/sdk/src/index.ts +++ b/server/packages/sdk/src/index.ts @@ -41,7 +41,7 @@ export type { } from './structs/group'; export { GroupPanelType } from './structs/group'; export { userType } from './structs/user'; -export type { UserStruct, UserType } from './structs/user'; +export type { UserStruct, UserType, UserStructWithToken } from './structs/user'; // db export * as db from './db'; diff --git a/server/packages/sdk/src/services/lib/errors.ts b/server/packages/sdk/src/services/lib/errors.ts index a41cfa03..8e4b9340 100644 --- a/server/packages/sdk/src/services/lib/errors.ts +++ b/server/packages/sdk/src/services/lib/errors.ts @@ -54,3 +54,9 @@ export class ServiceUnavailableError extends TcError { super('Service unavailable', 503, 'SERVICE_NOT_AVAILABLE', data); } } + +export class NotFoundError extends TcError { + constructor(data?: unknown) { + super('Not found', 404, 'NOT_FOUND', data); + } +} diff --git a/server/packages/sdk/src/structs/user.ts b/server/packages/sdk/src/structs/user.ts index 4417dfda..747bfc6b 100644 --- a/server/packages/sdk/src/structs/user.ts +++ b/server/packages/sdk/src/structs/user.ts @@ -1,47 +1,9 @@ -export const userType = ['normalUser', 'pluginBot', 'openapiBot'] as const; -export type UserType = (typeof userType)[number]; - -export interface UserStruct { - _id: string; - - /** - * 用户名 不可被修改 - * 与email必有一个 - */ - username?: string; - - /** - * 邮箱 不可被修改 - * 必填 - */ - email: string; - - password: string; - - /** - * 可以被修改的显示名 - */ - nickname: string; - - /** - * 识别器, 跟username构成全局唯一的用户名 - * 用于搜索 - * # - */ - discriminator: string; - - /** - * 是否为临时用户 - * @default false - */ - temporary: boolean; - - /** - * 头像 - */ - avatar?: string; - - type: UserType; - - emailVerified: boolean; -} +import type { UserBaseInfo, UserInfoWithToken, UserType } from 'tailchat-types'; +import { userType } from 'tailchat-types'; + +export { + userType, + UserType, + UserBaseInfo as UserStruct, + UserInfoWithToken as UserStructWithToken, +}; diff --git a/server/plugins/com.msgbyte.fim/models/fim.ts b/server/plugins/com.msgbyte.fim/models/fim.ts index 72de2d18..73ec744f 100644 --- a/server/plugins/com.msgbyte.fim/models/fim.ts +++ b/server/plugins/com.msgbyte.fim/models/fim.ts @@ -9,6 +9,23 @@ const { getModelForClass, prop, modelOptions, TimeStamps } = db; export class Fim extends TimeStamps implements db.Base { _id: db.Types.ObjectId; id: string; + + /** + * 账号供应商 + * 如 Github + */ + @prop() + provider: string; + + /** + * 在账号供应商那边的唯一标识 + * 根据不同的供应商有不同的格式 + */ + @prop() + providerId: string; + + @prop() + userId: string; } export type FimDocument = db.DocumentType; diff --git a/server/plugins/com.msgbyte.fim/services/fim.service.dev.ts b/server/plugins/com.msgbyte.fim/services/fim.service.dev.ts index d4b38f76..eea8cdb9 100644 --- a/server/plugins/com.msgbyte.fim/services/fim.service.dev.ts +++ b/server/plugins/com.msgbyte.fim/services/fim.service.dev.ts @@ -1,4 +1,5 @@ -import { TcService, TcDbService, TcPureContext } from 'tailchat-server-sdk'; +import { TcService, TcDbService, TcPureContext, db } from 'tailchat-server-sdk'; +import type { UserStructWithToken } from 'tailchat-server-sdk/src/structs/user'; import type { FimDocument, FimModel } from '../models/fim'; import { strategies } from '../strategies'; import type { StrategyType } from '../strategies/types'; @@ -15,23 +16,39 @@ class FimService extends TcService { } onInit() { - // this.registerLocalDb(require('../models/fim').default); - - strategies - .filter((strategy) => strategy.checkAvailable()) - .map((strategy) => { - const action = this.buildStrategyAction(strategy); - const name = strategy.name; - this.registerAction(`${name}.url`, action.url); - this.registerAction(`${name}.redirect`, action.redirect); - - this.registerAuthWhitelist([`/${name}/url`, `/${name}/redirect`]); - }); + this.registerLocalDb(require('../models/fim').default); + + const availableStrategies = strategies.filter((strategy) => + strategy.checkAvailable() + ); + + this.registerAction('availableStrategies', () => { + return availableStrategies.map((s) => ({ + name: s.name, + type: s.type, + })); + }); + + availableStrategies.forEach((strategy) => { + const action = this.buildStrategyAction(strategy); + const strategyName = strategy.name; + this.registerAction(`${strategyName}.loginUrl`, action.loginUrl); + this.registerAction(`${strategyName}.redirect`, action.redirect); + + this.registerAuthWhitelist([ + `/${strategyName}/loginUrl`, + `/${strategyName}/redirect`, + ]); + }); + + this.registerAuthWhitelist(['/availableStrategies']); } buildStrategyAction(strategy: StrategyType) { + const strategyName = strategy.name; + return { - url: async (ctx: TcPureContext) => { + loginUrl: async (ctx: TcPureContext) => { return strategy.getUrl(); }, redirect: async (ctx: TcPureContext<{ code: string }>) => { @@ -41,7 +58,53 @@ class FimService extends TcService { throw new Error(JSON.stringify(ctx.params)); } - return strategy.getUserInfo(code); + const providerUserInfo = await strategy.getUserInfo(code); + + const fimRecord = await this.adapter.model.findOne({ + provider: strategyName, + providerId: providerUserInfo.id, + }); + + if (!!fimRecord) { + // 存在记录,直接签发 token + const token = await ctx.call('user.signUserToken', { + userId: fimRecord.userId, + }); + + return { type: 'token', token }; + } + + // 不存在记录,查找是否已经注册过,如果已经注册过需要绑定,如果没有注册过则创建账号并绑定用户关系 + const userInfo = await ctx.call('user.findUserByEmail', { + email: providerUserInfo.email, + }); + if (!!userInfo) { + // 用户已存在,需要登录后才能确定绑定关系 + return { + type: 'existed', + }; + } + + const newUserInfo: UserStructWithToken = await ctx.call( + 'user.register', + { + email: providerUserInfo.email, + nickname: providerUserInfo.nickname, + password: new db.Types.ObjectId(), // random password + } + ); + + await this.adapter.model.create({ + provider: strategyName, + providerId: providerUserInfo.id, + userId: String(newUserInfo._id), + }); + + return { + type: 'token', + isNew: true, + token: newUserInfo.token, + }; }, }; } diff --git a/server/plugins/com.msgbyte.fim/strategies/github.ts b/server/plugins/com.msgbyte.fim/strategies/github.ts index f356b7e4..f627b2df 100644 --- a/server/plugins/com.msgbyte.fim/strategies/github.ts +++ b/server/plugins/com.msgbyte.fim/strategies/github.ts @@ -14,6 +14,7 @@ const redirect_uri = `${config.apiUrl}/api/plugin:com.msgbyte.fim/github/redirec export const GithubStrategy: StrategyType = { name: 'github', + type: 'oauth', checkAvailable: () => !!clientInfo.id && !!clientInfo.secret, getUrl: () => { return `${authorize_uri}?client_id=${clientInfo.id}&redirect_uri=${redirect_uri}`; @@ -21,28 +22,30 @@ export const GithubStrategy: StrategyType = { getUserInfo: async (code) => { console.log('authorization code:', code); - const tokenResponse = await got(access_token_uri, { - method: 'POST', - searchParams: { - client_id: clientInfo.id, - client_secret: clientInfo.secret, - code: code, - }, - headers: { - accept: 'application/json', - }, - }).json<{ access_token: string }>(); + const tokenResponse = await got + .post(access_token_uri, { + searchParams: { + client_id: clientInfo.id, + client_secret: clientInfo.secret, + code: code, + }, + headers: { + accept: 'application/json', + }, + }) + .json<{ access_token: string }>(); const accessToken = tokenResponse.access_token; console.log(`access token: ${accessToken}`); - const result = await got(userinfo_uri, { - method: 'GET', - headers: { - accept: 'application/json', - Authorization: `token ${accessToken}`, - }, - }).json<{ id: number; name: string; email: string; avatar_url: string }>(); + const result = await got + .get(userinfo_uri, { + headers: { + accept: 'application/json', + Authorization: `token ${accessToken}`, + }, + }) + .json<{ id: number; name: string; email: string; avatar_url: string }>(); return { id: String(result.id), diff --git a/server/plugins/com.msgbyte.fim/strategies/types.ts b/server/plugins/com.msgbyte.fim/strategies/types.ts index 5078a19a..f96bda29 100644 --- a/server/plugins/com.msgbyte.fim/strategies/types.ts +++ b/server/plugins/com.msgbyte.fim/strategies/types.ts @@ -1,5 +1,6 @@ export interface StrategyType { name: string; + type: 'oauth'; checkAvailable: () => boolean; getUrl: () => string; getUserInfo: (code: string) => Promise<{ diff --git a/server/services/core/user/user.service.ts b/server/services/core/user/user.service.ts index f50be669..4b1eeb4e 100644 --- a/server/services/core/user/user.service.ts +++ b/server/services/core/user/user.service.ts @@ -21,6 +21,7 @@ import { db, call, BannedError, + UserStructWithToken, } from 'tailchat-server-sdk'; import { generateRandomNumStr, @@ -29,6 +30,7 @@ import { } from '../../../lib/utils'; import type { TFunction } from 'i18next'; import _ from 'lodash'; +import type { UserStruct } from 'tailchat-server-sdk'; const { isValidObjectId, Types } = db; @@ -89,6 +91,12 @@ class UserService extends TcService { emailOTP: { type: 'string', optional: true }, }, }); + this.registerAction('signUserToken', this.signUserToken, { + visibility: 'public', + params: { + userId: 'string', + }, + }); this.registerAction('modifyPassword', this.modifyPassword, { rest: 'POST /modifyPassword', params: { @@ -185,6 +193,12 @@ class UserService extends TcService { }, }, }); + this.registerAction('findUserByEmail', this.findUserByEmail, { + visibility: 'public', + params: { + email: 'string', + }, + }); this.registerAction('updateUserField', this.updateUserField, { params: { fieldName: 'string', @@ -229,13 +243,13 @@ class UserService extends TcService { }, }); this.registerAction('generateUserToken', this.generateUserToken, { + visibility: 'public', params: { userId: 'string', nickname: 'string', email: 'string', avatar: 'string', }, - visibility: 'public', }); this.registerAuthWhitelist([ @@ -402,7 +416,7 @@ class UserService extends TcService { }, any > - ) { + ): Promise { const params = { ...ctx.params }; const t = ctx.meta.t; await this.validateEntity(params); @@ -452,6 +466,28 @@ class UserService extends TcService { return json; } + /** + * 签发token + * 仅内部可以调用 + */ + async signUserToken( + ctx: TcContext<{ + userId: string; + }> + ): Promise { + const userId = ctx.params.userId; + + const userInfo = await call(ctx).getUserInfo(userId); + const token = this.generateJWT({ + _id: userInfo._id, + nickname: userInfo.nickname, + email: userInfo.email, + avatar: userInfo.avatar, + }); + + return token; + } + /** * 修改密码 */ @@ -801,6 +837,29 @@ class UserService extends TcService { return list; } + /** + * 通过用户邮箱查找用户 + */ + async findUserByEmail( + ctx: TcContext<{ + email: string; + }> + ): Promise { + const email = ctx.params.email; + + const doc = await this.adapter.model.findOne({ + email, + }); + + if (!doc) { + return null; + } + + const user = await this.transformDocuments(ctx, {}, doc); + + return user; + } + /** * 修改用户字段 */