diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 380cd8b2..908d8e7b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,12 +23,12 @@ stages: HOST_NAME: $HOST_NAME_CHINA KUBE_CONFIG: $KUBE_CONFIG_CHINA -.us: +.sg: before_script: - - cp $PROJECT_CONFIG_UNITED_STATES ./helm/files/production.yaml + - cp $PROJECT_CONFIG_SG ./helm/files/production.yaml variables: - HOST_NAME: $HOST_NAME_UNITED_STATES - KUBE_CONFIG: $KUBE_CONFIG_UNITED_STATES + HOST_NAME: $HOST_NAME_SG + KUBE_CONFIG: $KUBE_CONFIG_SG .docker_build: &DOCKER_BUILD stage: build_image @@ -118,13 +118,6 @@ deploy_dev_cn: extends: .cn <<: *DEV -deploy_dev_us: - <<: *DEPLOY - extends: .us - variables: - DOCKER_TAG: dev-$CI_COMMIT_SHA - <<: *DEV - deploy_prod_cn: <<: *DEPLOY extends: .cn @@ -132,10 +125,9 @@ deploy_prod_cn: DOCKER_TAG: $CI_COMMIT_SHA <<: *PROD -deploy_prod_us: +deploy_prod_sg: <<: *DEPLOY - extends: .us + extends: .sg variables: - DOCKER_TAG: $CI_COMMIT_SHA - <<: *PROD - + DOCKER_TAG: dev-$CI_COMMIT_SHA + <<: *DEV diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..603606bc --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18.17.0 diff --git a/config/defaults.yaml b/config/defaults.yaml index a1057b80..c0f611db 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -79,6 +79,7 @@ oauth: - jpeg login: + salt: wechat: web: enable: false @@ -125,6 +126,23 @@ login: access_secret: template_code: sign_name: + email: + enable: true + test_emails: + - email: + code: + type: smtp + smtp: + host: smtpdm.aliyun.com + port: 465 + secure: true + auth: + user: + pass: + aliCloud: + access_id: + access_secret: + account_name: test@test.test agora: app: @@ -159,6 +177,7 @@ agora: secret: whiteboard: + app_id: access_key: secret_access_key: convert_region: diff --git a/config/test.yaml b/config/test.yaml index caa75566..ec728439 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -1,6 +1,8 @@ server: port: 8761 env: dev + region: CN + region_code: 1 redis: host: 127.0.0.1 @@ -72,6 +74,7 @@ oauth: - jpeg login: + salt: test wechat: web: enable: true @@ -118,6 +121,23 @@ login: access_secret: template_code: sign_name: + email: + enable: true + test_emails: + - email: + code: + type: smtp + smtp: + host: smtpdm.aliyun.com + port: 465 + secure: true + auth: + user: + pass: + aliCloud: + access_id: + access_secret: + account_name: test@test.test agora: app: @@ -152,6 +172,7 @@ agora: secret: whiteboard: + app_id: "test/flat-server" access_key: "test" secret_access_key: "test" convert_region: "cn-hz" diff --git a/docker/docker-compose-local.yml b/docker/docker-compose-local.yml new file mode 100644 index 00000000..18e3493f --- /dev/null +++ b/docker/docker-compose-local.yml @@ -0,0 +1,29 @@ +version: '3' +name: flat-server-local +services: + redis: + image: redis + container_name: redis + ports: + - 7528:6379 + volumes: + - redis_data:/data + environment: + - REDIS_PASSWORD=flat-server-test + + mysql: + image: mysql + container_name: mysql + ports: + - 7519:3306 + environment: + - MYSQL_ROOT_PASSWORD=flat-server-test + - MYSQL_DATABASE=flat_server + command: + --default-authentication-plugin=mysql_native_password + volumes: + - mysql_data:/var/lib/mysql + +volumes: + redis_data: + mysql_data: diff --git a/package.json b/package.json index e143d730..9d6f844c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "test:sync:orm": "ts-node -r dotenv-flow/config scripts/sync-orm.ts -- --default-node-env=test --dotenv-flow-path=config", "test:local": "./scripts/prepare-test-env.sh && yarn run test:sync:orm && yarn run test", "test": "c8 --reporter=html ava", + "docker:local:up": "docker compose -f docker/docker-compose-local.yml up --detach", + "docker:local:down": "docker compose -f docker/docker-compose-local.yml down", "lint": "lint-staged" }, "devDependencies": { @@ -23,6 +25,7 @@ "@types/lodash": "^4.14.170", "@types/node": "^18.0.0", "@types/node-rsa": "^1.1.1", + "@types/nodemailer": "^6.4.9", "@types/qs": "^6.9.7", "@types/query-string": "^6.3.0", "@types/sinon": "^10.0.12", @@ -58,6 +61,7 @@ "webpack-node-externals": "^3.0.0" }, "dependencies": { + "@alicloud/dm20151123": "^1.0.6", "@alicloud/dysmsapi20170525": "^2.0.9", "@alicloud/openapi-client": "^0.4.1", "@fastify-userland/request-id": "^2.0.1", @@ -89,6 +93,7 @@ "mysql2": "^2.2.5", "nanoid": "^3.1.31", "node-rsa": "^1.1.1", + "nodemailer": "^6.9.4", "prom-client": "^14.0.0", "qs": "^6.10.3", "reflect-metadata": "^0.1.13", diff --git a/src/ErrorCode.ts b/src/ErrorCode.ts index 4ed07eed..365d4be1 100644 --- a/src/ErrorCode.ts +++ b/src/ErrorCode.ts @@ -25,6 +25,8 @@ export enum ErrorCode { UserNotFound = 400000, // user not found UserRoomListNotEmpty, // user room list is not empty. UserAlreadyBinding, // user already binding + UserPasswordIncorrect, // user password (for update) incorrect + UserOrPasswordIncorrect, // user or password (for login) incorrect RecordNotFound = 500000, // record info not found @@ -50,6 +52,12 @@ export enum ErrorCode { SMSVerificationCodeInvalid = 110000, // verification code invalid SMSAlreadyExist, // phone already exist by current user SMSAlreadyBinding, // phone are binding by other users + SMSFailedToSendCode, // failed to send verification code + + EmailVerificationCodeInvalid = 115000, // verification code invalid + EmailAlreadyExist, // email already exist by current user + EmailAlreadyBinding, // email are binding by other users + EmailFailedToSendCode, // failed to send verification code CensorshipFailed = 120000, // censorship failed diff --git a/src/abstract/login/index.ts b/src/abstract/login/index.ts index 69617b1e..075b61a6 100644 --- a/src/abstract/login/index.ts +++ b/src/abstract/login/index.ts @@ -17,6 +17,7 @@ import { v4 } from "uuid"; import { ServiceCloudStorageUserFiles } from "../../v1/service/cloudStorage/CloudStorageUserFiles"; import { ServiceUserPhone } from "../../v1/service/user/UserPhone"; import { FileConvertStep, FileResourceType } from "../../model/cloudStorage/Constants"; +import { ServiceUser } from "../../v1/service/user/User"; export abstract class AbstractLogin { protected readonly userUUID: string; @@ -49,6 +50,7 @@ export abstract class AbstractLogin { ...userInfo, userUUID: this.userUUID, hasPhone: await ServiceUserPhone.exist(this.userUUID), + hasPassword: await ServiceUser.hasPassword(this.userUUID), }), 60 * 60, ); @@ -108,7 +110,7 @@ export abstract class AbstractLogin { ]); } - private static get guidePPTX(): string { + public static get guidePPTX(): string { return "guide-pptx/guide.pptx"; } } diff --git a/src/constants/Config.ts b/src/constants/Config.ts index 7c58cd02..cc3533c6 100644 --- a/src/constants/Config.ts +++ b/src/constants/Config.ts @@ -9,6 +9,9 @@ export const Server = { name: "flat-server", version: packages.version, env: config.server.env, + region: config.server.region || "CN", + // value: 1-9 + regionCode: config.server.region_code || 1, }; export const Redis = { @@ -28,6 +31,8 @@ export const MySQL = { db: config.mysql.db, }; +export const Salt = config.login.salt; + export const Website = config.website; export const WeChat = { @@ -95,6 +100,31 @@ export const PhoneSMS = { }, }; +export const EmailSMS = { + enable: config.login.email.enable, + testEmails: config.login.email.test_emails.map(user => { + return { + email: String(user.email), + code: user.code, + }; + }), + type: config.login.email.type, + aliCloud: { + accessId: config.login.email.aliCloud.access_id, + accessSecret: config.login.email.aliCloud.access_secret, + accountName: config.login.email.aliCloud.account_name, + }, + smtp: { + host: config.login.email.smtp.host, + port: config.login.email.smtp.port, + secure: config.login.email.smtp.secure, + auth: { + user: config.login.email.smtp.auth.user, + pass: config.login.email.smtp.auth.pass, + }, + }, +}; + export const Agora = { appId: config.agora.app.id, appCertificate: config.agora.app.certificate, @@ -131,6 +161,7 @@ export const JWT = { }; export const Whiteboard = { + appId: config.whiteboard.app_id, accessKey: config.whiteboard.access_key, secretAccessKey: config.whiteboard.secret_access_key, convertRegion: config.whiteboard.convert_region, diff --git a/src/constants/Project.ts b/src/constants/Project.ts index 9435c648..76446c93 100644 --- a/src/constants/Project.ts +++ b/src/constants/Project.ts @@ -15,6 +15,7 @@ export enum LoginPlatform { Agora = "Agora", Google = "Google", Phone = "Phone", + Email = "Email", } export enum Gender { diff --git a/src/dao/index.ts b/src/dao/index.ts index 2446e798..f467f076 100644 --- a/src/dao/index.ts +++ b/src/dao/index.ts @@ -17,6 +17,7 @@ import { UserAgoraModel } from "../model/user/Agora"; import { UserGoogleModel } from "../model/user/Google"; import { UserPhoneModel } from "../model/user/Phone"; import { UserSensitiveModel } from "../model/user/Sensitive"; +import { UserEmailModel } from "../model/user/Email"; export const UserDAO = DAOImplement(UserModel) as ReturnType>; @@ -32,6 +33,8 @@ export const UserGoogleDAO = DAOImplement(UserGoogleModel) as ReturnType>; +export const UserEmailDAO = DAOImplement(UserEmailModel) as ReturnType>; + export const UserSensitiveDAO = DAOImplement(UserSensitiveModel) as ReturnType< DAO >; diff --git a/src/logger/LogConext.ts b/src/logger/LogConext.ts index 50b4eb40..0f3e369c 100644 --- a/src/logger/LogConext.ts +++ b/src/logger/LogConext.ts @@ -76,6 +76,19 @@ export type LoggerSMS = LoggerBase & { }; }; +export type LoggerEmail = LoggerBase & { + email: { + accountName: string; + email: string; + verificationCode: string; + }; + emailDetail?: { + envId: string; + requestId: string; + messageId: string; + }; +}; + export type LoggerQueue = LoggerBase & { queue: { name: string; diff --git a/src/logger/index.ts b/src/logger/index.ts index 7716ffde..e726a995 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -12,6 +12,7 @@ import { LoggerServer, LoggerService, LoggerSMS, + LoggerEmail, } from "./LogConext"; import { LoggerPluginFile } from "./plugins/LoggerPluginFile"; import { LoggerPluginTerminal } from "./plugins/LoggerPluginTerminal"; @@ -104,6 +105,19 @@ export const createLoggerSMS = ( ); }; +export const createLoggerEmail = ( + context: Partial, +): Logger => { + return new Logger( + "email", + { + ...context, + ...baseContext, + }, + loggerPlugins as LoggerAbstractPlugin[], + ); +}; + export const createLoggerRTCScreenshot = ( context: Partial, ): Logger => { diff --git a/src/model/index.ts b/src/model/index.ts index 1105db1b..51afe999 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -18,6 +18,7 @@ import { OAuthInfosModel } from "./oauth/oauth-infos"; import { OAuthSecretsModel } from "./oauth/oauth-secrets"; import { OAuthUsersModel } from "./oauth/oauth-users"; import { UserSensitiveModel } from "./user/Sensitive"; +import { UserEmailModel } from "./user/Email"; export type Model = | UserModel @@ -27,6 +28,7 @@ export type Model = | UserAgoraModel | UserGoogleModel | UserPhoneModel + | UserEmailModel | UserSensitiveModel | RoomModel | RoomUserModel diff --git a/src/model/user/Constants.ts b/src/model/user/Constants.ts index 5270aa17..cfad4979 100644 --- a/src/model/user/Constants.ts +++ b/src/model/user/Constants.ts @@ -3,4 +3,5 @@ export enum SensitiveType { Avatar = "avatar", Name = "name", WeChatName = "wechat_name", + Email = "email", } diff --git a/src/model/user/Email.ts b/src/model/user/Email.ts new file mode 100644 index 00000000..f07c0ab6 --- /dev/null +++ b/src/model/user/Email.ts @@ -0,0 +1,30 @@ +import { Column, Entity, Index } from "typeorm"; +import { Content } from "../Content"; + +@Entity({ + name: "user_email", +}) +export class UserEmailModel extends Content { + @Index("user_email_user_uuid_uindex", { + unique: true, + }) + @Column({ + length: 40, + }) + user_uuid: string; + + @Index("user_email_user_email_uindex", { + unique: true, + }) + @Column({ + length: 100, + comment: "email address", + }) + user_email: string; + + @Index("user_email_is_delete_index") + @Column({ + default: false, + }) + is_delete: boolean; +} diff --git a/src/plugins/Ajv.ts b/src/plugins/Ajv.ts index 755d4439..0f4f947c 100644 --- a/src/plugins/Ajv.ts +++ b/src/plugins/Ajv.ts @@ -101,6 +101,23 @@ const directoryPath: FormatDefinition = { }, }; +const password: FormatDefinition = { + validate: str => { + // 8..32 characters, at least one letter and one number + return 8 <= str.length && str.length <= 32 && /[a-z]/i.test(str) && /\d/.test(str); + }, +}; + +// https://github.com/ajv-validator/ajv-formats/blob/4dd65447575b35d0187c6b125383366969e6267e/src/formats.ts#L64 +const emailRegex = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; + +const email: FormatDefinition = { + validate: emailStr => { + return emailRegex.test(emailStr); + }, +}; + export const ajvSelfPlugin = (ajv: Ajv): void => { ajv.addFormat("unix-timestamp", unixTimestamp); ajv.addFormat("uuid-v4", uuidV4); @@ -113,6 +130,8 @@ export const ajvSelfPlugin = (ajv: Ajv): void => { ajv.addFormat("phone", phone); ajv.addFormat("directory-name", directoryName); ajv.addFormat("directory-path", directoryPath); + ajv.addFormat("password", password); + ajv.addFormat("email", email); }; export const validateDirectoryName = (str: string): void => { diff --git a/src/plugins/__tests__/ajv.test.ts b/src/plugins/__tests__/ajv.test.ts index 7388de59..5e82ca63 100644 --- a/src/plugins/__tests__/ajv.test.ts +++ b/src/plugins/__tests__/ajv.test.ts @@ -10,17 +10,18 @@ test(`${namespace} - inject self plugin`, ava => { const ajv = new Ajv(); ajvSelfPlugin(ajv); - ava.deepEqual(Object.keys(ajv.formats), [ - "unix-timestamp", - "uuid-v4", - "file-suffix", + ava.deepEqual(Object.keys(ajv.formats).sort(), [ "avatar-suffix", - "oauth-logo-suffix", - "url", - "https", - "phone", "directory-name", "directory-path", + "file-suffix", + "https", + "oauth-logo-suffix", + "phone", + "temp-photo-suffix", + "unix-timestamp", + "url", + "uuid-v4", ]); }); diff --git a/src/thirdPartyService/TypeORMService.ts b/src/thirdPartyService/TypeORMService.ts index 84e7b81e..fb9b3f6f 100644 --- a/src/thirdPartyService/TypeORMService.ts +++ b/src/thirdPartyService/TypeORMService.ts @@ -21,6 +21,7 @@ import { UserSensitiveModel } from "../model/user/Sensitive"; import { OAuthInfosModel } from "../model/oauth/oauth-infos"; import { OAuthSecretsModel } from "../model/oauth/oauth-secrets"; import { OAuthUsersModel } from "../model/oauth/oauth-users"; +import { UserEmailModel } from "../model/user/Email"; export const dataSource = new DataSource({ type: "mysql", @@ -37,6 +38,7 @@ export const dataSource = new DataSource({ UserAgoraModel, UserGoogleModel, UserPhoneModel, + UserEmailModel, UserSensitiveModel, RoomModel, RoomUserModel, diff --git a/src/utils/Email.ts b/src/utils/Email.ts new file mode 100644 index 00000000..f8aa966b --- /dev/null +++ b/src/utils/Email.ts @@ -0,0 +1,175 @@ +import Dm20151123, { SingleSendMailRequest, SingleSendMailResponse } from "@alicloud/dm20151123"; +import { Config } from "@alicloud/openapi-client"; +import { RuntimeOptions } from "@alicloud/tea-util"; +import { createTransport, Transporter } from "nodemailer"; +import { EmailSMS } from "../constants/Config"; +import { SMSUtils } from "./SMS"; +import { createLoggerEmail, Logger, parseError } from "../logger"; +import { LoggerEmail } from "../logger/LogConext"; + +interface EmailClient { + send( + tag: string, + to: string, + subject: string, + body: string, + logger: Logger, + ): Promise; +} + +function createAliCloudClient(): EmailClient { + const config = new Config({ + accessKeyId: EmailSMS.aliCloud.accessId, + accessKeySecret: EmailSMS.aliCloud.accessSecret, + }); + config.endpoint = "dm.aliyuncs.com"; + + const client = new Dm20151123(config); + + return { + async send(tagName, toAddress, subject, htmlBody, logger) { + const request = new SingleSendMailRequest({ + accountName: EmailSMS.aliCloud.accountName, + addressType: 1, + tagName, + replyToAddress: true, + toAddress, + subject, + htmlBody, + }); + + const runtime = new RuntimeOptions({}); + + let resp: SingleSendMailResponse; + try { + resp = await client.singleSendMailWithOptions(request, runtime); + } catch (error) { + logger.error("send message error", parseError(error)); + return false; + } + + logger.withContext({ + emailDetail: { + envId: resp.body.envId || "", + requestId: resp.body.requestId || "", + messageId: "", + }, + }); + + if (200 <= resp.statusCode && resp.statusCode < 300) { + logger.debug("send message success"); + return true; + } else { + logger.error("send message failed"); + return false; + } + }, + }; +} + +function createSMTPTransport(): EmailClient { + const transport = createTransport({ + host: EmailSMS.smtp.host, + port: EmailSMS.smtp.port, + secure: EmailSMS.smtp.secure, + auth: { + user: EmailSMS.smtp.auth.user, + pass: EmailSMS.smtp.auth.pass, + }, + }); + + return { + async send(_tag, to, subject, html, logger) { + type ExtractResponse = T extends Transporter ? K : never; + + let resp: ExtractResponse; + + try { + resp = await transport.sendMail({ + from: EmailSMS.smtp.auth.user, + to, + subject, + html, + }); + } catch (error) { + logger.error("send message error", parseError(error)); + return false; + } + + logger.withContext({ + emailDetail: { + envId: "", + requestId: "", + messageId: resp.messageId || "", + }, + }); + + logger.debug("send message success: " + resp.response); + return true; + }, + }; +} + +function createEmailClient(): EmailClient { + if (EmailSMS.type === "smtp") { + return createSMTPTransport(); + } + if (EmailSMS.type === "aliCloud") { + return createAliCloudClient(); + } + throw new Error(`unknown email type: ${EmailSMS.type}`); +} + +function getAccountName(): string { + if (EmailSMS.type === "aliCloud") { + return EmailSMS.aliCloud.accountName; + } + if (EmailSMS.type === "smtp") { + return EmailSMS.smtp.auth.user; + } + throw new Error(`unknown email type: ${EmailSMS.type}`); +} + +export class Email { + private static client: EmailClient = createEmailClient(); + + public verificationCode = SMSUtils.verificationCode(); + private logger = this.createLoggerEmail(); + + public constructor( + private email: string, + private options: { + tagName?: string; + subject?: string; + htmlBody?: (email: string, verificationCode: string) => string; + } = {}, + ) {} + + public send(): Promise { + const { + tagName = "register", + subject = "Verification Code", + htmlBody = (_email, code) => `Your verification code is ${code}`, + } = this.options; + + this.logger.debug("ready send message"); + + return Email.client.send( + tagName, + this.email, + subject, + htmlBody(this.email, this.verificationCode), + this.logger, + ); + } + + private createLoggerEmail(): Logger { + return createLoggerEmail({ + email: { + accountName: getAccountName(), + email: this.email, + verificationCode: this.verificationCode, + }, + }); + } +} diff --git a/src/utils/Hash.ts b/src/utils/Hash.ts new file mode 100644 index 00000000..f03ace07 --- /dev/null +++ b/src/utils/Hash.ts @@ -0,0 +1,6 @@ +import { createHash } from "crypto"; +import { Salt } from "../constants/Config"; + +export function hash(data: string): string { + return createHash("md5").update(data).update(Salt).digest("hex"); +} diff --git a/src/utils/ParseConfig.ts b/src/utils/ParseConfig.ts index 6ec51ef5..8bf5c8fc 100644 --- a/src/utils/ParseConfig.ts +++ b/src/utils/ParseConfig.ts @@ -1,6 +1,7 @@ import yaml from "js-yaml"; import fs from "fs"; import path from "path"; +import crypto from "crypto"; const configDirPath = process.env.IS_TEST === "yes" @@ -24,12 +25,16 @@ const configPath = (() => { const yamlContent = fs.readFileSync(configPath, "utf8"); +export const configHash = crypto.createHash("md5").update(yamlContent).digest("hex"); + export const config = yaml.load(yamlContent) as Config; type Config = { server: { port: number; env: string; + region: string | null; + region_code: number | null; }; redis: { host: string; @@ -87,6 +92,7 @@ type Config = { }; }; login: { + salt: string; wechat: { web: { enable: boolean; @@ -129,6 +135,28 @@ type Config = { hmt: SMSConfig; global: SMSConfig; }; + email: { + enable: boolean; + test_emails: Array<{ + email: string; + code: number; + }>; + type: string; + aliCloud: { + access_id: string; + access_secret: string; + account_name: string; + }; + smtp: { + host: string; + port: number; + secure: boolean; + auth: { + user: string; + pass: string; + }; + }; + }; }; agora: { app: { @@ -170,6 +198,7 @@ type Config = { }; }; whiteboard: { + app_id: string; access_key: string; secret_access_key: string; convert_region: "cn-hz" | "us-sv" | "sg" | "in-mum" | "gb-lon"; diff --git a/src/utils/Redis.ts b/src/utils/Redis.ts index 4c1e2fe9..dd9af4be 100644 --- a/src/utils/Redis.ts +++ b/src/utils/Redis.ts @@ -2,26 +2,39 @@ export const RedisKey = { authUUID: (uuid: string): string => `auth:uuid:${uuid}`, authFailed: (authUUID: string): string => `auth:failed:${authUUID}`, authUserInfo: (authUUID: string): string => `auth:userInfo:${authUUID}`, + bindingAuthUUID: (uuid: string): string => `binding:auth:uuid:${uuid}`, bindingAuthStatus: (authUUID: string): string => `binding:auth:status:${authUUID}`, + agoraRTCRoomUserToken: (roomUUID: string, uid: string | number): string => `agora:rtc:room:${roomUUID}:uid:${uid}`, agoraRTMUserToken: (userUUID: string): string => `agora:rtm:userUUID:${userUUID}`, + cloudStorageFileInfo: (userUUID: string, fileUUID: string): string => `cloudStorage:${userUUID}:${fileUUID}`, cloudStorageTempPhotoInfo: (userUUID: string, fileUUID: string): string => `cloudStorage:tempPhoto:${userUUID}:${fileUUID}`, + userAvatarFileInfo: (userUUID: string, fileUUID: string): string => `user:avatar:${userUUID}:${fileUUID}`, + roomInviteCode: (inviteCode: string): string => `room:invite:${inviteCode}`, roomInviteCodeReverse: (roomUUID: string): string => `room:inviteReverse:${roomUUID}`, + phoneLogin: (phone: string): string => `phone:login:${phone}`, phoneTryLoginCount: (phone: string): string => `phone:count:login:${phone}`, + phoneBinding: (phone: string): string => `phone:binding:${phone}`, phoneTryBindingCount: (phone: string): string => `phone:count:binding:${phone}`, + + emailBinding: (email: string): string => `email:binding:${email}`, + emailTryBindingCount: (email: string): string => `email:count:binding:${email}`, + userDelete: (userUUID: string): string => `user:delete:${userUUID}`, + videoIllegalCount: (roomUUID: string): string => `illegal:video:${roomUUID}`, voiceIllegalCount: (roomUUID: string): string => `illegal:voice:${roomUUID}`, + oauthLogoFileInfo: (oauthUUID: string, fileUUID: string): string => `oauth:logo:${oauthUUID}:${fileUUID}`, oauthAccessToken: (accessToken: string): string => `oauth:accessToken:${accessToken}`, @@ -36,4 +49,10 @@ export const RedisKey = { `oauth:authorize:refreshToken:${refreshToken}`, oauthAuthorizeTokenByUserUUID: (clientID: string, userUUID: string) => `oauth:authorize:clientID:${clientID}:user:${userUUID}`, + + phoneRegisterOrReset: (phone: string): string => `phone:register:${phone}`, + phoneTryRegisterOrResetCount: (phone: string): string => `phone:count:register:${phone}`, + + emailRegisterOrReset: (email: string): string => `email:register:${email}`, + emailTryRegisterOrResetCount: (email: string): string => `email:count:register:${email}`, }; diff --git a/src/utils/SMS.ts b/src/utils/SMS.ts index 9699453f..11125ee8 100644 --- a/src/utils/SMS.ts +++ b/src/utils/SMS.ts @@ -81,7 +81,7 @@ export class SMS { this.client = new SMSClients(this.phone).client(); } - public async send(): Promise { + public async send(): Promise { const { templateCode, signName } = SMS.info(this.phone); this.logger.debug("ready send message"); @@ -101,7 +101,7 @@ export class SMS { }); if (resp === null || resp.body.code === undefined) { - return; + return false; } this.logger.withContext({ @@ -116,8 +116,10 @@ export class SMS { if (resp.body.code !== "OK") { this.logger.error("send message failed"); + return false; } else { this.logger.debug("send message success"); + return true; } } diff --git a/src/v1/controller/login/Login.ts b/src/v1/controller/login/Login.ts index 08c615ce..551a488e 100644 --- a/src/v1/controller/login/Login.ts +++ b/src/v1/controller/login/Login.ts @@ -12,6 +12,7 @@ import { AgoraLogin, Github, PhoneSMS, WeChat } from "../../../constants/Config" import { ControllerError } from "../../../error/ControllerError"; import { ErrorCode } from "../../../ErrorCode"; import { ServiceUserPhone } from "../../service/user/UserPhone"; +import { ServiceUserEmail } from "../../service/user/UserEmail"; @Controller({ method: "post", @@ -29,6 +30,7 @@ export class Login extends AbstractController { userAgora: ServiceUserAgora; userGoogle: ServiceUserGoogle; userPhone: ServiceUserPhone; + userEmail: ServiceUserEmail; }; public constructor(params: ControllerClassParams) { @@ -42,6 +44,7 @@ export class Login extends AbstractController { userAgora: new ServiceUserAgora(this.userUUID), userGoogle: new ServiceUserGoogle(this.userUUID), userPhone: new ServiceUserPhone(this.userUUID), + userEmail: new ServiceUserEmail(this.userUUID), }; } @@ -87,6 +90,7 @@ export class Login extends AbstractController { }), userUUID: this.userUUID, hasPhone: await this.svc.userPhone.exist(), + hasPassword: await this.svc.user.hasPassword(), }, }; } @@ -135,4 +139,5 @@ interface ResponseType { token: string; userUUID: string; hasPhone: boolean; + hasPassword: boolean; } diff --git a/src/v1/controller/login/Process.ts b/src/v1/controller/login/Process.ts index 381b3812..101ec07e 100644 --- a/src/v1/controller/login/Process.ts +++ b/src/v1/controller/login/Process.ts @@ -60,6 +60,7 @@ export class LoginProcess extends AbstractController userUUID: "", token: "", hasPhone: false, + hasPassword: false, }, }; } @@ -89,4 +90,5 @@ type ResponseType = { userUUID: string; token: string; hasPhone: boolean; + hasPassword: boolean; }; diff --git a/src/v1/controller/login/apple/jwt.ts b/src/v1/controller/login/apple/jwt.ts index 09b30242..649459ec 100644 --- a/src/v1/controller/login/apple/jwt.ts +++ b/src/v1/controller/login/apple/jwt.ts @@ -8,6 +8,7 @@ import { v4 } from "uuid"; import { LoginPlatform, Status } from "../../../../constants/Project"; import { Apple } from "../../../../constants/Config"; import { ServiceUserPhone } from "../../../service/user/UserPhone"; +import { ServiceUser } from "../../../service/user/User"; @Controller({ method: "post", @@ -78,6 +79,7 @@ export class AppleJWT extends AbstractController { loginSource: LoginPlatform.Apple, }), hasPhone: await ServiceUserPhone.exist(this.userUUID), + hasPassword: await ServiceUser.hasPassword(this.userUUID), }, }; } @@ -100,4 +102,5 @@ interface ResponseType { userUUID: string; token: string; hasPhone: boolean; + hasPassword: boolean; } diff --git a/src/v1/controller/login/phone/Phone.ts b/src/v1/controller/login/phone/Phone.ts index ac51e762..b345fb86 100644 --- a/src/v1/controller/login/phone/Phone.ts +++ b/src/v1/controller/login/phone/Phone.ts @@ -79,6 +79,7 @@ export class PhoneLogin extends AbstractController { loginSource: LoginPlatform.Phone, }), hasPhone: true, + hasPassword: await loginPhone.svc.user.hasPassword(), }, } as const; @@ -145,4 +146,5 @@ interface ResponseType { userUUID: string; token: string; hasPhone: true; + hasPassword: boolean; } diff --git a/src/v1/controller/login/phone/SendMessage.ts b/src/v1/controller/login/phone/SendMessage.ts index 4b00bb91..d02c30b3 100644 --- a/src/v1/controller/login/phone/SendMessage.ts +++ b/src/v1/controller/login/phone/SendMessage.ts @@ -7,6 +7,8 @@ import { RedisKey } from "../../../../utils/Redis"; import { SMS, SMSUtils } from "../../../../utils/SMS"; import { Status } from "../../../../constants/Project"; import { MessageExpirationSecond, MessageIntervalSecond } from "./Constants"; +import { ControllerError } from "../../../../error/ControllerError"; +import { ErrorCode } from "../../../../ErrorCode"; @Controller({ method: "post", @@ -35,7 +37,10 @@ export class SendMessage extends AbstractController { const safePhone = SMSUtils.safePhone(phone); if (await SendMessage.canSend(safePhone)) { - await sms.send(); + const success = await sms.send(); + if (!success) { + throw new ControllerError(ErrorCode.SMSFailedToSendCode); + } await RedisService.set( RedisKey.phoneLogin(safePhone), sms.verificationCode, @@ -43,6 +48,7 @@ export class SendMessage extends AbstractController { ); } else { this.logger.warn("count over limit"); + throw new ControllerError(ErrorCode.ExhaustiveAttack); } return { diff --git a/src/v1/controller/login/weChat/mobile/Callback.ts b/src/v1/controller/login/weChat/mobile/Callback.ts index 508353bf..4ed28edd 100644 --- a/src/v1/controller/login/weChat/mobile/Callback.ts +++ b/src/v1/controller/login/weChat/mobile/Callback.ts @@ -8,6 +8,7 @@ import { Status } from "../../../../../constants/Project"; import { parseError } from "../../../../../logger"; import { WeChat } from "../../../../../constants/Config"; import { ServiceUserPhone } from "../../../../service/user/UserPhone"; +import { ServiceUser } from "../../../../service/user/User"; @Controller({ method: "get", @@ -42,6 +43,7 @@ export class WechatMobileCallback extends AbstractController properties: { roomUUID: { type: "string", - format: "uuid-v4", }, }, }, diff --git a/src/v1/controller/room/cancel/Ordinary.ts b/src/v1/controller/room/cancel/Ordinary.ts index fe5be738..8831de17 100644 --- a/src/v1/controller/room/cancel/Ordinary.ts +++ b/src/v1/controller/room/cancel/Ordinary.ts @@ -25,7 +25,6 @@ export class CancelOrdinary extends AbstractController { + return `${Server.region}-` + v4(); +}; diff --git a/src/v1/controller/room/info/Ordinary.ts b/src/v1/controller/room/info/Ordinary.ts index 4208620c..4c8dfecf 100644 --- a/src/v1/controller/room/info/Ordinary.ts +++ b/src/v1/controller/room/info/Ordinary.ts @@ -20,7 +20,6 @@ export class OrdinaryInfo extends AbstractController properties: { roomUUID: { type: "string", - format: "uuid-v4", }, }, }, diff --git a/src/v1/controller/room/info/PeriodicSubRoom.ts b/src/v1/controller/room/info/PeriodicSubRoom.ts index b3d31b18..bf53f3cc 100644 --- a/src/v1/controller/room/info/PeriodicSubRoom.ts +++ b/src/v1/controller/room/info/PeriodicSubRoom.ts @@ -28,7 +28,6 @@ export class PeriodicSubRoomInfo extends AbstractController { properties: { roomUUID: { type: "string", - format: "uuid-v4", }, usersUUID: { type: "array", diff --git a/src/v1/controller/room/info/__tests__/helpers/createUsersRequest.ts b/src/v1/controller/room/info/__tests__/helpers/createUsersRequest.ts index 0cfdee35..6c6a3c93 100644 --- a/src/v1/controller/room/info/__tests__/helpers/createUsersRequest.ts +++ b/src/v1/controller/room/info/__tests__/helpers/createUsersRequest.ts @@ -28,11 +28,15 @@ export const createUsersRequest = ( } as ControllerClassParams); }; -export const createRoom = async (ownerUUID: string, roomUUID: string): Promise => { +export const createRoom = async ( + ownerUUID: string, + roomUUID: string, + roomStatus: RoomStatus = RoomStatus.Stopped, +): Promise => { await RoomDAO().insert({ room_uuid: roomUUID, periodic_uuid: "", - room_status: RoomStatus.Stopped, + room_status: roomStatus, begin_time: new Date(), end_time: new Date(), title: "test", diff --git a/src/v1/controller/room/join/Ordinary.ts b/src/v1/controller/room/join/Ordinary.ts index baf85394..8b86f18b 100644 --- a/src/v1/controller/room/join/Ordinary.ts +++ b/src/v1/controller/room/join/Ordinary.ts @@ -43,30 +43,34 @@ export const joinOrdinary = async ( } const { whiteboard_room_uuid: whiteboardRoomUUID } = roomInfo; - let rtcUID: string; + + // Either user is joinning a new room or rejoinning a (maybe deleted) room. + await RoomUserDAO().insert( + { + room_uuid: roomUUID, + user_uuid: userUUID, + rtc_uid: cryptoRandomString({ length: 6, type: "numeric" }), + }, + { + orUpdate: { + is_delete: false, + }, + }, + ); const roomUserInfo = await RoomUserDAO().findOne(["rtc_uid"], { room_uuid: roomUUID, user_uuid: userUUID, }); + let rtcUID: string; if (roomUserInfo !== undefined) { rtcUID = roomUserInfo.rtc_uid; } else { - rtcUID = cryptoRandomString({ length: 6, type: "numeric" }); - - await RoomUserDAO().insert( - { - room_uuid: roomUUID, - user_uuid: userUUID, - rtc_uid: rtcUID, - }, - { - orUpdate: { - is_delete: false, - }, - }, - ); + return { + status: Status.Failed, + code: ErrorCode.CurrentProcessFailed, + }; } return { diff --git a/src/v1/controller/room/join/Periodic.ts b/src/v1/controller/room/join/Periodic.ts index 914439d5..1d83150b 100644 --- a/src/v1/controller/room/join/Periodic.ts +++ b/src/v1/controller/room/join/Periodic.ts @@ -54,52 +54,55 @@ export const joinPeriodic = async ( } const { room_uuid: roomUUID, whiteboard_room_uuid: whiteboardRoomUUID } = roomInfo; - let rtcUID: string; + + await dataSource.transaction(async t => { + const commands: Promise[] = []; + + commands.push( + RoomUserDAO(t).insert( + { + room_uuid: roomUUID, + user_uuid: userUUID, + rtc_uid: cryptoRandomString({ length: 6, type: "numeric" }), + }, + { + orUpdate: { + is_delete: false, + }, + }, + ), + ); + + commands.push( + RoomPeriodicUserDAO(t).insert( + { + periodic_uuid: periodicUUID, + user_uuid: userUUID, + }, + { + orUpdate: { + is_delete: false, + }, + }, + ), + ); + + return await Promise.all(commands); + }); const roomUserInfo = await RoomUserDAO().findOne(["rtc_uid"], { room_uuid: roomUUID, user_uuid: userUUID, }); + let rtcUID: string; if (roomUserInfo !== undefined) { rtcUID = roomUserInfo.rtc_uid; } else { - rtcUID = cryptoRandomString({ length: 6, type: "numeric" }); - - await dataSource.transaction(async t => { - const commands: Promise[] = []; - - commands.push( - RoomUserDAO(t).insert( - { - room_uuid: roomUUID, - user_uuid: userUUID, - rtc_uid: rtcUID, - }, - { - orUpdate: { - is_delete: false, - }, - }, - ), - ); - - commands.push( - RoomPeriodicUserDAO(t).insert( - { - periodic_uuid: periodicUUID, - user_uuid: userUUID, - }, - { - orUpdate: { - is_delete: false, - }, - }, - ), - ); - - return await Promise.all(commands); - }); + return { + status: Status.Failed, + code: ErrorCode.CurrentProcessFailed, + }; } return { diff --git a/src/v1/controller/room/join/__tests__/helpers/createCancelOrdinary.ts b/src/v1/controller/room/join/__tests__/helpers/createCancelOrdinary.ts new file mode 100644 index 00000000..766d2c9d --- /dev/null +++ b/src/v1/controller/room/join/__tests__/helpers/createCancelOrdinary.ts @@ -0,0 +1,18 @@ +import { Logger } from "../../../../../../logger"; +import { CancelOrdinary } from "../../../cancel/Ordinary"; + +export const createCancel = (roomUUID: string, userUUID: string): CancelOrdinary => { + const logger = new Logger("test", {}, []); + return new CancelOrdinary({ + logger, + req: { + body: { + roomUUID, + }, + user: { + userUUID, + }, + }, + reply: {}, + } as any); +}; diff --git a/src/v1/controller/room/join/__tests__/helpers/createJoinRoom.ts b/src/v1/controller/room/join/__tests__/helpers/createJoinRoom.ts new file mode 100644 index 00000000..d1c10c66 --- /dev/null +++ b/src/v1/controller/room/join/__tests__/helpers/createJoinRoom.ts @@ -0,0 +1,18 @@ +import { JoinRoom } from "../.."; +import { Logger } from "../../../../../../logger"; + +export const createJoinRoom = (roomUUID: string, userUUID: string): JoinRoom => { + const logger = new Logger("test", {}, []); + return new JoinRoom({ + logger, + req: { + body: { + uuid: roomUUID, + }, + user: { + userUUID, + }, + }, + reply: {}, + } as any); +}; diff --git a/src/v1/controller/room/join/__tests__/join.test.ts b/src/v1/controller/room/join/__tests__/join.test.ts new file mode 100644 index 00000000..a9e72a3c --- /dev/null +++ b/src/v1/controller/room/join/__tests__/join.test.ts @@ -0,0 +1,36 @@ +import test from "ava"; +import { dataSource } from "../../../../../thirdPartyService/TypeORMService"; +import { v4 } from "uuid"; +import { createRoom, createRoomUser } from "../../info/__tests__/helpers/createUsersRequest"; +import { createCancel } from "./helpers/createCancelOrdinary"; +import { createJoinRoom } from "./helpers/createJoinRoom"; +import { RoomStatus } from "../../../../../model/room/Constants"; + +const namespace = "[api][api-v1][api-v1-room][api-v1-room-join]"; + +test.before(`${namespace} - initialize dataSource`, async () => { + await dataSource.initialize(); +}); + +test.after(`${namespace} - destroy dataSource`, async () => { + await dataSource.destroy(); +}); + +test(`${namespace} - join after user cancel`, async ava => { + const [roomUUID] = [v4()]; + const [ownerUUID, anotherUserUUID] = await createRoomUser(roomUUID, 2); + await createRoom(ownerUUID, roomUUID, RoomStatus.Started); + + const joinRoom = createJoinRoom(roomUUID, anotherUserUUID); + const result = await joinRoom.execute(); + const f: number = (result as any).data.rtcUID; + + await createCancel(roomUUID, anotherUserUUID).execute(); + + const joinRoom1 = createJoinRoom(roomUUID, anotherUserUUID); + const join1Result = await joinRoom1.execute(); + const f1: number = (join1Result as any).data.rtcUID; + + ava.is(f > 0 && f1 > 0, true); + ava.is(f == f1, true); +}); diff --git a/src/v1/controller/room/join/index.ts b/src/v1/controller/room/join/index.ts index 75ff1d13..56fa4ef2 100644 --- a/src/v1/controller/room/join/index.ts +++ b/src/v1/controller/room/join/index.ts @@ -78,7 +78,7 @@ export class JoinRoom extends AbstractController { } private isInviteCode(): boolean { - return /^\d{10}$/.test(this.body.uuid); + return /^\d{10,11}$/.test(this.body.uuid); } } diff --git a/src/v1/controller/room/record/Info.ts b/src/v1/controller/room/record/Info.ts index b667474a..a06f1f7e 100644 --- a/src/v1/controller/room/record/Info.ts +++ b/src/v1/controller/room/record/Info.ts @@ -22,7 +22,6 @@ export class RecordInfo extends AbstractController { properties: { roomUUID: { type: "string", - format: "uuid-v4", }, }, }, diff --git a/src/v1/controller/room/record/Started.ts b/src/v1/controller/room/record/Started.ts index 50b76dce..df126954 100644 --- a/src/v1/controller/room/record/Started.ts +++ b/src/v1/controller/room/record/Started.ts @@ -19,7 +19,6 @@ export class RecordStarted extends AbstractController properties: { roomUUID: { type: "string", - format: "uuid-v4", }, }, }, diff --git a/src/v1/controller/room/record/Stopped.ts b/src/v1/controller/room/record/Stopped.ts index 5de9bec1..e7a434eb 100644 --- a/src/v1/controller/room/record/Stopped.ts +++ b/src/v1/controller/room/record/Stopped.ts @@ -20,7 +20,6 @@ export class RecordStopped extends AbstractController properties: { roomUUID: { type: "string", - format: "uuid-v4", }, }, }, diff --git a/src/v1/controller/room/record/agora/Acquire.ts b/src/v1/controller/room/record/agora/Acquire.ts index c787b368..222c56a6 100644 --- a/src/v1/controller/room/record/agora/Acquire.ts +++ b/src/v1/controller/room/record/agora/Acquire.ts @@ -25,7 +25,6 @@ export class RecordAgoraAcquire extends AbstractController 1920 || height > 1080 || width < minLength || height < minLength) { + if (width > 1920) { + height = Math.floor((height * 1920) / width); + width = 1920; + } + if (height > 1080) { + width = Math.floor((width * 1080) / height); + height = 1080; + } + if (width < minLength) { + height = Math.floor((height * minLength) / width); + width = minLength; + } + if (height < minLength) { + width = Math.floor((width * minLength) / height); + height = minLength; + } + if (width > 1920) { + width = 1920; + } + if (height > 1080) { + height = 1080; + } + shouldUpdate = true; + } + if (shouldUpdate && agoraData.clientRequest.recordingConfig) { + agoraData.clientRequest.recordingConfig.transcodingConfig = { + ...transcodingConfig, + width, + height, + }; + } + } + const roomInfo = await RoomDAO().findOne(["room_status"], { room_uuid: roomUUID, owner_uuid: userUUID, diff --git a/src/v1/controller/room/record/agora/Stopped.ts b/src/v1/controller/room/record/agora/Stopped.ts index 7c6fdd09..98511fcf 100644 --- a/src/v1/controller/room/record/agora/Stopped.ts +++ b/src/v1/controller/room/record/agora/Stopped.ts @@ -27,7 +27,6 @@ export class RecordAgoraStopped extends AbstractController => { inviteCodeList.push(inviteCodeFn()); } + // insert region code at front return await RedisService.vacantKey(inviteCodeList); }; diff --git a/src/v1/controller/user/binding/List.ts b/src/v1/controller/user/binding/List.ts index f7df0518..6c3e03c6 100644 --- a/src/v1/controller/user/binding/List.ts +++ b/src/v1/controller/user/binding/List.ts @@ -8,6 +8,7 @@ import { ServiceUserAgora } from "../../../service/user/UserAgora"; import { ServiceUserApple } from "../../../service/user/UserApple"; import { ServiceUserGithub } from "../../../service/user/UserGithub"; import { ServiceUserGoogle } from "../../../service/user/UserGoogle"; +import { ServiceUserEmail } from "../../../service/user/UserEmail"; @Controller({ method: "post", @@ -24,6 +25,7 @@ export class BindingList extends AbstractController { [LoginPlatform.Apple]: new ServiceUserApple(this.userUUID), [LoginPlatform.Github]: new ServiceUserGithub(this.userUUID), [LoginPlatform.Google]: new ServiceUserGoogle(this.userUUID), + [LoginPlatform.Email]: new ServiceUserEmail(this.userUUID), }; public async execute(): Promise> { diff --git a/src/v1/controller/user/binding/Remove.ts b/src/v1/controller/user/binding/Remove.ts index 95f16e79..bda05c7c 100644 --- a/src/v1/controller/user/binding/Remove.ts +++ b/src/v1/controller/user/binding/Remove.ts @@ -7,6 +7,7 @@ import { ServiceUserAgora } from "../../../service/user/UserAgora"; import { ServiceUserApple } from "../../../service/user/UserApple"; import { ServiceUserGithub } from "../../../service/user/UserGithub"; import { ServiceUserGoogle } from "../../../service/user/UserGoogle"; +import { ServiceUserEmail } from "../../../service/user/UserEmail"; import { LoginPlatform, Status } from "../../../../constants/Project"; import { ControllerError } from "../../../../error/ControllerError"; import { ErrorCode } from "../../../../ErrorCode"; @@ -31,6 +32,7 @@ export class RemoveBinding extends AbstractController LoginPlatform.Apple, LoginPlatform.Github, LoginPlatform.Google, + LoginPlatform.Email, ], }, }, @@ -44,6 +46,7 @@ export class RemoveBinding extends AbstractController [LoginPlatform.Apple]: new ServiceUserApple(this.userUUID), [LoginPlatform.Github]: new ServiceUserGithub(this.userUUID), [LoginPlatform.Google]: new ServiceUserGoogle(this.userUUID), + [LoginPlatform.Email]: new ServiceUserEmail(this.userUUID), }; public async execute(): Promise> { diff --git a/src/v1/controller/user/binding/platform/email/Binding.ts b/src/v1/controller/user/binding/platform/email/Binding.ts new file mode 100644 index 00000000..2c929075 --- /dev/null +++ b/src/v1/controller/user/binding/platform/email/Binding.ts @@ -0,0 +1,132 @@ +import { Controller } from "../../../../../../decorator/Controller"; +import { EmailSMS } from "../../../../../../constants/Config"; +import { AbstractController } from "../../../../../../abstract/controller"; +import { FastifySchema, Response, ResponseError } from "../../../../../../types/Server"; +import RedisService from "../../../../../../thirdPartyService/RedisService"; +import { RedisKey } from "../../../../../../utils/Redis"; +import { ControllerError } from "../../../../../../error/ControllerError"; +import { ErrorCode } from "../../../../../../ErrorCode"; +import { Status } from "../../../../../../constants/Project"; +import { ServiceUserEmail } from "../../../../../service/user/UserEmail"; +import { UserDAO } from "../../../../../../dao"; +import { ServiceUserSensitive } from "../../../../../service/user/UserSensitive"; + +@Controller({ + method: "post", + path: ["user/bindingEmail", "user/binding/platform/email"], + auth: true, + enable: EmailSMS.enable, +}) +export class BindingEmail extends AbstractController { + public static readonly schema: FastifySchema = { + body: { + type: "object", + required: ["email", "code"], + properties: { + email: { + type: "string", + format: "email", + }, + code: { + type: "integer", + minimum: 100000, + maximum: 999999, + }, + }, + }, + }; + + private svc = { + userEmail: new ServiceUserEmail(this.userUUID), + userSensitive: new ServiceUserSensitive(this.userUUID), + }; + + private static ExhaustiveAttackCount = 10; + + public async execute(): Promise> { + const { email, code } = this.body; + + await BindingEmail.notExhaustiveAttack(email); + + if (await this.svc.userEmail.exist()) { + throw new ControllerError(ErrorCode.SMSAlreadyExist); + } + + if (await this.svc.userEmail.existEmail(email)) { + throw new ControllerError(ErrorCode.SMSAlreadyBinding); + } + + await BindingEmail.assertCodeCorrect(email, code); + await BindingEmail.clearTryBindingCount(email); + + const userInfo = await UserDAO().findOne(["user_name"], { + user_uuid: this.userUUID, + }); + + if (userInfo === undefined) { + return { + status: Status.Failed, + code: ErrorCode.UserNotFound, + }; + } + + await this.svc.userEmail.create({ + email, + }); + await this.svc.userSensitive.email({ + email, + }); + + await BindingEmail.clearVerificationCode(email); + + return { + status: Status.Success, + data: {}, + }; + } + + private static async assertCodeCorrect(email: string, code: number): Promise { + const value = await RedisService.get(RedisKey.emailBinding(email)); + + if (String(code) !== value) { + throw new ControllerError(ErrorCode.SMSVerificationCodeInvalid); + } + } + + private static async notExhaustiveAttack(email: string): Promise { + const key = RedisKey.emailTryBindingCount(email); + const value = Number(await RedisService.get(key)) || 0; + + if (value > BindingEmail.ExhaustiveAttackCount) { + throw new ControllerError(ErrorCode.ExhaustiveAttack); + } + + const incrValue = await RedisService.incr(key); + + if (incrValue === 1) { + // must re-wait 10 minute + await RedisService.expire(key, 60 * 10); + } + } + + private static async clearTryBindingCount(email: string): Promise { + await RedisService.del(RedisKey.emailTryBindingCount(email)); + } + + private static async clearVerificationCode(email: string): Promise { + await RedisService.del(RedisKey.emailBinding(email)); + } + + public errorHandler(error: Error): ResponseError { + return this.autoHandlerError(error); + } +} + +interface RequestType { + body: { + email: string; + code: number; + }; +} + +interface ResponseType {} diff --git a/src/v1/controller/user/binding/platform/email/Constants.ts b/src/v1/controller/user/binding/platform/email/Constants.ts new file mode 100644 index 00000000..86eabc9b --- /dev/null +++ b/src/v1/controller/user/binding/platform/email/Constants.ts @@ -0,0 +1,2 @@ +export const MessageIntervalSecond = 60; +export const MessageExpirationSecond = 60 * 10; diff --git a/src/v1/controller/user/binding/platform/email/SendMessage.ts b/src/v1/controller/user/binding/platform/email/SendMessage.ts new file mode 100644 index 00000000..a674a66b --- /dev/null +++ b/src/v1/controller/user/binding/platform/email/SendMessage.ts @@ -0,0 +1,99 @@ +import { Controller } from "../../../../../../decorator/Controller"; +import { EmailSMS } from "../../../../../../constants/Config"; +import { AbstractController } from "../../../../../../abstract/controller"; +import { FastifySchema, Response, ResponseError } from "../../../../../../types/Server"; +import RedisService from "../../../../../../thirdPartyService/RedisService"; +import { RedisKey } from "../../../../../../utils/Redis"; +import { Email } from "../../../../../../utils/Email"; +import { Status } from "../../../../../../constants/Project"; +import { MessageExpirationSecond, MessageIntervalSecond } from "./Constants"; +import { ServiceUserEmail } from "../../../../../service/user/UserEmail"; +import { ControllerError } from "../../../../../../error/ControllerError"; +import { ErrorCode } from "../../../../../../ErrorCode"; + +@Controller({ + method: "post", + path: ["user/bindingEmail/sendMessage", "user/binding/platform/email/sendMessage"], + auth: true, + enable: EmailSMS.enable, +}) +export class SendMessage extends AbstractController { + public static readonly schema: FastifySchema = { + body: { + type: "object", + required: ["email"], + properties: { + email: { + type: "string", + format: "email", + }, + }, + }, + }; + + private svc = { + userEmail: new ServiceUserEmail(this.userUUID), + }; + + public async execute(): Promise> { + const { email } = this.body; + const sms = new Email(email, { + tagName: "bind", + subject: "Flat Verification Code", + htmlBody: (email: string, code: string) => + `Hello, ${email}! Please enter the verification code within 10 minutes:

${code}




Thanks and Regards,
Leo Yang
Flat PM
yangliu02@agora.io`, + }); + + if (await SendMessage.canSend(email)) { + if (await this.svc.userEmail.exist()) { + throw new ControllerError(ErrorCode.EmailAlreadyExist); + } + + if (await this.svc.userEmail.existEmail(email)) { + throw new ControllerError(ErrorCode.EmailAlreadyBinding); + } + + const success = await sms.send(); + if (!success) { + throw new ControllerError(ErrorCode.EmailFailedToSendCode); + } + + await RedisService.set( + RedisKey.emailBinding(email), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + this.logger.warn("count over limit"); + } + + return { + status: Status.Success, + data: {}, + }; + } + + private static async canSend(email: string): Promise { + const ttl = await RedisService.ttl(RedisKey.emailBinding(email)); + + if (ttl < 0) { + return true; + } + + const elapsedTime = MessageExpirationSecond - ttl; + + return elapsedTime > MessageIntervalSecond; + } + + public errorHandler(error: Error): ResponseError { + return this.autoHandlerError(error); + } +} + +interface RequestType { + body: { + email: string; + }; +} + +interface ResponseType {} diff --git a/src/v1/controller/user/binding/platform/phone/SendMessage.ts b/src/v1/controller/user/binding/platform/phone/SendMessage.ts index 7f60e544..68e5ba32 100644 --- a/src/v1/controller/user/binding/platform/phone/SendMessage.ts +++ b/src/v1/controller/user/binding/platform/phone/SendMessage.ts @@ -50,7 +50,11 @@ export class SendMessage extends AbstractController { throw new ControllerError(ErrorCode.SMSAlreadyBinding); } - await sms.send(); + const success = await sms.send(); + if (!success) { + throw new ControllerError(ErrorCode.SMSFailedToSendCode); + } + await RedisService.set( RedisKey.phoneBinding(safePhone), sms.verificationCode, diff --git a/src/v1/controller/user/deleteAccount/index.ts b/src/v1/controller/user/deleteAccount/index.ts index 30c6a354..3fdd6395 100644 --- a/src/v1/controller/user/deleteAccount/index.ts +++ b/src/v1/controller/user/deleteAccount/index.ts @@ -11,6 +11,7 @@ import { ServiceUserGithub } from "../../../service/user/UserGithub"; import { ServiceUserGoogle } from "../../../service/user/UserGoogle"; import { ServiceUserWeChat } from "../../../service/user/UserWeChat"; import { ServiceUserAgora } from "../../../service/user/UserAgora"; +import { ServiceUserEmail } from "../../../service/user/UserEmail"; import RedisService from "../../../../thirdPartyService/RedisService"; import { RedisKey } from "../../../../utils/Redis"; import { alreadyJoinedRoomCount } from "./utils/AlreadyJoinedRoomCount"; @@ -33,6 +34,7 @@ export class DeleteAccount extends AbstractController userGoogle: new ServiceUserGoogle(this.userUUID), userPhone: new ServiceUserPhone(this.userUUID), userWeChat: new ServiceUserWeChat(this.userUUID), + userEmail: new ServiceUserEmail(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.userEmail.physicalDeletion(t), ); await Promise.all(commands); diff --git a/src/v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount.ts b/src/v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount.ts index ca4a9a34..7f02a6a0 100644 --- a/src/v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount.ts +++ b/src/v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount.ts @@ -1,10 +1,14 @@ +import { EntityManager } from "typeorm"; import { RoomUserModel } from "../../../../../model/room/RoomUser"; import { RoomModel } from "../../../../../model/room/Room"; import { RoomStatus } from "../../../../../model/room/Constants"; import { dataSource } from "../../../../../thirdPartyService/TypeORMService"; -export const alreadyJoinedRoomCount = async (userUUID: string): Promise => { - return await dataSource +export const alreadyJoinedRoomCount = async ( + userUUID: string, + t?: EntityManager, +): Promise => { + return await (t || dataSource) .createQueryBuilder(RoomUserModel, "ru") .innerJoin(RoomModel, "r", "ru.room_uuid = r.room_uuid") .andWhere("ru.user_uuid = :userUUID", { diff --git a/src/v1/service/user/User.ts b/src/v1/service/user/User.ts index a6a4a142..64638822 100644 --- a/src/v1/service/user/User.ts +++ b/src/v1/service/user/User.ts @@ -8,6 +8,18 @@ import { UpdateResult } from "typeorm/query-builder/result/UpdateResult"; export class ServiceUser { constructor(private readonly userUUID: string) {} + public async hasPassword(): Promise { + return await ServiceUser.hasPassword(this.userUUID); + } + + public static async hasPassword(userUUID: string): Promise { + const result = await UserDAO().findOne(["user_password"], { + user_uuid: userUUID, + }); + + return Boolean(result && result.user_password); + } + public async create( data: { userName: string; diff --git a/src/v1/service/user/UserEmail.ts b/src/v1/service/user/UserEmail.ts new file mode 100644 index 00000000..290966ef --- /dev/null +++ b/src/v1/service/user/UserEmail.ts @@ -0,0 +1,81 @@ +import { DeleteResult, EntityManager, InsertResult } from "typeorm"; +import { UserEmailDAO } from "../../../dao"; +import { ControllerError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { EmailSMS } from "../../../constants/Config"; + +export class ServiceUserEmail { + constructor(private readonly userUUID: string) {} + + public async create( + data: { + email: string; + }, + t?: EntityManager, + ): Promise { + const { email } = data; + + return await UserEmailDAO(t).insert({ + user_uuid: this.userUUID, + user_email: email, + }); + } + + public async exist(): Promise { + return await ServiceUserEmail.exist(this.userUUID); + } + + public static async exist(userUUID: string): Promise { + if (!ServiceUserEmail.enable) { + return false; + } + + const result = await UserEmailDAO().findOne(["id"], { + user_uuid: userUUID, + }); + + return !!result; + } + + public async existEmail(email: string): Promise { + return await ServiceUserEmail.existEmail(email); + } + + public static async existEmail(email: string): Promise { + if (!ServiceUserEmail.enable) { + return false; + } + + const result = await UserEmailDAO().findOne(["id"], { + user_email: email, + }); + + return !!result; + } + + public async assertExist(): Promise { + const result = await this.exist(); + + if (!result) { + throw new ControllerError(ErrorCode.UserNotFound); + } + } + + public static async userUUIDByEmail(email: string): Promise { + const result = await UserEmailDAO().findOne(["user_uuid"], { + user_email: email, + }); + + return result ? result.user_uuid : null; + } + + public async physicalDeletion(t?: EntityManager): Promise { + return await UserEmailDAO(t).physicalDeletion({ + user_uuid: this.userUUID, + }); + } + + private static get enable(): boolean { + return EmailSMS.enable; + } +} diff --git a/src/v1/service/user/UserSensitive.ts b/src/v1/service/user/UserSensitive.ts index 57260de8..37babaa8 100644 --- a/src/v1/service/user/UserSensitive.ts +++ b/src/v1/service/user/UserSensitive.ts @@ -61,4 +61,21 @@ export class ServiceUserSensitive { }); } + public async email( + data: { + email: string; + }, + t?: EntityManager, + ): Promise { + return await UserSensitiveDAO(t).insert({ + user_uuid: this.userUUID, + type: SensitiveType.Email, + content: this.desensitiveEmail(data.email), + }); + } + + private desensitiveEmail(email: string): string { + if (email.length <= 1) return email; + return email[0] + email.slice(1).replace(/[^@.]/g, "*"); + } } diff --git a/src/v2/__tests__/helpers/db/index.ts b/src/v2/__tests__/helpers/db/index.ts index 9fa79bf7..78a03025 100644 --- a/src/v2/__tests__/helpers/db/index.ts +++ b/src/v2/__tests__/helpers/db/index.ts @@ -10,6 +10,8 @@ import { CreateSecretsInfos } from "./oauth-secret"; import { CreateRoomJoin } from "./room-join"; import { CreateRoom } from "./room"; import { CreateUserPhone } from "./user-phone"; +import { CreateUserWeChat } from "./user-wechat"; +import { CreateUserEmail } from "./user-email"; export const testService = (t: EntityManager) => { return { @@ -24,5 +26,7 @@ export const testService = (t: EntityManager) => { createSecretsInfos: new CreateSecretsInfos(t), createUser: new CreateUser(t), createUserPhone: new CreateUserPhone(t), + createUserWeChat: new CreateUserWeChat(t), + createUserEmail: new CreateUserEmail(t), }; }; diff --git a/src/v2/__tests__/helpers/db/user-email.ts b/src/v2/__tests__/helpers/db/user-email.ts new file mode 100644 index 00000000..726a5796 --- /dev/null +++ b/src/v2/__tests__/helpers/db/user-email.ts @@ -0,0 +1,24 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; +import { userEmailDAO } from "../../../dao"; + +export class CreateUserEmail { + public constructor(private readonly t: EntityManager) {} + + public async full(info: { userUUID: string; userEmail: string }) { + await userEmailDAO.insert(this.t, { + user_uuid: info.userUUID, + user_email: info.userEmail, + }); + return info; + } + + public async quick(info: { userUUID?: string; userEmail?: string }) { + const fullInfo = { + userUUID: info.userUUID || v4(), + userEmail: info.userEmail || `${v4()}@test.com`, + }; + await this.full(fullInfo); + return fullInfo; + } +} diff --git a/src/v2/__tests__/helpers/db/user-phone.ts b/src/v2/__tests__/helpers/db/user-phone.ts index f5142a5f..c194d140 100644 --- a/src/v2/__tests__/helpers/db/user-phone.ts +++ b/src/v2/__tests__/helpers/db/user-phone.ts @@ -25,7 +25,7 @@ export class CreateUserPhone { } } -function randomPhoneNumber() { +export function randomPhoneNumber() { const prefixArray = ["130", "131", "132", "133", "135", "137", "138", "170", "187", "189"]; const i = parseInt(String(10 * Math.random())); let prefix = prefixArray[i]; diff --git a/src/v2/__tests__/helpers/db/user-wechat.ts b/src/v2/__tests__/helpers/db/user-wechat.ts new file mode 100644 index 00000000..02c14335 --- /dev/null +++ b/src/v2/__tests__/helpers/db/user-wechat.ts @@ -0,0 +1,38 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; +import { userWeChatDAO } from "../../../dao"; + +export class CreateUserWeChat { + public constructor(private readonly t: EntityManager) {} + + public async full(info: { + userUUID: string; + userName: string; + unionUUID: string; + openUUID: string; + }) { + await userWeChatDAO.insert(this.t, { + user_uuid: info.userUUID, + user_name: info.userName, + union_uuid: info.unionUUID, + open_uuid: info.openUUID, + }); + return info; + } + + public async quick(info: { + userUUID?: string; + userName?: string; + unionUUID?: string; + openUUID?: string; + }) { + const fullInfo = { + userUUID: info.userUUID || v4(), + userName: info.userName || v4().slice(6), + unionUUID: info.unionUUID || v4(), + openUUID: info.openUUID || v4(), + }; + await this.full(fullInfo); + return fullInfo; + } +} diff --git a/src/v2/__tests__/helpers/db/user.ts b/src/v2/__tests__/helpers/db/user.ts index 995a259e..e8357c7d 100644 --- a/src/v2/__tests__/helpers/db/user.ts +++ b/src/v2/__tests__/helpers/db/user.ts @@ -21,17 +21,24 @@ export class CreateUser { return info; } - public async quick() { - const info = { - userUUID: v4(), - userName: v4(), - userPassword: v4(), - avatarURL: v4(), + public async quick( + info: { + userUUID?: string; + userName?: string; + userPassword?: string; + avatarURL?: string; + } = {}, + ) { + const fullInfo = { + userUUID: info.userUUID || v4(), + userName: info.userName || v4(), + userPassword: info.userPassword ?? v4(), + avatarURL: info.avatarURL || v4(), }; - await this.full(info); + await this.full(fullInfo); - return info; + return fullInfo; } public async fixedName(userName: string) { diff --git a/src/v2/constants.ts b/src/v2/constants.ts new file mode 100644 index 00000000..86eabc9b --- /dev/null +++ b/src/v2/constants.ts @@ -0,0 +1,2 @@ +export const MessageIntervalSecond = 60; +export const MessageExpirationSecond = 60 * 10; diff --git a/src/v2/controllers/configs/__tests__/fetch-region-config.ts b/src/v2/controllers/configs/__tests__/fetch-region-config.ts new file mode 100644 index 00000000..cfec61f1 --- /dev/null +++ b/src/v2/controllers/configs/__tests__/fetch-region-config.ts @@ -0,0 +1,33 @@ +import test from "ava"; +import { HelperAPI } from "../../../__tests__/helpers/api"; +import { regionConfigs, regionConfigsRouters } from "../region-configs"; +import { initializeDataSource } from "../../../__tests__/helpers/db/test-hooks"; + +const namespace = "v2.controllers.region.configs"; +initializeDataSource(test, namespace); + +test(`${namespace} - fetch region configs`, async ava => { + const helperAPI = new HelperAPI(); + await helperAPI.import(regionConfigsRouters, regionConfigs); + + { + const resp = await helperAPI.inject({ + method: "GET", + url: "/v2/region/configs", + }); + const s = resp.payload; + console.log(s); + ava.is(resp.statusCode, 200); + const data = (await resp.json()).data; + ava.true(data.login.wechatWeb); + ava.true(data.login.wechatMobile); + ava.true(data.login.github); + ava.true(data.login.google); + ava.true(data.login.apple); + ava.true(data.login.agora); + ava.true(data.login.sms); + ava.false(data.login.smsForce); + ava.is(data.server.region, "CN"); + ava.is(data.server.regionCode, 1); + } +}); diff --git a/src/v2/controllers/configs/region-configs.ts b/src/v2/controllers/configs/region-configs.ts new file mode 100644 index 00000000..2395b85f --- /dev/null +++ b/src/v2/controllers/configs/region-configs.ts @@ -0,0 +1,131 @@ +import { ResponseSuccess } from "../../../types/Server"; + +import { Server } from "../../../utils/registryRoutersV2"; +import { Type } from "@sinclair/typebox"; +import { successJSON } from "../internal/utils/response-json"; + +import { configHash } from "../../../utils/ParseConfig"; +import { + Server as ServerConfig, + WeChat, + Github, + Google, + Apple, + AgoraLogin, + PhoneSMS, + Whiteboard, + Agora, + CloudStorage, + StorageService, + Censorship, +} from "../../../constants/Config"; + +type regionConfigsResponseSchema = { + hash: string; + login: { + wechatWeb: boolean; + wechatMobile: boolean; + github: boolean; + google: boolean; + apple: boolean; + agora: boolean; + sms: boolean; + smsForce: boolean; + }; + server: { + region: string; + regionCode: number; + env: string; + }; + whiteboard: { + appId: string; + convertRegion: string; + }; + agora: { + clientId: string; + appId: string; + screenshot: boolean; + messageNotification: boolean; + }; + github: { + clientId: string; + }; + wechat: { + webAppId: string; + mobileAppId: string; + }; + google: { + clientId: string; + }; + cloudStorage: { + singleFileSize: number; + totalSize: number; + allowFileSuffix: Array; + accessKey: string; + }; + censorship: { + video: boolean; + voice: boolean; + text: boolean; + }; +}; + +// export for unit test +export const regionConfigs = async (): Promise> => { + return successJSON({ + hash: configHash, + login: { + wechatWeb: WeChat.web.enable, + wechatMobile: WeChat.mobile.enable, + github: Github.enable, + google: Google.enable, + apple: Apple.enable, + agora: AgoraLogin.enable, + sms: PhoneSMS.enable, + smsForce: PhoneSMS.force, + }, + server: { + region: ServerConfig.region, + regionCode: ServerConfig.regionCode, + env: ServerConfig.env, + }, + whiteboard: { + appId: Whiteboard.appId, + convertRegion: Whiteboard.convertRegion, + }, + agora: { + clientId: AgoraLogin.clientId, + appId: Agora.appId, + screenshot: Agora.screenshot.enable, + messageNotification: Agora.messageNotification.enable, + }, + github: { + clientId: Github.clientId, + }, + wechat: { + webAppId: WeChat.web.appId, + mobileAppId: WeChat.mobile.appId, + }, + google: { + clientId: Google.clientId, + }, + cloudStorage: { + singleFileSize: CloudStorage.singleFileSize, + totalSize: CloudStorage.totalSize, + allowFileSuffix: CloudStorage.allowFileSuffix, + accessKey: StorageService.oss.accessKey, + }, + censorship: { + video: Censorship.video.enable, + voice: Censorship.voice.enable, + text: Censorship.text.enable, + }, + }); +}; + +export const regionConfigsRouters = (server: Server): void => { + server.get("region/configs", regionConfigs, { + schema: Type.Object({}), + auth: false, + }); +}; diff --git a/src/v2/controllers/login/email/index.ts b/src/v2/controllers/login/email/index.ts new file mode 100644 index 00000000..92a4a0cc --- /dev/null +++ b/src/v2/controllers/login/email/index.ts @@ -0,0 +1,41 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyReply } from "fastify"; +import { LoginPlatform } from "../../../../constants/Project"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { EmailLoginReturn, UserEmailService } from "../../../services/user/email"; +import { successJSON } from "../../internal/utils/response-json"; + +export const loginEmailSchema = { + body: Type.Object( + { + email: Type.String({ + format: "email", + }), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const loginEmail = async ( + req: FastifyRequestTypebox, + reply: FastifyReply, +): Promise> => { + const service = new UserEmailService(req.ids, req.DBTransaction); + + const jwtSign = (userUUID: string): Promise => + reply.jwtSign({ + userUUID, + loginSource: LoginPlatform.Email, + }); + + const result = await service.login(req.body.email, req.body.password, jwtSign); + + return successJSON(result); +}; diff --git a/src/v2/controllers/login/phone/index.ts b/src/v2/controllers/login/phone/index.ts new file mode 100644 index 00000000..22dc36b0 --- /dev/null +++ b/src/v2/controllers/login/phone/index.ts @@ -0,0 +1,39 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyReply } from "fastify"; +import { LoginPlatform } from "../../../../constants/Project"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { PhoneLoginReturn, UserPhoneService } from "../../../services/user/phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const loginPhoneSchema = { + body: Type.Object( + { + phone: Type.String(), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const loginPhone = async ( + req: FastifyRequestTypebox, + reply: FastifyReply, +): Promise> => { + const service = new UserPhoneService(req.ids, req.DBTransaction); + + const jwtSign = (userUUID: string): Promise => + reply.jwtSign({ + userUUID, + loginSource: LoginPlatform.Phone, + }); + + const result = await service.login(req.body.phone, req.body.password, jwtSign); + + return successJSON(result); +}; diff --git a/src/v2/controllers/login/routes.ts b/src/v2/controllers/login/routes.ts new file mode 100644 index 00000000..a9fe9789 --- /dev/null +++ b/src/v2/controllers/login/routes.ts @@ -0,0 +1,15 @@ +import { Server } from "../../../utils/registryRoutersV2"; +import { loginEmail, loginEmailSchema } from "./email"; +import { loginPhone, loginPhoneSchema } from "./phone"; + +export const loginRouters = (server: Server): void => { + server.post("login/phone", loginPhone, { + schema: loginPhoneSchema, + auth: false, + }); + + server.post("login/email", loginEmail, { + schema: loginEmailSchema, + auth: false, + }); +}; diff --git a/src/v2/controllers/register/email/index.ts b/src/v2/controllers/register/email/index.ts new file mode 100644 index 00000000..75024800 --- /dev/null +++ b/src/v2/controllers/register/email/index.ts @@ -0,0 +1,47 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyReply } from "fastify"; +import { LoginPlatform } from "../../../../constants/Project"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { EmailRegisterReturn, UserEmailService } from "../../../services/user/email"; +import { successJSON } from "../../internal/utils/response-json"; + +export const registerEmailSchema = { + body: Type.Object( + { + email: Type.String({ + format: "email", + }), + code: Type.Integer(), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const registerEmail = async ( + req: FastifyRequestTypebox, + reply: FastifyReply, +): Promise> => { + const service = new UserEmailService(req.ids, req.DBTransaction); + + const jwtSign = (userUUID: string): Promise => + reply.jwtSign({ + userUUID, + loginSource: LoginPlatform.Email, + }); + + const result = await service.register( + req.body.email, + req.body.code, + req.body.password, + jwtSign, + ); + + return successJSON(result); +}; diff --git a/src/v2/controllers/register/email/send-message.ts b/src/v2/controllers/register/email/send-message.ts new file mode 100644 index 00000000..df4a11a5 --- /dev/null +++ b/src/v2/controllers/register/email/send-message.ts @@ -0,0 +1,28 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserEmailService } from "../../../services/user/email"; +import { successJSON } from "../../internal/utils/response-json"; + +export const registerEmailSendMessageSchema = { + body: Type.Object( + { + email: Type.String({ + format: "email", + }), + language: Type.Optional(Type.String()), + }, + { + additionalProperties: false, + }, + ), +}; + +export const registerEmailSendMessage = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserEmailService(req.ids, req.DBTransaction); + + await service.sendMessageForRegister(req.body.email, req.body.language); + + return successJSON({}); +}; diff --git a/src/v2/controllers/register/phone/index.ts b/src/v2/controllers/register/phone/index.ts new file mode 100644 index 00000000..5621bb3a --- /dev/null +++ b/src/v2/controllers/register/phone/index.ts @@ -0,0 +1,45 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyReply } from "fastify"; +import { LoginPlatform } from "../../../../constants/Project"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { PhoneRegisterReturn, UserPhoneService } from "../../../services/user/phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const registerPhoneSchema = { + body: Type.Object( + { + phone: Type.String(), + code: Type.Integer(), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const registerPhone = async ( + req: FastifyRequestTypebox, + reply: FastifyReply, +): Promise> => { + const service = new UserPhoneService(req.ids, req.DBTransaction); + + const jwtSign = (userUUID: string): Promise => + reply.jwtSign({ + userUUID, + loginSource: LoginPlatform.Phone, + }); + + const result = await service.register( + req.body.phone, + req.body.code, + req.body.password, + jwtSign, + ); + + return successJSON(result); +}; diff --git a/src/v2/controllers/register/phone/send-message.ts b/src/v2/controllers/register/phone/send-message.ts new file mode 100644 index 00000000..0eed3c50 --- /dev/null +++ b/src/v2/controllers/register/phone/send-message.ts @@ -0,0 +1,25 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserPhoneService } from "../../../services/user/phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const registerPhoneSendMessageSchema = { + body: Type.Object( + { + phone: Type.String(), + }, + { + additionalProperties: false, + }, + ), +}; + +export const registerPhoneSendMessage = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserPhoneService(req.ids, req.DBTransaction); + + await service.sendMessageForRegister(req.body.phone); + + return successJSON({}); +}; diff --git a/src/v2/controllers/register/routes.ts b/src/v2/controllers/register/routes.ts new file mode 100644 index 00000000..feabf206 --- /dev/null +++ b/src/v2/controllers/register/routes.ts @@ -0,0 +1,27 @@ +import { Server } from "../../../utils/registryRoutersV2"; +import { registerEmail, registerEmailSchema } from "./email"; +import { registerEmailSendMessage, registerEmailSendMessageSchema } from "./email/send-message"; +import { registerPhone, registerPhoneSchema } from "./phone"; +import { registerPhoneSendMessage, registerPhoneSendMessageSchema } from "./phone/send-message"; + +export const registerRouters = (server: Server): void => { + server.post("register/phone/send-message", registerPhoneSendMessage, { + schema: registerPhoneSendMessageSchema, + auth: false, + }); + + server.post("register/phone", registerPhone, { + schema: registerPhoneSchema, + auth: false, + }); + + server.post("register/email/send-message", registerEmailSendMessage, { + schema: registerEmailSendMessageSchema, + auth: false, + }); + + server.post("register/email", registerEmail, { + schema: registerEmailSchema, + auth: false, + }); +}; diff --git a/src/v2/controllers/reset/email/index.ts b/src/v2/controllers/reset/email/index.ts new file mode 100644 index 00000000..2154492d --- /dev/null +++ b/src/v2/controllers/reset/email/index.ts @@ -0,0 +1,33 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserEmailService } from "../../../services/user/email"; +import { successJSON } from "../../internal/utils/response-json"; + +export const resetEmailSchema = { + body: Type.Object( + { + email: Type.String({ + format: "email", + }), + code: Type.Integer(), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const resetEmail = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserEmailService(req.ids, req.DBTransaction); + + await service.reset(req.body.email, req.body.code, req.body.password); + + return successJSON({}); +}; diff --git a/src/v2/controllers/reset/email/send-message.ts b/src/v2/controllers/reset/email/send-message.ts new file mode 100644 index 00000000..53909fa4 --- /dev/null +++ b/src/v2/controllers/reset/email/send-message.ts @@ -0,0 +1,28 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserEmailService } from "../../../services/user/email"; +import { successJSON } from "../../internal/utils/response-json"; + +export const resetEmailSendMessageSchema = { + body: Type.Object( + { + email: Type.String({ + format: "email", + }), + language: Type.Optional(Type.String()), + }, + { + additionalProperties: false, + }, + ), +}; + +export const resetEmailSendMessage = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserEmailService(req.ids, req.DBTransaction); + + await service.sendMessageForReset(req.body.email, req.body.language); + + return successJSON({}); +}; diff --git a/src/v2/controllers/reset/phone/index.ts b/src/v2/controllers/reset/phone/index.ts new file mode 100644 index 00000000..ad21a2dd --- /dev/null +++ b/src/v2/controllers/reset/phone/index.ts @@ -0,0 +1,31 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserPhoneService } from "../../../services/user/phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const resetPhoneSchema = { + body: Type.Object( + { + phone: Type.String(), + code: Type.Integer(), + password: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const resetPhone = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserPhoneService(req.ids, req.DBTransaction); + + await service.reset(req.body.phone, req.body.code, req.body.password); + + return successJSON({}); +}; diff --git a/src/v2/controllers/reset/phone/send-message.ts b/src/v2/controllers/reset/phone/send-message.ts new file mode 100644 index 00000000..d2ab8eb6 --- /dev/null +++ b/src/v2/controllers/reset/phone/send-message.ts @@ -0,0 +1,25 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserPhoneService } from "../../../services/user/phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const resetPhoneSendMessageSchema = { + body: Type.Object( + { + phone: Type.String(), + }, + { + additionalProperties: false, + }, + ), +}; + +export const resetPhoneSendMessage = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserPhoneService(req.ids, req.DBTransaction); + + await service.sendMessageForReset(req.body.phone); + + return successJSON({}); +}; diff --git a/src/v2/controllers/reset/routes.ts b/src/v2/controllers/reset/routes.ts new file mode 100644 index 00000000..ad034238 --- /dev/null +++ b/src/v2/controllers/reset/routes.ts @@ -0,0 +1,27 @@ +import { Server } from "../../../utils/registryRoutersV2"; +import { resetEmail, resetEmailSchema } from "./email"; +import { resetEmailSendMessage, resetEmailSendMessageSchema } from "./email/send-message"; +import { resetPhone, resetPhoneSchema } from "./phone"; +import { resetPhoneSendMessage, resetPhoneSendMessageSchema } from "./phone/send-message"; + +export const resetRouters = (server: Server): void => { + server.post("reset/phone/send-message", resetPhoneSendMessage, { + schema: resetPhoneSendMessageSchema, + auth: false, + }); + + server.post("reset/phone", resetPhone, { + schema: resetPhoneSchema, + auth: false, + }); + + server.post("reset/email/send-message", resetEmailSendMessage, { + schema: resetEmailSendMessageSchema, + auth: false, + }); + + server.post("reset/email", resetEmail, { + schema: resetEmailSchema, + auth: false, + }); +}; diff --git a/src/v2/controllers/room/export-users/index.ts b/src/v2/controllers/room/export-users/index.ts index d1788fc7..f470a887 100644 --- a/src/v2/controllers/room/export-users/index.ts +++ b/src/v2/controllers/room/export-users/index.ts @@ -7,9 +7,7 @@ import { Type } from "@sinclair/typebox"; export const roomExportUsersSchema = { body: Type.Object( { - roomUUID: Type.String({ - format: "uuid-v4", - }), + roomUUID: Type.String(), }, { additionalProperties: false, diff --git a/src/v2/controllers/routes.ts b/src/v2/controllers/routes.ts index c4dd747a..b2ea4996 100644 --- a/src/v2/controllers/routes.ts +++ b/src/v2/controllers/routes.ts @@ -5,6 +5,10 @@ import { applicationRouters } from "./application/routes"; import { roomRouters } from "./room/routes"; import { oauthRouters } from "./auth2/routes"; import { tempPhotoRouters } from "./temp-photo/routes"; +import { regionConfigsRouters } from "./configs/region-configs"; +import { registerRouters } from "./register/routes"; +import { loginRouters } from "./login/routes"; +import { resetRouters } from "./reset/routes"; export const v2Routes = [ userRouters, @@ -14,4 +18,8 @@ export const v2Routes = [ roomRouters, oauthRouters, tempPhotoRouters, + regionConfigsRouters, + registerRouters, + loginRouters, + resetRouters, ]; diff --git a/src/v2/controllers/user/password/index.ts b/src/v2/controllers/user/password/index.ts new file mode 100644 index 00000000..e9fdb296 --- /dev/null +++ b/src/v2/controllers/user/password/index.ts @@ -0,0 +1,36 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { successJSON } from "../../internal/utils/response-json"; +import { UserUpdateService } from "../../../services/user/update"; + +export const userPasswordSchema = { + body: Type.Object( + { + password: Type.Optional( + Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + ), + newPassword: Type.String({ + format: "password", + minLength: 8, + maxLength: 32, + }), + }, + { + additionalProperties: false, + }, + ), +}; + +export const userPassword = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserUpdateService(req.ids, req.DBTransaction, req.userUUID); + + await service.password(req.body.password || null, req.body.newPassword); + + return successJSON({}); +}; diff --git a/src/v2/controllers/user/rebind-phone/index.ts b/src/v2/controllers/user/rebind-phone/index.ts new file mode 100644 index 00000000..b0fd2e30 --- /dev/null +++ b/src/v2/controllers/user/rebind-phone/index.ts @@ -0,0 +1,32 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyReply } from "fastify"; +import { LoginPlatform } from "../../../../constants/Project"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserRebindPhoneService, UserRebindReturn } from "../../../services/user/rebind-phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const userRebindPhoneSchema = { + body: Type.Object( + { + phone: Type.String(), + code: Type.Integer(), + }, + { + additionalProperties: false, + }, + ), +}; + +export const userRebindPhone = async ( + req: FastifyRequestTypebox, + reply: FastifyReply, +): Promise> => { + const service = new UserRebindPhoneService(req.ids, req.DBTransaction, req.userUUID); + + const jwtSign = (userUUID: string): Promise => + reply.jwtSign({ userUUID, loginSource: LoginPlatform.Phone }); + + const result = await service.rebind(req.body.phone, req.body.code, jwtSign); + + return successJSON(result); +}; diff --git a/src/v2/controllers/user/rebind-phone/send-message.ts b/src/v2/controllers/user/rebind-phone/send-message.ts new file mode 100644 index 00000000..53cbe38d --- /dev/null +++ b/src/v2/controllers/user/rebind-phone/send-message.ts @@ -0,0 +1,25 @@ +import { Type } from "@sinclair/typebox"; +import { FastifyRequestTypebox, Response } from "../../../../types/Server"; +import { UserRebindPhoneService } from "../../../services/user/rebind-phone"; +import { successJSON } from "../../internal/utils/response-json"; + +export const userRebindPhoneSendMessageSchema = { + body: Type.Object( + { + phone: Type.String(), + }, + { + additionalProperties: false, + }, + ), +}; + +export const userRebindPhoneSendMessage = async ( + req: FastifyRequestTypebox, +): Promise => { + const service = new UserRebindPhoneService(req.ids, req.DBTransaction, req.userUUID); + + await service.sendMessage(req.body.phone); + + return successJSON({}); +}; diff --git a/src/v2/controllers/user/routes.ts b/src/v2/controllers/user/routes.ts index 59be0262..fa3358c2 100644 --- a/src/v2/controllers/user/routes.ts +++ b/src/v2/controllers/user/routes.ts @@ -3,6 +3,12 @@ import { userRename, userRenameSchema } from "./rename"; import { userUploadAvatarStart, userUploadAvatarStartSchema } from "./upload-avatar/start"; import { userUploadAvatarFinish, userUploadAvatarFinishSchema } from "./upload-avatar/finish"; import { userSensitive, userSensitiveSchema } from "./sensitive"; +import { + userRebindPhoneSendMessage, + userRebindPhoneSendMessageSchema, +} from "./rebind-phone/send-message"; +import { userRebindPhone, userRebindPhoneSchema } from "./rebind-phone"; +import { userPassword, userPasswordSchema } from "./password"; export const userRouters = (server: Server): void => { server.post("user/rename", userRename, { @@ -20,4 +26,16 @@ export const userRouters = (server: Server): void => { server.post("user/sensitive", userSensitive, { schema: userSensitiveSchema, }); + + server.post("user/rebind-phone/send-message", userRebindPhoneSendMessage, { + schema: userRebindPhoneSendMessageSchema, + }); + + server.post("user/rebind-phone", userRebindPhone, { + schema: userRebindPhoneSchema, + }); + + server.post("user/password", userPassword, { + schema: userPasswordSchema, + }); }; diff --git a/src/v2/dao/index.ts b/src/v2/dao/index.ts index 48f85df1..f329ac1e 100644 --- a/src/v2/dao/index.ts +++ b/src/v2/dao/index.ts @@ -10,6 +10,7 @@ import { UserGithubModel } from "../../model/user/Github"; import { UserAgoraModel } from "../../model/user/Agora"; import { UserGoogleModel } from "../../model/user/Google"; import { UserPhoneModel } from "../../model/user/Phone"; +import { UserEmailModel } from "../../model/user/Email"; import { UserSensitiveModel } from "../../model/user/Sensitive"; import { RoomModel } from "../../model/room/Room"; import { RoomUserModel } from "../../model/room/RoomUser"; @@ -25,7 +26,7 @@ import { OAuthSecretsModel } from "../../model/oauth/oauth-secrets"; import { OAuthUsersModel } from "../../model/oauth/oauth-users"; import { dataSource } from "../../thirdPartyService/TypeORMService"; -class DAO { +export class DAO { public constructor(private readonly model: EntityTarget) {} public async findOne( @@ -182,6 +183,7 @@ export const userAppleDAO = new DAO(UserAppleModel); export const userAgoraDAO = new DAO(UserAgoraModel); export const userGoogleDAO = new DAO(UserGoogleModel); export const userPhoneDAO = new DAO(UserPhoneModel); +export const userEmailDAO = new DAO(UserEmailModel); export const userSensitiveDAO = new DAO(UserSensitiveModel); export const roomDAO = new DAO(RoomModel); export const roomUserDAO = new DAO(RoomUserModel); diff --git a/src/v2/services/cloud-storage/__tests__/convert.test.ts b/src/v2/services/cloud-storage/__tests__/convert.test.ts index 94ce064c..764ccb51 100644 --- a/src/v2/services/cloud-storage/__tests__/convert.test.ts +++ b/src/v2/services/cloud-storage/__tests__/convert.test.ts @@ -322,15 +322,16 @@ test.serial(`${namespace} - start whiteboard projector - failed`, async ava => { }), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.FileConvertFailed}`, + // it won't start when step != None + message: `${Status.Failed}: ${ErrorCode.FileNotIsConvertNone}`, }, ); - const data = await cloudStorageFilesDAO.findOne(t, 'payload', { + const data = await cloudStorageFilesDAO.findOne(t, "payload", { file_uuid: fileUUID, }); - ava.is((data.payload as any)?.convertStep, FileConvertStep.Failed); + ava.is((data.payload as any)?.convertStep, undefined); stubAxios.restore(); await releaseRunner(); diff --git a/src/v2/services/user/__tests__/email.test.ts b/src/v2/services/user/__tests__/email.test.ts new file mode 100644 index 00000000..f9e5ee36 --- /dev/null +++ b/src/v2/services/user/__tests__/email.test.ts @@ -0,0 +1,209 @@ +import test from "ava"; +import { v4 } from "uuid"; +import { Status } from "../../../../constants/Project"; +import { FError } from "../../../../error/ControllerError"; +import { ErrorCode } from "../../../../ErrorCode"; +import RedisService from "../../../../thirdPartyService/RedisService"; +import { hash } from "../../../../utils/Hash"; +import { RedisKey } from "../../../../utils/Redis"; +import { MessageExpirationSecond } from "../../../constants"; +import { userDAO, userEmailDAO } from "../../../dao"; +import { testService } from "../../../__tests__/helpers/db"; +import { useTransaction } from "../../../__tests__/helpers/db/query-runner"; +import { initializeDataSource } from "../../../__tests__/helpers/db/test-hooks"; +import { ids } from "../../../__tests__/helpers/fastify/ids"; +import { UserEmailService } from "../email"; + +const namespace = "v2.services.user.email"; +initializeDataSource(test, namespace); + +test(`${namespace} - user already registered in send message`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + const userInfo = await createUser.quick(); + const userEmailInfo = await createUserEmail.quick(userInfo); + + await ava.throwsAsync( + () => new UserEmailService(ids(), t).sendMessageForRegister(userEmailInfo.userEmail), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.EmailAlreadyExist}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user not found in send message for reset`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + await ava.throwsAsync( + () => new UserEmailService(ids(), t).sendMessageForReset(`${v4()}@test.com`), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user email already registered`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + const userInfo = await createUser.quick(); + const userEmailInfo = await createUserEmail.quick(userInfo); + + RedisService.set( + RedisKey.emailRegisterOrReset(userEmailInfo.userEmail), + "666666", + MessageExpirationSecond, + ); + + await ava.throwsAsync( + () => + new UserEmailService(ids(), t).register( + userEmailInfo.userEmail, + 666666, + v4(), + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.EmailAlreadyExist}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user email register success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + const email = `${v4()}@test.com`; + RedisService.set(RedisKey.emailRegisterOrReset(email), "666666", MessageExpirationSecond); + + const password = v4(); + const { userUUID } = await new UserEmailService(ids(), t).register( + email, + 666666, + password, + async () => "", + ); + + const userInfo = await userDAO.findOne(t, ["user_password"], { user_uuid: userUUID }); + ava.not(userInfo, null); + ava.is(userInfo?.user_password, hash(password)); + + const userEmailInfo = await userEmailDAO.findOne(t, ["id"], { user_email: email }); + ava.not(userEmailInfo, null); + + await releaseRunner(); +}); + +test(`${namespace} - user email not found in reset`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + const email = `${v4()}@test.com`; + RedisService.set(RedisKey.emailRegisterOrReset(email), "666666", MessageExpirationSecond); + + const password = v4(); + + await ava.throwsAsync(() => new UserEmailService(ids(), t).reset(email, 666666, password), { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }); + + await releaseRunner(); +}); + +test(`${namespace} - user email reset success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + const userInfo = await createUser.quick(); + const userEmailInfo = await createUserEmail.quick(userInfo); + + RedisService.set( + RedisKey.emailRegisterOrReset(userEmailInfo.userEmail), + "666666", + MessageExpirationSecond, + ); + + const password = v4(); + + await new UserEmailService(ids(), t).reset(userEmailInfo.userEmail, 666666, password); + + const newUserInfo = await userDAO.findOne(t, ["user_password"], { + user_uuid: userInfo.userUUID, + }); + ava.not(newUserInfo, null); + ava.is(newUserInfo?.user_password, hash(password)); + + await releaseRunner(); +}); + +test(`${namespace} - user email not found in login`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + await ava.throwsAsync( + () => new UserEmailService(ids(), t).login(`${v4()}@test.com`, v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + const userInfo = await createUser.quick({ userPassword: "" }); + const userEmailInfo = await createUserEmail.quick(userInfo); + + await ava.throwsAsync( + () => new UserEmailService(ids(), t).login(userEmailInfo.userEmail, v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user email wrong password`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + const userInfo = await createUser.quick(); + const userEmailInfo = await createUserEmail.quick(userInfo); + + await ava.throwsAsync( + () => new UserEmailService(ids(), t).login(userEmailInfo.userEmail, v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user email login success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserEmail } = testService(t); + + const password = v4(); + const userInfo = await createUser.quick({ userPassword: hash(password) }); + const userEmailInfo = await createUserEmail.quick(userInfo); + + const result = await new UserEmailService(ids(), t).login( + userEmailInfo.userEmail, + password, + async () => "", + ); + + ava.is(result.userUUID, userInfo.userUUID); + + await releaseRunner(); +}); diff --git a/src/v2/services/user/__tests__/phone.test.ts b/src/v2/services/user/__tests__/phone.test.ts new file mode 100644 index 00000000..f012c24a --- /dev/null +++ b/src/v2/services/user/__tests__/phone.test.ts @@ -0,0 +1,214 @@ +import test from "ava"; +import { v4 } from "uuid"; +import { Status } from "../../../../constants/Project"; +import { FError } from "../../../../error/ControllerError"; +import { ErrorCode } from "../../../../ErrorCode"; +import RedisService from "../../../../thirdPartyService/RedisService"; +import { hash } from "../../../../utils/Hash"; +import { RedisKey } from "../../../../utils/Redis"; +import { MessageExpirationSecond } from "../../../constants"; +import { userDAO, userPhoneDAO } from "../../../dao"; +import { testService } from "../../../__tests__/helpers/db"; +import { useTransaction } from "../../../__tests__/helpers/db/query-runner"; +import { initializeDataSource } from "../../../__tests__/helpers/db/test-hooks"; +import { randomPhoneNumber } from "../../../__tests__/helpers/db/user-phone"; +import { ids } from "../../../__tests__/helpers/fastify/ids"; +import { UserPhoneService } from "../phone"; + +const namespace = "v2.services.user.phone"; +initializeDataSource(test, namespace); + +test(`${namespace} - user already registered in send message`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).sendMessageForRegister(userPhoneInfo.phoneNumber), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.SMSAlreadyExist}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user not found in send message for reset`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).sendMessageForReset(randomPhoneNumber()), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user phone already registered`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + RedisService.set( + RedisKey.phoneRegisterOrReset(userPhoneInfo.phoneNumber), + "666666", + MessageExpirationSecond, + ); + + await ava.throwsAsync( + () => + new UserPhoneService(ids(), t).register( + userPhoneInfo.phoneNumber, + 666666, + v4(), + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.SMSAlreadyExist}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user phone register success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + const phoneNumber = randomPhoneNumber(); + RedisService.set(RedisKey.phoneRegisterOrReset(phoneNumber), "666666", MessageExpirationSecond); + + const password = v4(); + const { userUUID } = await new UserPhoneService(ids(), t).register( + phoneNumber, + 666666, + password, + async () => "", + ); + + const userInfo = await userDAO.findOne(t, ["user_password"], { user_uuid: userUUID }); + ava.not(userInfo, null); + ava.is(userInfo?.user_password, hash(password)); + + const userPhoneInfo = await userPhoneDAO.findOne(t, ["id"], { phone_number: phoneNumber }); + ava.not(userPhoneInfo, null); + + await releaseRunner(); +}); + +test(`${namespace} - user phone not found in reset`, async ava => { + const { t, releaseRunner } = await useTransaction(); + + const phoneNumber = randomPhoneNumber(); + RedisService.set(RedisKey.phoneRegisterOrReset(phoneNumber), "666666", MessageExpirationSecond); + + const password = v4(); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).reset(phoneNumber, 666666, password), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user phone reset success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + RedisService.set( + RedisKey.phoneRegisterOrReset(userPhoneInfo.phoneNumber), + "666666", + MessageExpirationSecond, + ); + + const password = v4(); + + await new UserPhoneService(ids(), t).reset(userPhoneInfo.phoneNumber, 666666, password); + + const newUserInfo = await userDAO.findOne(t, ["user_password"], { + user_uuid: userInfo.userUUID, + }); + ava.not(newUserInfo, null); + ava.is(newUserInfo?.user_password, hash(password)); + + await releaseRunner(); +}); + +test(`${namespace} - user phone not found in login`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).login(randomPhoneNumber(), v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + const userInfo = await createUser.quick({ userPassword: "" }); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).login(userPhoneInfo.phoneNumber, v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user phone wrong password`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + await ava.throwsAsync( + () => new UserPhoneService(ids(), t).login(userPhoneInfo.phoneNumber, v4(), async () => ""), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user phone login success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const password = v4(); + const userInfo = await createUser.quick({ userPassword: hash(password) }); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + const result = await new UserPhoneService(ids(), t).login( + userPhoneInfo.phoneNumber, + password, + async () => "", + ); + + ava.is(result.userUUID, userInfo.userUUID); + ava.is(result.hasPhone, true); + + await releaseRunner(); +}); diff --git a/src/v2/services/user/__tests__/rebind-phone.test.ts b/src/v2/services/user/__tests__/rebind-phone.test.ts new file mode 100644 index 00000000..36117daa --- /dev/null +++ b/src/v2/services/user/__tests__/rebind-phone.test.ts @@ -0,0 +1,160 @@ +import test from "ava"; +import { v4 } from "uuid"; +import { Status } from "../../../../constants/Project"; +import { FError } from "../../../../error/ControllerError"; +import { ErrorCode } from "../../../../ErrorCode"; +import { RoomStatus } from "../../../../model/room/Constants"; +import RedisService from "../../../../thirdPartyService/RedisService"; +import { RedisKey } from "../../../../utils/Redis"; +import { userDAO, userPhoneDAO, userWeChatDAO } from "../../../dao"; +import { testService } from "../../../__tests__/helpers/db"; +import { useTransaction } from "../../../__tests__/helpers/db/query-runner"; +import { initializeDataSource } from "../../../__tests__/helpers/db/test-hooks"; +import { randomPhoneNumber } from "../../../__tests__/helpers/db/user-phone"; +import { ids } from "../../../__tests__/helpers/fastify/ids"; +import { MessageExpirationSecond, UserRebindPhoneService } from "../rebind-phone"; + +const namespace = "v2.services.user.rebind-phone"; +initializeDataSource(test, namespace); + +test(`${namespace} - user already bind phone in send message`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + await ava.throwsAsync( + () => + new UserRebindPhoneService(ids(), t, userInfo.userUUID).sendMessage( + userPhoneInfo.phoneNumber, + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.SMSAlreadyExist}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user has joined room in rebind`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone, createRoom, createRoomJoin } = testService(t); + + const userInfo = await createUser.quick(); + const roomInfo = await createRoom.quick({ roomStatus: RoomStatus.Started }); + await createRoomJoin.quick({ ...roomInfo, ...userInfo }); + + const targetUserInfo = await createUser.quick(); + const targetUserPhoneInfo = await createUserPhone.quick(targetUserInfo); + + RedisService.set( + RedisKey.phoneBinding(targetUserPhoneInfo.phoneNumber), + "666666", + MessageExpirationSecond, + ); + + await ava.throwsAsync( + () => + new UserRebindPhoneService(ids(), t, userInfo.userUUID).rebind( + targetUserPhoneInfo.phoneNumber, + 666666, + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserRoomListNotEmpty}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user not found in rebind`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone } = testService(t); + + await ava.throwsAsync( + () => + new UserRebindPhoneService(ids(), t, v4()).rebind( + randomPhoneNumber(), + 666666, + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }, + ); + + const userInfo = await createUser.quick(); + const userPhoneInfo = await createUserPhone.quick(userInfo); + + await ava.throwsAsync( + () => + new UserRebindPhoneService(ids(), t, userInfo.userUUID).rebind( + randomPhoneNumber(), + 666666, + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.SMSAlreadyBinding}`, + }, + ); + + await userPhoneDAO.delete(t, { phone_number: userPhoneInfo.phoneNumber }); + + await ava.throwsAsync( + () => + new UserRebindPhoneService(ids(), t, userInfo.userUUID).rebind( + randomPhoneNumber(), + 666666, + async () => "", + ), + { + instanceOf: FError, + message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + }, + ); + + await releaseRunner(); +}); + +test(`${namespace} - user rebind success`, async ava => { + const { t, releaseRunner } = await useTransaction(); + const { createUser, createUserPhone, createUserWeChat } = testService(t); + + const userInfo = await createUser.quick(); + const oldUserWeChatInfo = await createUserWeChat.quick(userInfo); + + const targetUserInfo = await createUser.quick(); + const targetUserPhoneInfo = await createUserPhone.quick(targetUserInfo); + + RedisService.set( + RedisKey.phoneBinding(targetUserPhoneInfo.phoneNumber), + "666666", + MessageExpirationSecond, + ); + + const result = await new UserRebindPhoneService(ids(), t, userInfo.userUUID).rebind( + targetUserPhoneInfo.phoneNumber, + 666666, + async () => "", + ); + + ava.deepEqual(result.rebind, { WeChat: 0, Github: -1, Apple: -1, Agora: -1, Google: -1 }); + + const newUserWeChatInfo = await userWeChatDAO.findOne(t, ["union_uuid", "open_uuid"], { + user_uuid: targetUserInfo.userUUID, + }); + + ava.is(newUserWeChatInfo?.open_uuid, oldUserWeChatInfo.openUUID); + ava.is(newUserWeChatInfo?.union_uuid, oldUserWeChatInfo.unionUUID); + + const userShouldDelete = await userDAO.findOne(t, ["id"], { user_uuid: userInfo.userUUID }); + ava.is(userShouldDelete, null); + + await releaseRunner(); +}); diff --git a/src/v2/services/user/email.ts b/src/v2/services/user/email.ts new file mode 100644 index 00000000..419a85c1 --- /dev/null +++ b/src/v2/services/user/email.ts @@ -0,0 +1,317 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; + +import RedisService from "../../../thirdPartyService/RedisService"; +import { EmailSMS, Server } from "../../../constants/Config"; +import { FError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { createLoggerService } from "../../../logger"; +import { Email } from "../../../utils/Email"; +import { hash } from "../../../utils/Hash"; +import { RedisKey } from "../../../utils/Redis"; +import { MessageExpirationSecond, MessageIntervalSecond } from "../../constants"; +import { userDAO, userEmailDAO, userPhoneDAO } from "../../dao"; +import { setGuidePPTX } from "./utils"; + +export class UserEmailService { + private readonly logger = createLoggerService<"userEmail">({ + serviceName: "userEmail", + ids: this.ids, + }); + + constructor(private readonly ids: IDS, private readonly DBTransaction: EntityManager) {} + + public async sendMessageForRegister(email: string, language?: string): Promise { + const sms = new Email(email, { + tagName: "register", + subject: this.getSubject("register", language), + htmlBody: (email: string, code: string) => + this.getMessage("register", email, code, language), + }); + + if (await UserEmailService.canSend(email)) { + const exist = await userEmailDAO.findOne(this.DBTransaction, ["id"], { + user_email: email, + }); + if (exist) { + throw new FError(ErrorCode.EmailAlreadyExist); + } + + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.EmailFailedToSendCode); + } + + await RedisService.set( + RedisKey.emailRegisterOrReset(email), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + throw new FError(ErrorCode.ExhaustiveAttack); + } + } + + public async sendMessageForReset(email: string, language?: string): Promise { + const sms = new Email(email, { + tagName: "reset", + subject: this.getSubject("reset", language), + htmlBody: (email: string, code: string) => + this.getMessage("reset", email, code, language), + }); + + if (await UserEmailService.canSend(email)) { + const exist = await userEmailDAO.findOne(this.DBTransaction, ["id"], { + user_email: email, + }); + if (!exist) { + throw new FError(ErrorCode.UserNotFound); + } + + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.EmailFailedToSendCode); + } + + await RedisService.set( + RedisKey.emailRegisterOrReset(email), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + throw new FError(ErrorCode.ExhaustiveAttack); + } + } + + public async register( + email: string, + code: number, + password: string, + jwtSign: (userUUID: string) => Promise, + ): Promise { + password = hash(password); + + await UserEmailService.notExhaustiveAttack(email); + await UserEmailService.assertCodeCorrect(email, code); + await UserEmailService.clearTryRegisterCount(email); + + const userUUIDByEmail = await this.userUUIDByEmail(email); + if (userUUIDByEmail) { + this.logger.info("register email already exist", { userEmail: { email } }); + throw new FError(ErrorCode.EmailAlreadyExist); + } + + const userUUID = v4(); + const userName = email.split("@")[0]; + + const createUser = userDAO.insert(this.DBTransaction, { + user_name: userName, + user_uuid: userUUID, + avatar_url: "", + user_password: password, + }); + + const createUserEmail = userEmailDAO.insert(this.DBTransaction, { + user_uuid: userUUID, + user_email: email, + }); + + const setupGuidePPTX = setGuidePPTX(this.DBTransaction, userUUID); + + await Promise.all([createUser, createUserEmail, setupGuidePPTX]); + + const result: EmailRegisterReturn = { + name: userName, + avatarURL: "", + userUUID, + token: await jwtSign(userUUID), + hasPhone: false, + hasPassword: true, + }; + + await UserEmailService.clearVerificationCode(email); + + return result; + } + + public async reset(email: string, code: number, password: string): Promise { + password = hash(password); + + await UserEmailService.notExhaustiveAttack(email); + await UserEmailService.assertCodeCorrect(email, code); + await UserEmailService.clearTryRegisterCount(email); + + const userUUIDByEmail = await this.userUUIDByEmail(email); + if (!userUUIDByEmail) { + this.logger.info("reset email not found", { userEmail: { email } }); + throw new FError(ErrorCode.UserNotFound); + } + + await userDAO.update( + this.DBTransaction, + { user_password: password }, + { user_uuid: userUUIDByEmail }, + ); + + await UserEmailService.clearVerificationCode(email); + } + + public async login( + email: string, + password: string, + jwtSign: (userUUID: string) => Promise, + ): Promise { + password = hash(password); + + const userUUIDByEmail = await this.userUUIDByEmail(email); + if (!userUUIDByEmail) { + this.logger.info("login email not found", { userEmail: { email } }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + const user = await userDAO.findOne( + this.DBTransaction, + ["user_name", "avatar_url", "user_password"], + { user_uuid: userUUIDByEmail }, + ); + if (!user) { + this.logger.info("login email not found user", { + userEmail: { email, userUUIDByEmail }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + if (!user.user_password) { + this.logger.info("login email user password is null", { + userEmail: { email, userUUIDByEmail }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + if (user.user_password !== password) { + this.logger.info("login email password incorrect", { + userEmail: { email, userUUIDByEmail }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + return { + name: user.user_name, + avatarURL: user.avatar_url, + userUUID: userUUIDByEmail, + token: await jwtSign(userUUIDByEmail), + hasPhone: await this.hasPhone(userUUIDByEmail), + hasPassword: true, + }; + } + + private getSubject(_type: "register" | "reset", language?: string): string { + if (language && language.startsWith("zh")) { + return "Flat 验证码"; + } else { + return "Flat Verification Code"; + } + } + + private getMessage( + type: "register" | "reset" | "bind", + email: string, + code: string, + language?: string, + ): string { + const name = email.split("@")[0]; + if (language && language.startsWith("zh")) { + if (type === "register") { + return `${name},你好!

感谢注册 Flat 在线教室,请在10分钟内输入验证码:

${code}



Flat 是一款开源的在线授课软件,专为个人老师设计。我们努力克制保持简单、清爽、专注课中互动体验,希望可以给你带来愉悦的上课体验。

目前 Flat 正在积极开发中,如果你在使用过程中遇到问题,欢迎联系我进行反馈。它在一天天长大,我们很高兴与你分享这份喜悦。

Leo Yang
Flat 产品经理
yangliu02@agora.io`; + } else { + return `${name},你好!请在10分钟内输入验证码:

${code}



目前 Flat 正在积极开发中,如果你在使用过程中遇到问题,欢迎联系我进行反馈。它在一天天长大,我们很高兴与你分享这份喜悦。

Leo Yang
Flat 产品经理
yangliu02@agora.io`; + } + } else { + if (type === "register") { + return `Hello, ${name}!

Thank you for registering with Flat Online Classroom. Please enter the verification code within 10 minutes:

${code}



Flat is an open-source online teaching software designed specifically for freelance teachers. We strive to maintain a simple, refreshing, and focused in-class interactive experience, aiming to provide you with a pleasant teaching experience.

Currently, Flat is actively under development. If you encounter any issues during usage, please feel free to contact me for feedback. It is growing day by day, and we are delighted to share this joy with you.

Thanks and Regards,
Leo Yang
Flat PM
yangliu02@agora.io`; + } else { + return `Hello, ${name}! Please enter the verification code within 10 minutes:

${code}




Thanks and Regards,
Leo Yang
Flat PM
yangliu02@agora.io`; + } + } + } + + private async hasPhone(userUUID: string): Promise { + const exist = await userPhoneDAO.findOne(this.DBTransaction, ["id"], { + user_uuid: userUUID, + }); + return Boolean(exist); + } + + private async userUUIDByEmail(email: string): Promise { + const result = await userEmailDAO.findOne(this.DBTransaction, ["user_uuid"], { + user_email: email, + }); + + return result ? result.user_uuid : null; + } + + private static async canSend(email: string): Promise { + const ttl = await RedisService.ttl(RedisKey.emailRegisterOrReset(email)); + + if (ttl < 0) { + return true; + } + + const elapsedTime = MessageExpirationSecond - ttl; + + return elapsedTime > MessageIntervalSecond; + } + + private static async assertCodeCorrect(email: string, code: number): Promise { + if (Server.env === "dev") { + for (const user of EmailSMS.testEmails) { + if (user.email === email && user.code === code) { + return; + } + } + } + + const value = await RedisService.get(RedisKey.emailRegisterOrReset(email)); + + if (String(code) !== value) { + throw new FError(ErrorCode.EmailVerificationCodeInvalid); + } + } + + private static readonly ExhaustiveAttackCount = 10; + + private static async notExhaustiveAttack(email: string): Promise { + const key = RedisKey.emailTryRegisterOrResetCount(email); + const value = Number(await RedisService.get(key)) || 0; + + if (value > UserEmailService.ExhaustiveAttackCount) { + throw new FError(ErrorCode.ExhaustiveAttack); + } + + const count = await RedisService.incr(key); + + if (count === 1) { + // must re-wait 10 minutes + await RedisService.expire(key, 60 * 10); + } + } + + private static async clearTryRegisterCount(email: string): Promise { + await RedisService.del(RedisKey.emailTryRegisterOrResetCount(email)); + } + + private static async clearVerificationCode(email: string): Promise { + await RedisService.del(RedisKey.emailRegisterOrReset(email)); + } +} + +export type EmailRegisterReturn = { + name: string; + avatarURL: string; + userUUID: string; + token: string; + hasPhone: boolean; + hasPassword: boolean; +}; + +export type EmailLoginReturn = EmailRegisterReturn; diff --git a/src/v2/services/user/phone.ts b/src/v2/services/user/phone.ts new file mode 100644 index 00000000..c13861b4 --- /dev/null +++ b/src/v2/services/user/phone.ts @@ -0,0 +1,280 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; + +import RedisService from "../../../thirdPartyService/RedisService"; +import { PhoneSMS, Server } from "../../../constants/Config"; +import { FError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { createLoggerService } from "../../../logger"; +import { hash } from "../../../utils/Hash"; +import { RedisKey } from "../../../utils/Redis"; +import { SMS, SMSUtils } from "../../../utils/SMS"; +import { MessageExpirationSecond, MessageIntervalSecond } from "../../constants"; +import { userDAO, userPhoneDAO } from "../../dao"; +import { setGuidePPTX } from "./utils"; + +export class UserPhoneService { + private readonly logger = createLoggerService<"userPhone">({ + serviceName: "userPhone", + ids: this.ids, + }); + + constructor(private readonly ids: IDS, private readonly DBTransaction: EntityManager) {} + + public async sendMessageForRegister(phone: string): Promise { + const sms = new SMS(phone); + + const safePhone = SMSUtils.safePhone(phone); + + if (await UserPhoneService.canSend(safePhone)) { + const exist = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { + phone_number: phone, + }); + if (exist) { + throw new FError(ErrorCode.SMSAlreadyExist); + } + + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + + await RedisService.set( + RedisKey.phoneRegisterOrReset(safePhone), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + throw new FError(ErrorCode.ExhaustiveAttack); + } + } + + public async sendMessageForReset(phone: string): Promise { + const sms = new SMS(phone); + + const safePhone = SMSUtils.safePhone(phone); + + if (await UserPhoneService.canSend(safePhone)) { + const user = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { + phone_number: phone, + }); + if (!user) { + throw new FError(ErrorCode.UserNotFound); + } + + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + + await RedisService.set( + RedisKey.phoneRegisterOrReset(safePhone), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + throw new FError(ErrorCode.ExhaustiveAttack); + } + } + + public async register( + phone: string, + code: number, + password: string, + jwtSign: (userUUID: string) => Promise, + ): Promise { + password = hash(password); + + const safePhone = SMSUtils.safePhone(phone); + + await UserPhoneService.notExhaustiveAttack(safePhone); + await UserPhoneService.assertCodeCorrect(safePhone, code); + await UserPhoneService.clearTryRegisterCount(safePhone); + + const userUUIDByPhone = await this.userUUIDByPhone(phone); + if (userUUIDByPhone) { + this.logger.info("register phone already exist", { userPhone: { phone } }); + throw new FError(ErrorCode.SMSAlreadyExist); + } + + const userUUID = v4(); + const userName = safePhone.slice(-4); + + const createUser = userDAO.insert(this.DBTransaction, { + user_name: userName, + user_uuid: userUUID, + avatar_url: "", + user_password: password, + }); + + const createUserPhone = userPhoneDAO.insert(this.DBTransaction, { + user_name: userName, + user_uuid: userUUID, + phone_number: phone, + }); + + const setupGuidePPTX = setGuidePPTX(this.DBTransaction, userUUID); + + await Promise.all([createUser, createUserPhone, setupGuidePPTX]); + + const result: PhoneRegisterReturn = { + name: userName, + avatarURL: "", + userUUID, + token: await jwtSign(userUUID), + hasPhone: true, + hasPassword: true, + }; + + await UserPhoneService.clearVerificationCode(safePhone); + + return result; + } + + public async reset(phone: string, code: number, password: string): Promise { + password = hash(password); + + const safePhone = SMSUtils.safePhone(phone); + + await UserPhoneService.notExhaustiveAttack(safePhone); + await UserPhoneService.assertCodeCorrect(safePhone, code); + await UserPhoneService.clearTryRegisterCount(safePhone); + + const userUUIDByPhone = await this.userUUIDByPhone(phone); + if (!userUUIDByPhone) { + this.logger.info("reset phone not found", { userPhone: { phone } }); + throw new FError(ErrorCode.UserNotFound); + } + + await userDAO.update( + this.DBTransaction, + { user_password: password }, + { user_uuid: userUUIDByPhone }, + ); + + await UserPhoneService.clearVerificationCode(safePhone); + } + + public async login( + phone: string, + password: string, + jwtSign: (userUUID: string) => Promise, + ): Promise { + password = hash(password); + + const userUUIDByPhone = await this.userUUIDByPhone(phone); + if (!userUUIDByPhone) { + this.logger.info("login phone not found", { userPhone: { phone } }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + const user = await userDAO.findOne( + this.DBTransaction, + ["user_name", "avatar_url", "user_password"], + { user_uuid: userUUIDByPhone }, + ); + if (!user) { + this.logger.info("login phone not found user", { + userPhone: { phone, userUUIDByPhone }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + // User didn't set password, in this case we should not allow login with password + if (!user.user_password) { + this.logger.info("login phone user password is null", { + userPhone: { phone, userUUIDByPhone }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + if (user.user_password !== password) { + this.logger.info("login phone user password incorrect", { + userPhone: { phone, userUUIDByPhone }, + }); + throw new FError(ErrorCode.UserOrPasswordIncorrect); + } + + return { + name: user.user_name, + avatarURL: user.avatar_url, + userUUID: userUUIDByPhone, + token: await jwtSign(userUUIDByPhone), + hasPhone: true, + hasPassword: true, + }; + } + + private async userUUIDByPhone(phone: string): Promise { + const result = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { + phone_number: phone, + }); + + return result ? result.user_uuid : null; + } + + private static async canSend(safePhone: string): Promise { + const ttl = await RedisService.ttl(RedisKey.phoneRegisterOrReset(safePhone)); + + if (ttl < 0) { + return true; + } + + const elapsedTime = MessageExpirationSecond - ttl; + + return elapsedTime > MessageIntervalSecond; + } + + private static async assertCodeCorrect(safePhone: string, code: number): Promise { + if (Server.env === "dev") { + for (const user of PhoneSMS.testUsers) { + if (user.phone === safePhone && user.code === code) { + return; + } + } + } + + const value = await RedisService.get(RedisKey.phoneRegisterOrReset(safePhone)); + + if (String(code) !== value) { + throw new FError(ErrorCode.SMSVerificationCodeInvalid); + } + } + + private static readonly ExhaustiveAttackCount = 10; + + private static async notExhaustiveAttack(safePhone: string): Promise { + const key = RedisKey.phoneTryRegisterOrResetCount(safePhone); + const value = Number(await RedisService.get(key)) || 0; + + if (value > UserPhoneService.ExhaustiveAttackCount) { + throw new FError(ErrorCode.ExhaustiveAttack); + } + + const count = await RedisService.incr(key); + + if (count === 1) { + // must re-wait 10 minutes + await RedisService.expire(key, 60 * 10); + } + } + + private static async clearTryRegisterCount(safePhone: string): Promise { + await RedisService.del(RedisKey.phoneTryRegisterOrResetCount(safePhone)); + } + + private static async clearVerificationCode(safePhone: string): Promise { + await RedisService.del(RedisKey.phoneRegisterOrReset(safePhone)); + } +} + +export type PhoneRegisterReturn = { + name: string; + avatarURL: string; + userUUID: string; + token: string; + hasPhone: boolean; + hasPassword: boolean; +}; + +export type PhoneLoginReturn = PhoneRegisterReturn; diff --git a/src/v2/services/user/rebind-phone.ts b/src/v2/services/user/rebind-phone.ts new file mode 100644 index 00000000..f1273e12 --- /dev/null +++ b/src/v2/services/user/rebind-phone.ts @@ -0,0 +1,288 @@ +import { EntityManager } from "typeorm"; + +import RedisService from "../../../thirdPartyService/RedisService"; +import { alreadyJoinedRoomCount } from "../../../v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount"; + +import { LoginPlatform } from "../../../constants/Project"; +import { FError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { createLoggerService, parseError } from "../../../logger"; +import { UserAgoraModel } from "../../../model/user/Agora"; +import { UserAppleModel } from "../../../model/user/Apple"; +import { UserEmailModel } from "../../../model/user/Email"; +import { UserGithubModel } from "../../../model/user/Github"; +import { UserGoogleModel } from "../../../model/user/Google"; +import { UserPhoneModel } from "../../../model/user/Phone"; +import { UserModel } from "../../../model/user/User"; +import { UserWeChatModel } from "../../../model/user/WeChat"; +import { RedisKey } from "../../../utils/Redis"; +import { SMS, SMSUtils } from "../../../utils/SMS"; +import { + DAO, + userAgoraDAO, + userAppleDAO, + userDAO, + userEmailDAO, + userGithubDAO, + userGoogleDAO, + userPhoneDAO, + userSensitiveDAO, + userWeChatDAO, +} from "../../dao"; + +type UserPlatform = + | UserModel + | UserPhoneModel + | UserEmailModel + | UserWeChatModel + | UserGithubModel + | UserAppleModel + | UserAgoraModel + | UserGoogleModel; + +type RebindStatusKey = Exclude; +enum RebindStatusVal { + NotChanged = -1, + Success = 0, + Failed = 1, +} +type RebindStatus = Record; + +export const MessageIntervalSecond = 60; +export const MessageExpirationSecond = 60 * 10; + +export class UserRebindPhoneService { + private readonly logger = createLoggerService<"rebindPhone">({ + serviceName: "rebindPhone", + ids: this.ids, + }); + + constructor( + private readonly ids: IDS, + private readonly DBTransaction: EntityManager, + private readonly userUUID: string, + ) {} + + public async sendMessage(phone: string): Promise { + const sms = new SMS(phone); + + const safePhone = SMSUtils.safePhone(phone); + + if (await UserRebindPhoneService.canSend(safePhone)) { + if (await this.exists(userPhoneDAO, this.userUUID)) { + throw new FError(ErrorCode.SMSAlreadyExist); + } + + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + + await RedisService.set( + RedisKey.phoneBinding(safePhone), + sms.verificationCode, + MessageExpirationSecond, + ); + } else { + throw new FError(ErrorCode.ExhaustiveAttack); + } + } + + public async rebind( + phone: string, + code: number, + jwtSign: (userUUID: string) => Promise, + ): Promise { + const safePhone = SMSUtils.safePhone(phone); + await UserRebindPhoneService.notExhaustiveAttack(safePhone); + + const joinedRoomCount = await alreadyJoinedRoomCount(this.userUUID, this.DBTransaction); + if (joinedRoomCount > 0) { + this.logger.info("user has room", { rebindPhone: { userUUID: this.userUUID } }); + throw new FError(ErrorCode.UserRoomListNotEmpty); + } + + const exist = await this.exists(userDAO, this.userUUID); + if (!exist) { + this.logger.info("not found user", { rebindPhone: { userUUID: this.userUUID } }); + throw new FError(ErrorCode.UserNotFound); + } + + const existPhone = await this.exists(userPhoneDAO, this.userUUID); + if (existPhone) { + this.logger.info("user has phone", { rebindPhone: { userUUID: this.userUUID } }); + throw new FError(ErrorCode.SMSAlreadyBinding); + } + + const original = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { + phone_number: phone, + }); + if (!original) { + this.logger.info("not found user by phone", { rebindPhone: { phone, safePhone } }); + throw new FError(ErrorCode.UserNotFound); + } + + await UserRebindPhoneService.assertCodeCorrect(safePhone, code); + await UserRebindPhoneService.clearTryBindingCount(safePhone); + + // Move binding data from this.userUUID to original.user_uuid + const status: RebindStatus = { + Agora: RebindStatusVal.NotChanged, + Apple: RebindStatusVal.NotChanged, + Github: RebindStatusVal.NotChanged, + Google: RebindStatusVal.NotChanged, + WeChat: RebindStatusVal.NotChanged, + Email: RebindStatusVal.NotChanged, + }; + + await this.tryUpdate(userAgoraDAO, original.user_uuid, status, LoginPlatform.Agora); + await this.tryUpdate(userAppleDAO, original.user_uuid, status, LoginPlatform.Apple); + await this.tryUpdate(userGithubDAO, original.user_uuid, status, LoginPlatform.Github); + await this.tryUpdate(userGoogleDAO, original.user_uuid, status, LoginPlatform.Google); + await this.tryUpdate(userWeChatDAO, original.user_uuid, status, LoginPlatform.WeChat); + await this.tryUpdate(userEmailDAO, original.user_uuid, status, LoginPlatform.Email); + + // Delete account of this.userUUID + await Promise.all([ + userDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userAgoraDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userAppleDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userGithubDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userGoogleDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userPhoneDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userWeChatDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + userEmailDAO.deleteHard(this.DBTransaction, { user_uuid: this.userUUID }), + ]); + + await RedisService.set(RedisKey.userDelete(this.userUUID), "").catch(error => { + this.logger.warn("set userDelete failed", parseError(error)); + }); + + // Login with original.user_uuid + const result = await userDAO.findOne(this.DBTransaction, ["user_name", "avatar_url"], { + user_uuid: original.user_uuid, + }); + + await UserRebindPhoneService.clearVerificationCode(safePhone); + + if (result) { + return { + name: result.user_name, + avatar: result.avatar_url, + userUUID: original.user_uuid, + token: await jwtSign(original.user_uuid), + hasPhone: true, + hasPassword: await this.hasPassword(original.user_uuid), + rebind: status, + }; + } else { + this.logger.info("not found original user", { + rebindPhone: { userUUID: original.user_uuid }, + }); + throw new FError(ErrorCode.UserNotFound); + } + } + + private static async canSend(safePhone: string): Promise { + const ttl = await RedisService.ttl(RedisKey.phoneBinding(safePhone)); + + if (ttl < 0) { + return true; + } + + const elapsedTime = MessageExpirationSecond - ttl; + + return elapsedTime > MessageIntervalSecond; + } + + private async hasPassword(userUUID: string): Promise { + const user = await userDAO.findOne(this.DBTransaction, ["user_password"], { + user_uuid: userUUID, + }); + return Boolean(user?.user_password); + } + + private async tryUpdate( + dao: DAO, + user_uuid: string, + status: RebindStatus, + key: RebindStatusKey, + ) { + const [original, current] = await Promise.all([ + this.exists(dao, user_uuid), + this.exists(dao, this.userUUID), + ]); + // Do not update the existing value + if (original && current) { + status[key] = RebindStatusVal.Failed; + } + + // Move to original user + else if (current) { + await dao.update( + this.DBTransaction, + { user_uuid: user_uuid }, + { user_uuid: this.userUUID }, + ); + status[key] = RebindStatusVal.Success; + + // Record sensitive data, currently only WeChat is affected + if (key === LoginPlatform.WeChat) { + await userSensitiveDAO.update( + this.DBTransaction, + { user_uuid: user_uuid }, + { user_uuid: this.userUUID }, + ); + } + } + } + + private async exists(dao: DAO, user_uuid: string): Promise { + const result = await dao.findOne(this.DBTransaction, ["id"], { user_uuid }); + return result !== null; + } + + private static async assertCodeCorrect(safePhone: string, code: number): Promise { + const value = await RedisService.get(RedisKey.phoneBinding(safePhone)); + + if (String(code) !== value) { + throw new FError(ErrorCode.SMSVerificationCodeInvalid); + } + } + + private static readonly ExhaustiveAttackCount = 10; + + private static async notExhaustiveAttack(safePhone: string): Promise { + const key = RedisKey.phoneTryBindingCount(safePhone); + const value = Number(await RedisService.get(key)) || 0; + + if (value > UserRebindPhoneService.ExhaustiveAttackCount) { + throw new FError(ErrorCode.ExhaustiveAttack); + } + + const count = await RedisService.incr(key); + + if (count === 1) { + // must re-wait 10 minutes + await RedisService.expire(key, 60 * 10); + } + } + + private static async clearTryBindingCount(safePhone: string): Promise { + await RedisService.del(RedisKey.phoneTryBindingCount(safePhone)); + } + + private static async clearVerificationCode(safePhone: string): Promise { + await RedisService.del(RedisKey.phoneBinding(safePhone)); + } +} + +export type UserRebindReturn = { + name: string; + avatar: string; + token: string; + userUUID: string; + hasPhone: boolean; + hasPassword: boolean; + rebind: RebindStatus; +}; diff --git a/src/v2/services/user/update.ts b/src/v2/services/user/update.ts index 239061b5..da399e42 100644 --- a/src/v2/services/user/update.ts +++ b/src/v2/services/user/update.ts @@ -1,7 +1,10 @@ -import { createLoggerService } from "../../../logger"; import { EntityManager } from "typeorm"; -import { userDAO, userSensitiveDAO } from "../../dao"; +import { createLoggerService } from "../../../logger"; import { SensitiveType } from "../../../model/user/Constants"; +import { userDAO, userSensitiveDAO } from "../../dao"; +import { FError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; +import { hash } from "../../../utils/Hash"; export class UserUpdateService { private readonly logger = createLoggerService<"userUpdate">({ @@ -58,4 +61,25 @@ export class UserUpdateService { }, }); } + + public async password(oldPassword: string | null, newPassword: string): Promise { + newPassword = hash(newPassword); + + const user = await userDAO.findOne(this.DBTransaction, ["user_password"], { + user_uuid: this.userUUID, + }); + if (!user) { + throw new FError(ErrorCode.UserNotFound); + } + + if (user.user_password && (!oldPassword || hash(oldPassword) !== user.user_password)) { + throw new FError(ErrorCode.UserPasswordIncorrect); + } + + await userDAO.update( + this.DBTransaction, + { user_password: newPassword }, + { user_uuid: this.userUUID }, + ); + } } diff --git a/src/v2/services/user/utils.ts b/src/v2/services/user/utils.ts new file mode 100644 index 00000000..40e45162 --- /dev/null +++ b/src/v2/services/user/utils.ts @@ -0,0 +1,73 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; +import { AbstractLogin } from "../../../abstract/login"; +import { Region } from "../../../constants/Project"; +import { FileConvertStep, FileResourceType } from "../../../model/cloudStorage/Constants"; +import { + getFilePath, + getOSSFileURLPath, +} from "../../../v1/controller/cloudStorage/alibabaCloud/upload/Utils"; +import { getDisposition, ossClient } from "../../../v1/controller/cloudStorage/alibabaCloud/Utils"; +import { cloudStorageConfigsDAO, cloudStorageFilesDAO, cloudStorageUserFilesDAO } from "../../dao"; + +export async function setGuidePPTX(t: EntityManager, userUUID: string): Promise { + const [cnFileUUID, enFileUUID] = [v4(), v4()]; + const [cnName, enName] = ["开始使用 Flat.pptx", "Get Started with Flat.pptx"]; + const [cnPPTXPath, enPPTXPath] = [ + getFilePath(cnName, cnFileUUID), + getFilePath(enName, enFileUUID), + ]; + const [cnFileSize, enFileSize] = [5027927, 5141265]; + + await Promise.all([ + ossClient.copy(cnPPTXPath, AbstractLogin.guidePPTX, { + headers: { "Content-Disposition": getDisposition(cnName) }, + }), + ossClient.copy(enPPTXPath, AbstractLogin.guidePPTX, { + headers: { "Content-Disposition": getDisposition(enName) }, + }), + ]); + + await Promise.all([ + cloudStorageConfigsDAO.insert( + t, + { + user_uuid: userUUID, + total_usage: String(cnFileSize + enFileSize), + }, + { + orUpdate: ["total_usage"], + }, + ), + cloudStorageFilesDAO.insert(t, { + payload: { + region: Region.CN_HZ, + convertStep: FileConvertStep.None, + }, + fileURL: getOSSFileURLPath(cnPPTXPath), + fileSize: cnFileSize, + fileUUID: cnFileUUID, + fileName: cnName, + resourceType: FileResourceType.WhiteboardProjector, + }), + cloudStorageFilesDAO.insert(t, { + payload: { + region: Region.US_SV, + convertStep: FileConvertStep.None, + }, + fileURL: getOSSFileURLPath(enPPTXPath), + fileSize: enFileSize, + fileUUID: enFileUUID, + fileName: enName, + resourceType: FileResourceType.WhiteboardProjector, + }), + cloudStorageUserFilesDAO.insert(t, { + user_uuid: userUUID, + file_uuid: cnFileUUID, + }), + cloudStorageUserFilesDAO.insert(t, { + user_uuid: userUUID, + file_uuid: enFileUUID, + }), + ]); +} diff --git a/yarn.lock b/yarn.lock index a44881a6..a60e2c28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,17 @@ ini "^1.3.5" kitx "^2.0.0" +"@alicloud/dm20151123@^1.0.6": + version "1.0.6" + resolved "https://registry.npmjs.org/@alicloud/dm20151123/-/dm20151123-1.0.6.tgz#18d95e50c991e220f8bc896a935d4fbf4fbe200f" + integrity sha512-Ha1MaP8pdxEDfgavKfi4pRNILFrEgV+z5/QiBz71+g4uBqJH+a6LV7sUQrlldqNvn4Mzed1fpcgrEtczohLF+g== + dependencies: + "@alicloud/endpoint-util" "^0.0.1" + "@alicloud/openapi-client" "^0.4.4" + "@alicloud/openapi-util" "^0.3.1" + "@alicloud/tea-typescript" "^1.7.1" + "@alicloud/tea-util" "^1.4.7" + "@alicloud/dysmsapi20170525@^2.0.9": version "2.0.9" resolved "https://registry.yarnpkg.com/@alicloud/dysmsapi20170525/-/dysmsapi20170525-2.0.9.tgz#95fe77f0ca6469f881f58585caf49463763e2932" @@ -50,6 +61,18 @@ "@alicloud/tea-typescript" "^1.7.1" "@alicloud/tea-util" "^1.4.0" +"@alicloud/openapi-client@^0.4.4": + version "0.4.6" + resolved "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.6.tgz#6e354cf94cddb56322f97569714a31fbbb12cb34" + integrity sha512-cyhUQOJehLRslHy2l+lsginiyXdzfV7yF7b9EJcxzGG7zHAEX0XF3OJvfo13n7WgiqCzt9suQBatJz7b5F+14A== + dependencies: + "@alicloud/credentials" "^2" + "@alicloud/gateway-spi" "^0.0.8" + "@alicloud/openapi-util" "^0.3.1" + "@alicloud/tea-typescript" "^1.7.1" + "@alicloud/tea-util" "^1.4.5" + "@alicloud/tea-xml" "0.0.2" + "@alicloud/openapi-util@^0.2.7": version "0.2.8" resolved "https://registry.yarnpkg.com/@alicloud/openapi-util/-/openapi-util-0.2.8.tgz#b404cff825c1b00967e0b3929a48856e6a42bbd1" @@ -60,6 +83,24 @@ kitx "^2.1.0" sm3 "^1.0.3" +"@alicloud/openapi-util@^0.3.1": + version "0.3.1" + resolved "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.1.tgz#104f79d91e13a347115c8a416cc2c8f56580af5d" + integrity sha512-6mGT+hs+SXismZi/CEkjPhhbn2U3qTT/Qv/RXAYFA1DC3Jk4/YaX3N7RtpgdzOhdD7uI8XtNkaULKHZY3BrtxQ== + dependencies: + "@alicloud/tea-typescript" "^1.7.1" + "@alicloud/tea-util" "^1.3.0" + kitx "^2.1.0" + sm3 "^1.0.3" + +"@alicloud/tea-typescript@^1": + version "1.8.0" + resolved "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz#aa9b04b6ee53e1b22aa51e224a950ea5bcd966e9" + integrity sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ== + dependencies: + "@types/node" "^12.0.2" + httpx "^2.2.6" + "@alicloud/tea-typescript@^1.5.1", "@alicloud/tea-typescript@^1.5.3", "@alicloud/tea-typescript@^1.7.1": version "1.7.2" resolved "https://registry.yarnpkg.com/@alicloud/tea-typescript/-/tea-typescript-1.7.2.tgz#1f9baf1093ff9f865f2b6091c2ba166aafd106cc" @@ -76,6 +117,23 @@ "@alicloud/tea-typescript" "^1.5.1" kitx "^2.0.0" +"@alicloud/tea-util@^1.4.5", "@alicloud/tea-util@^1.4.7": + version "1.4.7" + resolved "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.7.tgz#40748613c3751f5373ffa8e5a0e892602ef3f78c" + integrity sha512-Lrpfk9kxihHsit3oMoeIMjk783AxjOvzMhLAbZcIzazKiVg3Zk/209XDe9r1lXqxII59j3V4rhC9X14y6WGYyg== + dependencies: + "@alicloud/tea-typescript" "^1.5.1" + kitx "^2.0.0" + +"@alicloud/tea-xml@0.0.2": + version "0.0.2" + resolved "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.2.tgz#7c97a38255d5e4f009c437facd3a2afc0ef17f45" + integrity sha512-Xs7v5y7YSNSDDYmiDWAC0/013VWPjS3dQU4KezSLva9VGiTVPaL3S7Nk4NrTmAYCG6MKcrRj/nGEDIWL5KRoPg== + dependencies: + "@alicloud/tea-typescript" "^1" + "@types/xml2js" "^0.4.5" + xml2js "^0.4.22" + "@babel/code-frame@^7.0.0": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" @@ -688,6 +746,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.12.tgz#0d4557fd3b94497d793efd4e7d92df2f83b4ef24" integrity sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A== +"@types/nodemailer@^6.4.9": + version "6.4.9" + resolved "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz#38e22cc2e62006170df0966fb8762fbf5cec6cbf" + integrity sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig== + dependencies: + "@types/node" "*" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -751,6 +816,13 @@ resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.0.tgz#8c0a9435dfa7b3b1be76562f3070efb3f92637b4" integrity sha512-Fx+NpfOO0CpeYX2g9bkvX8O5qh9wrU1sOF4g8sft4Mu7z+qfe387YlyY8w8daDyDsKY5vUxM0yxkAYnbkRbZEw== +"@types/xml2js@^0.4.5": + version "0.4.11" + resolved "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.11.tgz#bf46a84ecc12c41159a7bd9cf51ae84129af0e79" + integrity sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^5.30.0": version "5.30.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.0.tgz#524a11e15c09701733033c96943ecf33f55d9ca1" @@ -4401,6 +4473,11 @@ node-rsa@^1.1.1: dependencies: asn1 "^0.2.4" +nodemailer@^6.9.4: + version "6.9.4" + resolved "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz#93bd4a60eb0be6fa088a0483340551ebabfd2abf" + integrity sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA== + nofilter@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-3.1.0.tgz#c757ba68801d41ff930ba2ec55bab52ca184aa66" @@ -6345,7 +6422,7 @@ write-file-atomic@^4.0.1: imurmurhash "^0.1.4" signal-exit "^3.0.7" -xml2js@^0.4.16, xml2js@^0.4.23: +xml2js@^0.4.16, xml2js@^0.4.22, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==