diff --git a/config/.env.defaults b/config/.env.defaults index 514b2e9d..4c204540 100644 --- a/config/.env.defaults +++ b/config/.env.defaults @@ -44,6 +44,9 @@ WEB_WECHAT_APP_SECRET= MOBILE_WECHAT_APP_ID= MOBILE_WECHAT_APP_SECRET= +QQ_APP_ID= +QQ_APP_SECRET= + GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/config/defaults.yaml b/config/defaults.yaml index 1c7be4be..484477c9 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -69,6 +69,10 @@ oauth: - jpeg login: + qq: + enable: false + app_id: + app_secret: wechat: web: enable: false diff --git a/config/test.yaml b/config/test.yaml index 21000681..1819cbfc 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -64,6 +64,10 @@ oauth: - jpeg login: + qq: + enable: false + app_id: + app_secret: wechat: web: enable: true diff --git a/src/constants/Config.ts b/src/constants/Config.ts index 701be356..c04a1a16 100644 --- a/src/constants/Config.ts +++ b/src/constants/Config.ts @@ -43,6 +43,12 @@ export const WeChat = { }, }; +export const QQ = { + enable: config.login.qq.enable, + appId: config.login.qq.app_id, + appSecret: config.login.qq.app_secret, +}; + export const Github = { enable: config.login.github.enable, clientId: config.login.github.client_id, diff --git a/src/constants/Project.ts b/src/constants/Project.ts index 9435c648..b2f189a2 100644 --- a/src/constants/Project.ts +++ b/src/constants/Project.ts @@ -9,6 +9,7 @@ export enum Status { } export enum LoginPlatform { + QQ = "QQ", WeChat = "WeChat", Github = "Github", Apple = "Apple", diff --git a/src/dao/index.ts b/src/dao/index.ts index 21f2bb62..8093b011 100644 --- a/src/dao/index.ts +++ b/src/dao/index.ts @@ -3,6 +3,7 @@ import { RoomModel } from "../model/room/Room"; import { DAO } from "./Type"; import { RoomUserModel } from "../model/room/RoomUser"; import { UserModel } from "../model/user/User"; +import { UserQQModel } from "../model/user/QQ"; import { UserWeChatModel } from "../model/user/WeChat"; import { RoomPeriodicConfigModel } from "../model/room/RoomPeriodicConfig"; import { RoomPeriodicModel } from "../model/room/RoomPeriodic"; @@ -19,6 +20,8 @@ import { UserPhoneModel } from "../model/user/Phone"; export const UserDAO = DAOImplement(UserModel) as ReturnType>; +export const UserQQDAO = DAOImplement(UserQQModel) as ReturnType>; + export const UserWeChatDAO = DAOImplement(UserWeChatModel) as ReturnType>; export const UserGithubDAO = DAOImplement(UserGithubModel) as ReturnType>; diff --git a/src/model/index.ts b/src/model/index.ts index f4535fc6..4d1c9300 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -1,4 +1,5 @@ import { UserModel } from "./user/User"; +import { UserQQModel } from "./user/QQ"; import { UserWeChatModel } from "./user/WeChat"; import { UserGithubModel } from "./user/Github"; import { UserAppleModel } from "./user/Apple"; @@ -20,6 +21,7 @@ import { OAuthUsersModel } from "./oauth/oauth-users"; export type Model = | UserModel + | UserQQModel | UserWeChatModel | UserGithubModel | UserAppleModel diff --git a/src/model/user/QQ.ts b/src/model/user/QQ.ts new file mode 100644 index 00000000..3dbcf6ee --- /dev/null +++ b/src/model/user/QQ.ts @@ -0,0 +1,39 @@ +import { Column, Entity, Index } from "typeorm"; +import { Content } from "../Content"; + +@Entity({ + name: "user_qq", +}) +export class UserQQModel extends Content { + @Index("user_qq_user_uuid_uindex", { + unique: true, + }) + @Column({ + length: 40, + }) + user_uuid: string; + + @Column({ + length: 40, + comment: "qq nickname", + }) + user_name: string; + + @Column({ + length: 40, + comment: "qq open id", + }) + open_uuid: string; + + @Column({ + length: 40, + comment: "qq union id", + }) + union_uuid: string; + + @Index("user_qq_is_delete_index") + @Column({ + default: false, + }) + is_delete: boolean; +} diff --git a/src/thirdPartyService/TypeORMService.ts b/src/thirdPartyService/TypeORMService.ts index bb28eead..30710171 100644 --- a/src/thirdPartyService/TypeORMService.ts +++ b/src/thirdPartyService/TypeORMService.ts @@ -10,6 +10,7 @@ import { RoomPeriodicUserModel } from "../model/room/RoomPeriodicUser"; import { RoomRecordModel } from "../model/room/RoomRecord"; import { RoomUserModel } from "../model/room/RoomUser"; import { UserModel } from "../model/user/User"; +import { UserQQModel } from "../model/user/QQ"; import { UserWeChatModel } from "../model/user/WeChat"; import { UserGithubModel } from "../model/user/Github"; import { loggerServer, parseError } from "../logger"; @@ -30,6 +31,7 @@ export const dataSource = new DataSource({ port: MySQL.port, entities: [ UserModel, + UserQQModel, UserWeChatModel, UserGithubModel, UserAppleModel, diff --git a/src/utils/ParseConfig.ts b/src/utils/ParseConfig.ts index db285c38..52abd7bc 100644 --- a/src/utils/ParseConfig.ts +++ b/src/utils/ParseConfig.ts @@ -93,6 +93,11 @@ type Config = { app_secret: string; }; }; + qq: { + enable: boolean; + app_id: string; + app_secret: string; + }; github: { enable: boolean; client_id: string; diff --git a/src/utils/registryRoutersV2.ts b/src/utils/registryRoutersV2.ts index 1eafd575..b67b4b85 100644 --- a/src/utils/registryRoutersV2.ts +++ b/src/utils/registryRoutersV2.ts @@ -114,6 +114,7 @@ interface R { auth?: boolean; schema: S; autoHandle?: O; + enable?: boolean; }, ): void; } diff --git a/src/v1/controller/login/Router.ts b/src/v1/controller/login/Router.ts index b86a726e..8876d9a7 100644 --- a/src/v1/controller/login/Router.ts +++ b/src/v1/controller/login/Router.ts @@ -1,3 +1,4 @@ +import { QQWebCallback } from "./qq/Callback"; import { WechatWebCallback } from "./weChat/web/Callback"; import { WechatMobileCallback } from "./weChat/mobile/Callback"; import { GithubCallback } from "./github/Callback"; @@ -14,6 +15,7 @@ import { PhoneLogin } from "./phone/Phone"; export const loginRouters: Readonly>> = Object.freeze([ SetAuthUUID, LoginProcess, + QQWebCallback, WechatWebCallback, WechatMobileCallback, AppleJWT, diff --git a/src/v1/controller/login/platforms/LoginQQ.ts b/src/v1/controller/login/platforms/LoginQQ.ts new file mode 100644 index 00000000..dedfd448 --- /dev/null +++ b/src/v1/controller/login/platforms/LoginQQ.ts @@ -0,0 +1,153 @@ +import { LoginClassParams } from "abstract/login/Type"; +import { AbstractLogin } from "../../../../abstract/login"; +import { QQ } from "../../../../constants/Config"; +import { Gender } from "../../../../constants/Project"; +import { dataSource } from "../../../../thirdPartyService/TypeORMService"; +import { ServiceUser } from "../../../service/user/User"; +import { ServiceUserQQ } from "../../../service/user/UserQQ"; +import { ServiceCloudStorageFiles } from "../../../service/cloudStorage/CloudStorageFiles"; +import { ServiceCloudStorageConfigs } from "../../../service/cloudStorage/CloudStorageConfigs"; +import { ServiceCloudStorageUserFiles } from "../../../service/cloudStorage/CloudStorageUserFiles"; +import { ax } from "../../../utils/Axios"; + +export class LoginQQ extends AbstractLogin { + public readonly svc: RegisterService; + + constructor(params: LoginClassParams) { + super(params); + + this.svc = { + user: new ServiceUser(this.userUUID), + userQQ: new ServiceUserQQ(this.userUUID), + cloudStorageFiles: new ServiceCloudStorageFiles(), + cloudStorageConfigs: new ServiceCloudStorageConfigs(this.userUUID), + cloudStorageUserFiles: new ServiceCloudStorageUserFiles(this.userUUID), + }; + } + + public async register(info: RegisterInfo): Promise { + await dataSource.transaction(async t => { + const createUser = this.svc.user.create(info, t); + + const createUserWeChat = this.svc.userQQ.create(info, t); + + return await Promise.all([ + createUser, + createUserWeChat, + this.setGuidePPTX(this.svc, t), + ]); + }); + } + + public static async getUserInfoAndToken( + code: string, + ): Promise { + const accessToken = await LoginQQ.getToken(code); + const uuidInfo = await LoginQQ.getUUIDByAPI(accessToken); + const userInfo = await LoginQQ.getUserInfoByAPI(accessToken, uuidInfo.openUUID); + + return { + accessToken, + ...uuidInfo, + ...userInfo, + }; + } + + public static async getToken(code: string): Promise { + const QQ_CALLBACK = "https://flat-web.whiteboard.agora.io/qq/callback"; + const response = await ax.get( + `https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=${QQ.appId}&client_secret=${QQ.appSecret}&code=${code}&redirect_uri=${QQ_CALLBACK}&fmt=json`, + ); + + return response.data.access_token; + } + + public static async getUUIDByAPI(accessToken: string): Promise { + const response = await ax.get( + `https://graph.qq.com/oauth2.0/me?access_token=${accessToken}&unionid=1&fmt=json`, + ); + + if ("error" in response.data) { + throw new Error(String(response.data.error)); + } + + return { + openUUID: response.data.openid, + unionUUID: response.data.unionid, + }; + } + + public static async getUserInfoByAPI( + accessToken: string, + openUUID: string, + ): Promise { + const response = await ax.get( + `https://graph.qq.com/user/get_user_info?access_token=${accessToken}&oauth_consumer_key=${QQ.appId}&openid=${openUUID}`, + ); + + const { ret, nickname, figureurl_qq_1, figureurl_qq_2, gender } = response.data; + + if (ret !== 0) { + throw new Error(String(ret)); + } + + return { + userName: nickname, + avatarURL: figureurl_qq_2 || figureurl_qq_1, + gender: gender === "男" ? Gender["Man"] : Gender["Woman"], + }; + } +} + +interface RegisterService { + user: ServiceUser; + userQQ: ServiceUserQQ; + cloudStorageFiles: ServiceCloudStorageFiles; + cloudStorageUserFiles: ServiceCloudStorageUserFiles; + cloudStorageConfigs: ServiceCloudStorageConfigs; +} + +interface RegisterInfo { + userName: string; + avatarURL: string; + openUUID: string; + unionUUID: string; + gender: Gender; +} + +interface AccessToken { + readonly access_token: string; + readonly expires_in: string; + readonly refresh_token: string; +} + +interface QQUUIDResponse { + readonly client_id: string; + readonly openid: string; + readonly unionid: string; +} + +interface QQUUIDInfo { + readonly openUUID: string; + readonly unionUUID: string; +} + +interface QQUserInfo { + readonly userName: string; + readonly avatarURL: string; + readonly gender: Gender; +} + +interface QQUserResponse { + readonly ret: number; + readonly msg: string; + readonly nickname: string; + readonly figureurl_qq_1: string; + readonly figureurl_qq_2: string; + readonly gender: "男" | "女"; +} + +interface RequestFailed { + readonly error: number; + readonly error_description: string; +} diff --git a/src/v1/controller/login/qq/Callback.ts b/src/v1/controller/login/qq/Callback.ts new file mode 100644 index 00000000..0adfc460 --- /dev/null +++ b/src/v1/controller/login/qq/Callback.ts @@ -0,0 +1,96 @@ +import { v4 } from "uuid"; +import { FastifySchema, ResponseError } from "../../../../types/Server"; +import redisService from "../../../../thirdPartyService/RedisService"; +import { RedisKey } from "../../../../utils/Redis"; +import { parseError } from "../../../../logger"; +import { AbstractController } from "../../../../abstract/controller"; +import { Controller } from "../../../../decorator/Controller"; +import { QQ } from "../../../../constants/Config"; +import { LoginQQ } from "../platforms/LoginQQ"; +import { ServiceUserQQ } from "../../../service/user/UserQQ"; +import { LoginPlatform } from "../../../../constants/Project"; +import { failedHTML, successHTML } from "../utils/callbackHTML"; + +@Controller({ + method: "get", + path: "login/qq/callback", + auth: false, + skipAutoHandle: true, + enable: QQ.enable, +}) +export class QQWebCallback extends AbstractController { + public static readonly schema: FastifySchema = { + querystring: { + type: "object", + required: ["state", "code"], + properties: { + state: { + type: "string", + format: "uuid-v4", + }, + code: { + type: "string", + }, + platform: { + type: "string", + nullable: true, + }, + }, + }, + }; + + public async execute(): Promise { + void this.reply.headers({ + "content-type": "text/html", + }); + void this.reply.send(); + + const { state: authUUID, platform, code } = this.querystring; + + await LoginQQ.assertHasAuthUUID(authUUID, this.logger); + + const userInfo = await LoginQQ.getUserInfoAndToken(code); + + const userUUIDByDB = await ServiceUserQQ.userUUIDByUnionUUID(userInfo.unionUUID); + + const userUUID = userUUIDByDB || v4(); + + const loginQQ = new LoginQQ({ + userUUID, + }); + + if (!userUUIDByDB) { + await loginQQ.register(userInfo); + } + + const { userName, avatarURL } = !userUUIDByDB + ? userInfo + : (await loginQQ.svc.user.nameAndAvatar())!; + + await loginQQ.tempSaveUserInfo(authUUID, { + name: userName, + token: await this.reply.jwtSign({ + userUUID, + loginSource: LoginPlatform.QQ, + }), + avatar: avatarURL, + }); + + return this.reply.send(successHTML(platform !== "web")); + } + + public async errorHandler(error: Error): Promise { + await redisService.set(RedisKey.authFailed(this.querystring.state), error.message, 60 * 60); + + this.logger.error("request failed", parseError(error)); + return this.reply.send(failedHTML()); + } +} + +interface RequestType { + querystring: { + state: string; + code: string; + platform: string; + }; +} diff --git a/src/v1/controller/user/binding/List.ts b/src/v1/controller/user/binding/List.ts index f7df0518..11453e4c 100644 --- a/src/v1/controller/user/binding/List.ts +++ b/src/v1/controller/user/binding/List.ts @@ -2,6 +2,7 @@ import { Controller } from "../../../../decorator/Controller"; import { AbstractController } from "../../../../abstract/controller"; import { FastifySchema, Response, ResponseError } from "../../../../types/Server"; import { LoginPlatform, Status } from "../../../../constants/Project"; +import { ServiceUserQQ } from "../../../service/user/UserQQ"; import { ServiceUserWeChat } from "../../../service/user/UserWeChat"; import { ServiceUserPhone } from "../../../service/user/UserPhone"; import { ServiceUserAgora } from "../../../service/user/UserAgora"; @@ -18,6 +19,7 @@ export class BindingList extends AbstractController { public static readonly schema: FastifySchema = {}; private readonly svc = { + [LoginPlatform.QQ]: new ServiceUserQQ(this.userUUID), [LoginPlatform.WeChat]: new ServiceUserWeChat(this.userUUID), [LoginPlatform.Phone]: new ServiceUserPhone(this.userUUID), [LoginPlatform.Agora]: new ServiceUserAgora(this.userUUID), diff --git a/src/v1/controller/user/binding/Remove.ts b/src/v1/controller/user/binding/Remove.ts index 95f16e79..fb0b720f 100644 --- a/src/v1/controller/user/binding/Remove.ts +++ b/src/v1/controller/user/binding/Remove.ts @@ -1,6 +1,7 @@ import { Controller } from "../../../../decorator/Controller"; import { AbstractController } from "../../../../abstract/controller"; import { FastifySchema, Response, ResponseError } from "../../../../types/Server"; +import { ServiceUserQQ } from "../../../service/user/UserQQ"; import { ServiceUserWeChat } from "../../../service/user/UserWeChat"; import { ServiceUserPhone } from "../../../service/user/UserPhone"; import { ServiceUserAgora } from "../../../service/user/UserAgora"; @@ -25,6 +26,7 @@ export class RemoveBinding extends AbstractController target: { type: "string", enum: [ + LoginPlatform.QQ, LoginPlatform.WeChat, LoginPlatform.Phone, LoginPlatform.Agora, @@ -38,6 +40,7 @@ export class RemoveBinding extends AbstractController }; private readonly svc = { + [LoginPlatform.QQ]: new ServiceUserQQ(this.userUUID), [LoginPlatform.WeChat]: new ServiceUserWeChat(this.userUUID), [LoginPlatform.Phone]: new ServiceUserPhone(this.userUUID), [LoginPlatform.Agora]: new ServiceUserAgora(this.userUUID), diff --git a/src/v1/controller/user/deleteAccount/index.ts b/src/v1/controller/user/deleteAccount/index.ts index 30c6a354..f989d815 100644 --- a/src/v1/controller/user/deleteAccount/index.ts +++ b/src/v1/controller/user/deleteAccount/index.ts @@ -9,6 +9,7 @@ import { ServiceUserPhone } from "../../../service/user/UserPhone"; import { ServiceUserApple } from "../../../service/user/UserApple"; import { ServiceUserGithub } from "../../../service/user/UserGithub"; import { ServiceUserGoogle } from "../../../service/user/UserGoogle"; +import { ServiceUserQQ } from "../../../service/user/UserQQ"; import { ServiceUserWeChat } from "../../../service/user/UserWeChat"; import { ServiceUserAgora } from "../../../service/user/UserAgora"; import RedisService from "../../../../thirdPartyService/RedisService"; @@ -33,6 +34,7 @@ export class DeleteAccount extends AbstractController userGoogle: new ServiceUserGoogle(this.userUUID), userPhone: new ServiceUserPhone(this.userUUID), userWeChat: new ServiceUserWeChat(this.userUUID), + userQQ: new ServiceUserQQ(this.userUUID), }; public async execute(): Promise> { @@ -51,6 +53,7 @@ export class DeleteAccount extends AbstractController this.svc.userGoogle.physicalDeletion(t), this.svc.userPhone.physicalDeletion(t), this.svc.userWeChat.physicalDeletion(t), + this.svc.userQQ.physicalDeletion(t), ); await Promise.all(commands); diff --git a/src/v1/service/user/UserQQ.ts b/src/v1/service/user/UserQQ.ts new file mode 100644 index 00000000..e0555637 --- /dev/null +++ b/src/v1/service/user/UserQQ.ts @@ -0,0 +1,65 @@ +import { UserQQDAO } from "../../../dao"; +import { DeleteResult, EntityManager, InsertResult } from "typeorm"; +import { ControllerError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { QQ } from "../../../constants/Config"; + +export class ServiceUserQQ { + constructor(private readonly userUUID: string) {} + + public async create( + data: { + userName: string; + unionUUID: string; + openUUID: string; + }, + t?: EntityManager, + ): Promise { + const { userName, unionUUID, openUUID } = data; + + return await UserQQDAO(t).insert({ + user_uuid: this.userUUID, + user_name: userName, + union_uuid: unionUUID, + open_uuid: openUUID, + }); + } + + public async assertExist(): Promise { + const result = await this.exist(); + + if (!result) { + throw new ControllerError(ErrorCode.UserNotFound); + } + } + + public async exist(): Promise { + if (!ServiceUserQQ.enable) { + return false; + } + + const result = await UserQQDAO().findOne(["id"], { + user_uuid: this.userUUID, + }); + + return !!result; + } + + public static async userUUIDByUnionUUID(unionUUID: string): Promise { + const result = await UserQQDAO().findOne(["user_uuid"], { + union_uuid: unionUUID, + }); + + return result ? result.user_uuid : null; + } + + public async physicalDeletion(t?: EntityManager): Promise { + return await UserQQDAO(t).physicalDeletion({ + user_uuid: this.userUUID, + }); + } + + private static get enable(): boolean { + return QQ.enable; + } +}