From 9d9650d05026040515ab6c16bc8a8ad32878cecf Mon Sep 17 00:00:00 2001 From: aderan Date: Mon, 5 Jun 2023 14:09:01 +0800 Subject: [PATCH 01/21] chore(docker): add docker-compose-local --- docker/docker-compose-local.yml | 29 +++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 31 insertions(+) create mode 100644 docker/docker-compose-local.yml 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..a29c0a0e 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": { From 4f95654b4ee2fcce0017e79f196a80f67863f625 Mon Sep 17 00:00:00 2001 From: Vince Date: Mon, 3 Jul 2023 12:05:35 +0800 Subject: [PATCH 02/21] fix(record): keep record transcodingConfig limit to 1920*1080 (#733) --- .../controller/room/record/agora/Started.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/v1/controller/room/record/agora/Started.ts b/src/v1/controller/room/record/agora/Started.ts index 0c028f0a..2ad34b67 100644 --- a/src/v1/controller/room/record/agora/Started.ts +++ b/src/v1/controller/room/record/agora/Started.ts @@ -55,6 +55,28 @@ export class RecordAgoraStarted extends AbstractController 1920) { + transcodingConfig.width = 1920; + transcodingConfig.height = Math.floor((1920 / width) * height); + shouldUpdate = true; + } + if (height > 1080) { + transcodingConfig.height = 1080; + transcodingConfig.width = Math.floor((1080 / height) * width); + shouldUpdate = true; + } + if (shouldUpdate && agoraData.clientRequest.recordingConfig) { + agoraData.clientRequest.recordingConfig.transcodingConfig = transcodingConfig; + } + } + const roomInfo = await RoomDAO().findOne(["room_status"], { room_uuid: roomUUID, owner_uuid: userUUID, From bf2e3b4c8fd1f0461e636f3e64b36683e92ccc51 Mon Sep 17 00:00:00 2001 From: Vince Date: Mon, 3 Jul 2023 17:13:45 +0800 Subject: [PATCH 03/21] fix(record): update agora record transcodingConfig limitation (#734) --- .../controller/room/record/agora/Started.ts | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/v1/controller/room/record/agora/Started.ts b/src/v1/controller/room/record/agora/Started.ts index 2ad34b67..3585cd3c 100644 --- a/src/v1/controller/room/record/agora/Started.ts +++ b/src/v1/controller/room/record/agora/Started.ts @@ -55,25 +55,43 @@ export class RecordAgoraStarted extends AbstractController 1920) { - transcodingConfig.width = 1920; - transcodingConfig.height = Math.floor((1920 / width) * height); - shouldUpdate = true; - } - if (height > 1080) { - transcodingConfig.height = 1080; - transcodingConfig.width = Math.floor((1080 / height) * width); + const minLength = 96; + if (width > 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; + agoraData.clientRequest.recordingConfig.transcodingConfig = { + ...transcodingConfig, + width, + height, + }; } } From 4928867fded77dc6da9f734ac6f82facb1470c99 Mon Sep 17 00:00:00 2001 From: hyrious Date: Mon, 3 Jul 2023 18:57:20 +0800 Subject: [PATCH 04/21] chore(ci): remove US deployment (#735) --- .gitlab-ci.yml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 380cd8b2..9897d062 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,13 +23,6 @@ stages: HOST_NAME: $HOST_NAME_CHINA KUBE_CONFIG: $KUBE_CONFIG_CHINA -.us: - before_script: - - cp $PROJECT_CONFIG_UNITED_STATES ./helm/files/production.yaml - variables: - HOST_NAME: $HOST_NAME_UNITED_STATES - KUBE_CONFIG: $KUBE_CONFIG_UNITED_STATES - .docker_build: &DOCKER_BUILD stage: build_image image: docker:19.03.12 @@ -118,24 +111,9 @@ 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 variables: DOCKER_TAG: $CI_COMMIT_SHA <<: *PROD - -deploy_prod_us: - <<: *DEPLOY - extends: .us - variables: - DOCKER_TAG: $CI_COMMIT_SHA - <<: *PROD - From 43c9478b924cf48ecf79b4261a41a1787b04acd3 Mon Sep 17 00:00:00 2001 From: ooeyuna Date: Wed, 19 Jul 2023 10:20:20 +0800 Subject: [PATCH 05/21] feat: add region code (#736) * add region code for roomUUID and inviteCode * remove us ci, use CN as default region * fix test.yaml * roomUUID not only uuidv4 format * `region_code` use `1` as default --------- Co-authored-by: siyu --- config/test.yaml | 2 ++ src/constants/Config.ts | 3 +++ src/plugins/__tests__/ajv.test.ts | 17 +++++++++-------- src/utils/ParseConfig.ts | 2 ++ src/v1/controller/room/cancel/History.ts | 1 - src/v1/controller/room/cancel/Ordinary.ts | 1 - src/v1/controller/room/create/Ordinary.ts | 5 ++--- src/v1/controller/room/create/Utils.ts | 9 ++++++++- src/v1/controller/room/info/Ordinary.ts | 1 - src/v1/controller/room/info/Users.ts | 1 - src/v1/controller/room/record/Info.ts | 1 - src/v1/controller/room/record/Started.ts | 1 - src/v1/controller/room/record/Stopped.ts | 1 - src/v1/controller/room/updateStatus/Paused.ts | 1 - src/v1/controller/room/updateStatus/Started.ts | 1 - src/v1/controller/room/updateStatus/Stopped.ts | 1 - .../controller/room/utils/GenerateInviteCode.ts | 1 + .../cloud-storage/__tests__/convert.test.ts | 7 ++++--- 18 files changed, 31 insertions(+), 25 deletions(-) diff --git a/config/test.yaml b/config/test.yaml index caa75566..5d2e749c 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 diff --git a/src/constants/Config.ts b/src/constants/Config.ts index 7c58cd02..18548788 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 = { 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/utils/ParseConfig.ts b/src/utils/ParseConfig.ts index 6ec51ef5..509626e2 100644 --- a/src/utils/ParseConfig.ts +++ b/src/utils/ParseConfig.ts @@ -30,6 +30,8 @@ type Config = { server: { port: number; env: string; + region: string | null; + region_code: number | null; }; redis: { host: string; diff --git a/src/v1/controller/room/cancel/History.ts b/src/v1/controller/room/cancel/History.ts index 45bf19a9..7f0117f3 100644 --- a/src/v1/controller/room/cancel/History.ts +++ b/src/v1/controller/room/cancel/History.ts @@ -19,7 +19,6 @@ export class CancelHistory 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/Users.ts b/src/v1/controller/room/info/Users.ts index cdb35ed3..dd0d1c47 100644 --- a/src/v1/controller/room/info/Users.ts +++ b/src/v1/controller/room/info/Users.ts @@ -21,7 +21,6 @@ export class UserInfo extends AbstractController { properties: { roomUUID: { type: "string", - format: "uuid-v4", }, usersUUID: { type: "array", 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/updateStatus/Paused.ts b/src/v1/controller/room/updateStatus/Paused.ts index 4265c921..007989d0 100644 --- a/src/v1/controller/room/updateStatus/Paused.ts +++ b/src/v1/controller/room/updateStatus/Paused.ts @@ -20,7 +20,6 @@ export class UpdateStatusPaused extends AbstractController => { inviteCodeList.push(inviteCodeFn()); } + // insert region code at front return await RedisService.vacantKey(inviteCodeList); }; 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(); From df16feb0d61b8c4d9ad9c628b9a683b84a8ad68d Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 19 Jul 2023 10:21:36 +0800 Subject: [PATCH 06/21] fix(v1): rejoin a deleted room causes incorrect rtc uid (#737) Co-authored-by: xuyunshi <405029644@qq.com> --- .../__tests__/helpers/createUsersRequest.ts | 8 +- src/v1/controller/room/join/Ordinary.ts | 34 ++++---- src/v1/controller/room/join/Periodic.ts | 77 ++++++++++--------- .../__tests__/helpers/createCancelOrdinary.ts | 18 +++++ .../join/__tests__/helpers/createJoinRoom.ts | 18 +++++ .../room/join/__tests__/join.test.ts | 36 +++++++++ 6 files changed, 137 insertions(+), 54 deletions(-) create mode 100644 src/v1/controller/room/join/__tests__/helpers/createCancelOrdinary.ts create mode 100644 src/v1/controller/room/join/__tests__/helpers/createJoinRoom.ts create mode 100644 src/v1/controller/room/join/__tests__/join.test.ts 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); +}); From 3c12f4311198e2d3b33a27a40a7f1558c1136189 Mon Sep 17 00:00:00 2001 From: ooeyuna Date: Wed, 19 Jul 2023 15:17:58 +0800 Subject: [PATCH 07/21] (fix): cancel roomUUID uuid-v4 format limit (#738) * fix: cancel roomUUID uuid-v4 format limit * cancel room export users api uuid limit --------- Co-authored-by: siyu --- src/v1/controller/room/cancel/PeriodicSubRoom.ts | 1 - src/v1/controller/room/info/PeriodicSubRoom.ts | 1 - src/v1/controller/room/record/agora/Acquire.ts | 1 - src/v1/controller/room/record/agora/Query.ts | 1 - src/v1/controller/room/record/agora/Started.ts | 1 - src/v1/controller/room/record/agora/Stopped.ts | 1 - src/v1/controller/room/record/agora/UpdateLayout.ts | 1 - src/v1/controller/room/update/Ordinary.ts | 1 - src/v1/controller/room/update/PeriodicSubRoom.ts | 1 - src/v2/controllers/room/export-users/index.ts | 4 +--- 10 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/v1/controller/room/cancel/PeriodicSubRoom.ts b/src/v1/controller/room/cancel/PeriodicSubRoom.ts index 2297541a..f620984e 100644 --- a/src/v1/controller/room/cancel/PeriodicSubRoom.ts +++ b/src/v1/controller/room/cancel/PeriodicSubRoom.ts @@ -29,7 +29,6 @@ export class CancelPeriodicSubRoom extends AbstractController Date: Tue, 25 Jul 2023 18:48:43 +0800 Subject: [PATCH 08/21] fix invite code check (#739) Co-authored-by: siyu --- src/v1/controller/room/join/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } } From 2b00cd9bcf980eca91820ad6f602de297bff4f31 Mon Sep 17 00:00:00 2001 From: hyrious Date: Tue, 25 Jul 2023 20:19:17 +0800 Subject: [PATCH 09/21] refactor(room): set invite code expiration to 100 days (#740) --- src/v1/controller/room/create/Utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v1/controller/room/create/Utils.ts b/src/v1/controller/room/create/Utils.ts index f7aeceb7..ea293381 100644 --- a/src/v1/controller/room/create/Utils.ts +++ b/src/v1/controller/room/create/Utils.ts @@ -20,7 +20,7 @@ export const generateRoomInviteCode = async ( } if (inviteCode !== roomUUID) { - const fiftyDays = 60 * 60 * 24 * 50; + const fiftyDays = 60 * 60 * 24 * 100; await RedisService.client .multi() From aa307d4e77d65636cb8231ff5d975ff4c059b6c3 Mon Sep 17 00:00:00 2001 From: ooeyuna Date: Fri, 28 Jul 2023 09:52:59 +0800 Subject: [PATCH 10/21] feat: add region configs api (#741) Co-authored-by: siyu Co-authored-by: hyrious --- .node-version | 1 + .../configs/__tests__/fetchRegionConfig.ts | 35 ++++++++ src/v2/controllers/configs/regionConfigs.ts | 80 +++++++++++++++++++ src/v2/controllers/routes.ts | 2 + 4 files changed, 118 insertions(+) create mode 100644 .node-version create mode 100644 src/v2/controllers/configs/__tests__/fetchRegionConfig.ts create mode 100644 src/v2/controllers/configs/regionConfigs.ts 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/src/v2/controllers/configs/__tests__/fetchRegionConfig.ts b/src/v2/controllers/configs/__tests__/fetchRegionConfig.ts new file mode 100644 index 00000000..ea48b4ec --- /dev/null +++ b/src/v2/controllers/configs/__tests__/fetchRegionConfig.ts @@ -0,0 +1,35 @@ +import test from "ava"; +import { HelperAPI } from "../../../__tests__/helpers/api"; +import { regionConfigs, regionConfigsRouters } from "../regionConfigs"; +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/regionConfigs.ts b/src/v2/controllers/configs/regionConfigs.ts new file mode 100644 index 00000000..e1ce1ca1 --- /dev/null +++ b/src/v2/controllers/configs/regionConfigs.ts @@ -0,0 +1,80 @@ +import { ResponseSuccess } from "../../../types/Server"; + +import { Server } from "../../../utils/registryRoutersV2"; +import { Type } from "@sinclair/typebox"; +import { successJSON } from "../internal/utils/response-json"; + +import { + Server as ServerConfig, WeChat, Github, Google, Apple, AgoraLogin, + PhoneSMS, Whiteboard, Agora, CloudStorage +} from "../../../constants/Config"; + +type regionConfigsResponseSchema = { + 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: { + convertRegion: string; + }, + agora: { + screenshot: boolean; + messageNotification: boolean; + }, + cloudStorage: { + singleFileSize: number; + totleSize: number; + allowFileSuffix: Array; + }; +}; + +// export for unit test +export const regionConfigs = async (): Promise> => { + return successJSON({ + 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: { + convertRegion: Whiteboard.convertRegion + }, + agora: { + screenshot: Agora.screenshot.enable, + messageNotification: Agora.messageNotification.enable, + }, + cloudStorage: { + singleFileSize: CloudStorage.singleFileSize, + totleSize: CloudStorage.totalSize, + allowFileSuffix: CloudStorage.allowFileSuffix, + } + }); +}; + +export const regionConfigsRouters = (server: Server): void => { + server.get("region/configs", regionConfigs, { + schema: Type.Object({}), + auth: false, + }); +}; diff --git a/src/v2/controllers/routes.ts b/src/v2/controllers/routes.ts index c4dd747a..97079c18 100644 --- a/src/v2/controllers/routes.ts +++ b/src/v2/controllers/routes.ts @@ -5,6 +5,7 @@ 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/regionConfigs"; export const v2Routes = [ userRouters, @@ -14,4 +15,5 @@ export const v2Routes = [ roomRouters, oauthRouters, tempPhotoRouters, + regionConfigsRouters, ]; From 644f70d172c7324984dc49f7ea263e71639948de Mon Sep 17 00:00:00 2001 From: hyrious Date: Thu, 3 Aug 2023 11:15:54 +0800 Subject: [PATCH 11/21] feat(v2): add user/rebind-phone{/send-message} (#742) * feat(v2): add user/rebind-phone{/send-message} * use safe phone in checking can send code * update filename * add unit test --- .../utils/AlreadyJoinedRoomCount.ts | 8 +- src/v2/__tests__/helpers/db/index.ts | 2 + src/v2/__tests__/helpers/db/user-phone.ts | 2 +- src/v2/__tests__/helpers/db/user-wechat.ts | 38 +++ src/v2/controllers/user/rebind-phone/index.ts | 32 +++ .../user/rebind-phone/send-message.ts | 25 ++ src/v2/controllers/user/routes.ts | 13 + src/v2/dao/index.ts | 2 +- .../user/__tests__/rebind-phone.test.ts | 160 +++++++++++ src/v2/services/user/rebind-phone.ts | 269 ++++++++++++++++++ src/v2/services/user/update.ts | 4 +- 11 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 src/v2/__tests__/helpers/db/user-wechat.ts create mode 100644 src/v2/controllers/user/rebind-phone/index.ts create mode 100644 src/v2/controllers/user/rebind-phone/send-message.ts create mode 100644 src/v2/services/user/__tests__/rebind-phone.test.ts create mode 100644 src/v2/services/user/rebind-phone.ts 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/v2/__tests__/helpers/db/index.ts b/src/v2/__tests__/helpers/db/index.ts index 9fa79bf7..9a11d965 100644 --- a/src/v2/__tests__/helpers/db/index.ts +++ b/src/v2/__tests__/helpers/db/index.ts @@ -10,6 +10,7 @@ import { CreateSecretsInfos } from "./oauth-secret"; import { CreateRoomJoin } from "./room-join"; import { CreateRoom } from "./room"; import { CreateUserPhone } from "./user-phone"; +import { CreateUserWeChat } from "./user-wechat"; export const testService = (t: EntityManager) => { return { @@ -24,5 +25,6 @@ export const testService = (t: EntityManager) => { createSecretsInfos: new CreateSecretsInfos(t), createUser: new CreateUser(t), createUserPhone: new CreateUserPhone(t), + createUserWeChat: new CreateUserWeChat(t), }; }; 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/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..da8d5e77 100644 --- a/src/v2/controllers/user/routes.ts +++ b/src/v2/controllers/user/routes.ts @@ -3,6 +3,11 @@ 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"; export const userRouters = (server: Server): void => { server.post("user/rename", userRename, { @@ -20,4 +25,12 @@ 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, + }); }; diff --git a/src/v2/dao/index.ts b/src/v2/dao/index.ts index 48f85df1..1b5306ad 100644 --- a/src/v2/dao/index.ts +++ b/src/v2/dao/index.ts @@ -25,7 +25,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( 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/rebind-phone.ts b/src/v2/services/user/rebind-phone.ts new file mode 100644 index 00000000..31ad0236 --- /dev/null +++ b/src/v2/services/user/rebind-phone.ts @@ -0,0 +1,269 @@ +import { EntityManager } from "typeorm"; + +import RedisService from "../../../thirdPartyService/RedisService"; +import { alreadyJoinedRoomCount } from "../../../v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount"; + +import { ErrorCode } from "../../../ErrorCode"; +import { LoginPlatform } from "../../../constants/Project"; +import { FError } from "../../../error/ControllerError"; +import { createLoggerService, parseError } from "../../../logger"; +import { UserAgoraModel } from "../../../model/user/Agora"; +import { UserAppleModel } from "../../../model/user/Apple"; +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, + userGithubDAO, + userGoogleDAO, + userPhoneDAO, + userSensitiveDAO, + userWeChatDAO, +} from "../../dao"; + +type UserPlatform = + | UserModel + | UserPhoneModel + | 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); + } + + await sms.send(); + 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: safePhone, + }); + 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, + }; + + 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); + + // 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 }), + ]); + + 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, + 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 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; + rebind: RebindStatus; +}; diff --git a/src/v2/services/user/update.ts b/src/v2/services/user/update.ts index 239061b5..ad011ad3 100644 --- a/src/v2/services/user/update.ts +++ b/src/v2/services/user/update.ts @@ -1,7 +1,7 @@ -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"; export class UserUpdateService { private readonly logger = createLoggerService<"userUpdate">({ From 8d27bd7b6538b738c586b91f69a8c9a719176d68 Mon Sep 17 00:00:00 2001 From: hyrious Date: Thu, 3 Aug 2023 11:20:14 +0800 Subject: [PATCH 12/21] feat(v2): add register/phone{/send-message} (#743) feat(v2): add login/phone feat(v2): add user/password feat(v2): add reset/phone{/send-password} add unit test move mysql.salt to login.salt --- config/defaults.yaml | 1 + config/test.yaml | 1 + src/ErrorCode.ts | 1 + src/abstract/login/index.ts | 2 +- src/constants/Config.ts | 2 + src/plugins/Ajv.ts | 8 + src/utils/Hash.ts | 6 + src/utils/ParseConfig.ts | 1 + src/utils/Redis.ts | 13 + src/v2/__tests__/helpers/db/user.ts | 23 +- src/v2/constants.ts | 2 + src/v2/controllers/login/phone/index.ts | 39 ++ src/v2/controllers/login/routes.ts | 9 + src/v2/controllers/register/phone/index.ts | 45 +++ .../register/phone/send-message.ts | 25 ++ src/v2/controllers/register/routes.ts | 15 + src/v2/controllers/reset/phone/index.ts | 31 ++ .../controllers/reset/phone/send-message.ts | 25 ++ src/v2/controllers/reset/routes.ts | 15 + src/v2/controllers/routes.ts | 6 + src/v2/controllers/user/password/index.ts | 36 ++ src/v2/controllers/user/routes.ts | 5 + src/v2/services/user/__tests__/phone.test.ts | 214 +++++++++++ src/v2/services/user/phone.ts | 345 ++++++++++++++++++ src/v2/services/user/update.ts | 24 ++ 25 files changed, 885 insertions(+), 9 deletions(-) create mode 100644 src/utils/Hash.ts create mode 100644 src/v2/constants.ts create mode 100644 src/v2/controllers/login/phone/index.ts create mode 100644 src/v2/controllers/login/routes.ts create mode 100644 src/v2/controllers/register/phone/index.ts create mode 100644 src/v2/controllers/register/phone/send-message.ts create mode 100644 src/v2/controllers/register/routes.ts create mode 100644 src/v2/controllers/reset/phone/index.ts create mode 100644 src/v2/controllers/reset/phone/send-message.ts create mode 100644 src/v2/controllers/reset/routes.ts create mode 100644 src/v2/controllers/user/password/index.ts create mode 100644 src/v2/services/user/__tests__/phone.test.ts create mode 100644 src/v2/services/user/phone.ts diff --git a/config/defaults.yaml b/config/defaults.yaml index a1057b80..161fa49e 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -79,6 +79,7 @@ oauth: - jpeg login: + salt: wechat: web: enable: false diff --git a/config/test.yaml b/config/test.yaml index 5d2e749c..55899880 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -74,6 +74,7 @@ oauth: - jpeg login: + salt: test wechat: web: enable: true diff --git a/src/ErrorCode.ts b/src/ErrorCode.ts index 4ed07eed..922234cf 100644 --- a/src/ErrorCode.ts +++ b/src/ErrorCode.ts @@ -25,6 +25,7 @@ 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 RecordNotFound = 500000, // record info not found diff --git a/src/abstract/login/index.ts b/src/abstract/login/index.ts index 69617b1e..0ac362ed 100644 --- a/src/abstract/login/index.ts +++ b/src/abstract/login/index.ts @@ -108,7 +108,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 18548788..19c040b0 100644 --- a/src/constants/Config.ts +++ b/src/constants/Config.ts @@ -31,6 +31,8 @@ export const MySQL = { db: config.mysql.db, }; +export const Salt = config.login.salt; + export const Website = config.website; export const WeChat = { diff --git a/src/plugins/Ajv.ts b/src/plugins/Ajv.ts index 755d4439..49ac44e3 100644 --- a/src/plugins/Ajv.ts +++ b/src/plugins/Ajv.ts @@ -101,6 +101,13 @@ 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); + }, +}; + export const ajvSelfPlugin = (ajv: Ajv): void => { ajv.addFormat("unix-timestamp", unixTimestamp); ajv.addFormat("uuid-v4", uuidV4); @@ -113,6 +120,7 @@ export const ajvSelfPlugin = (ajv: Ajv): void => { ajv.addFormat("phone", phone); ajv.addFormat("directory-name", directoryName); ajv.addFormat("directory-path", directoryPath); + ajv.addFormat("password", password); }; export const validateDirectoryName = (str: string): void => { 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 509626e2..3b96556c 100644 --- a/src/utils/ParseConfig.ts +++ b/src/utils/ParseConfig.ts @@ -89,6 +89,7 @@ type Config = { }; }; login: { + salt: string; wechat: { web: { enable: boolean; diff --git a/src/utils/Redis.ts b/src/utils/Redis.ts index 4c1e2fe9..a09e2982 100644 --- a/src/utils/Redis.ts +++ b/src/utils/Redis.ts @@ -2,26 +2,36 @@ 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}`, + 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 +46,7 @@ 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}`, }; 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/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..41a499f4 --- /dev/null +++ b/src/v2/controllers/login/routes.ts @@ -0,0 +1,9 @@ +import { Server } from "../../../utils/registryRoutersV2"; +import { loginPhone, loginPhoneSchema } from "./phone"; + +export const loginRouters = (server: Server): void => { + server.post("login/phone", loginPhone, { + schema: loginPhoneSchema, + auth: false, + }); +}; 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..a6b3d61d --- /dev/null +++ b/src/v2/controllers/register/routes.ts @@ -0,0 +1,15 @@ +import { Server } from "../../../utils/registryRoutersV2"; +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, + }); +}; 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..339d5242 --- /dev/null +++ b/src/v2/controllers/reset/routes.ts @@ -0,0 +1,15 @@ +import { Server } from "../../../utils/registryRoutersV2"; +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, + }); +}; diff --git a/src/v2/controllers/routes.ts b/src/v2/controllers/routes.ts index 97079c18..a0767593 100644 --- a/src/v2/controllers/routes.ts +++ b/src/v2/controllers/routes.ts @@ -6,6 +6,9 @@ import { roomRouters } from "./room/routes"; import { oauthRouters } from "./auth2/routes"; import { tempPhotoRouters } from "./temp-photo/routes"; import { regionConfigsRouters } from "./configs/regionConfigs"; +import { registerRouters } from "./register/routes"; +import { loginRouters } from "./login/routes"; +import { resetRouters } from "./reset/routes"; export const v2Routes = [ userRouters, @@ -16,4 +19,7 @@ export const v2Routes = [ 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/routes.ts b/src/v2/controllers/user/routes.ts index da8d5e77..fa3358c2 100644 --- a/src/v2/controllers/user/routes.ts +++ b/src/v2/controllers/user/routes.ts @@ -8,6 +8,7 @@ import { 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, { @@ -33,4 +34,8 @@ export const userRouters = (server: Server): void => { server.post("user/rebind-phone", userRebindPhone, { schema: userRebindPhoneSchema, }); + + server.post("user/password", userPassword, { + schema: userPasswordSchema, + }); }; 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..7bd06713 --- /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.UserNotFound}`, + }, + ); + + 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.UserNotFound}`, + }, + ); + + 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.UserPasswordIncorrect}`, + }, + ); + + 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/phone.ts b/src/v2/services/user/phone.ts new file mode 100644 index 00000000..704c7002 --- /dev/null +++ b/src/v2/services/user/phone.ts @@ -0,0 +1,345 @@ +import { EntityManager } from "typeorm"; +import { v4 } from "uuid"; + +import RedisService from "../../../thirdPartyService/RedisService"; +import { getDisposition, ossClient } from "../../../v1/controller/cloudStorage/alibabaCloud/Utils"; +import { + getFilePath, + getOSSFileURLPath, +} from "../../../v1/controller/cloudStorage/alibabaCloud/upload/Utils"; + +import { ErrorCode } from "../../../ErrorCode"; +import { AbstractLogin } from "../../../abstract/login"; +import { PhoneSMS, Server } from "../../../constants/Config"; +import { Region } from "../../../constants/Project"; +import { FError } from "../../../error/ControllerError"; +import { createLoggerService } from "../../../logger"; +import { FileConvertStep, FileResourceType } from "../../../model/cloudStorage/Constants"; +import { hash } from "../../../utils/Hash"; +import { RedisKey } from "../../../utils/Redis"; +import { SMS, SMSUtils } from "../../../utils/SMS"; +import { MessageExpirationSecond, MessageIntervalSecond } from "../../constants"; +import { + cloudStorageConfigsDAO, + cloudStorageFilesDAO, + cloudStorageUserFilesDAO, + userDAO, + userPhoneDAO, +} from "../../dao"; + +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: safePhone, + }); + if (exist) { + throw new FError(ErrorCode.SMSAlreadyExist); + } + + await sms.send(); + 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: safePhone, + }); + if (!user) { + throw new FError(ErrorCode.UserNotFound); + } + + await sms.send(); + 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(safePhone); + 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: safePhone, + }); + + const setupGuidePPTX = this.setGuidePPTX(userUUID); + + await Promise.all([createUser, createUserPhone, setupGuidePPTX]); + + const result = { + name: userName, + avatarURL: "", + userUUID, + token: await jwtSign(userUUID), + hasPhone: 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(safePhone); + if (!userUUIDByPhone) { + 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 safePhone = SMSUtils.safePhone(phone); + + const userUUIDByPhone = await this.userUUIDByPhone(safePhone); + if (!userUUIDByPhone) { + throw new FError(ErrorCode.UserNotFound); + } + + 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.UserNotFound); + } + + // 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.UserNotFound); + } + + if (user.user_password !== password) { + this.logger.info("login phone user password incorrect", { + userPhone: { phone, userUUIDByPhone }, + }); + throw new FError(ErrorCode.UserPasswordIncorrect); + } + + return { + name: user.user_name, + avatarURL: user.avatar_url, + userUUID: userUUIDByPhone, + token: await jwtSign(userUUIDByPhone), + hasPhone: true, + }; + } + + private async setGuidePPTX(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( + this.DBTransaction, + { + user_uuid: userUUID, + total_usage: String(cnFileSize + enFileSize), + }, + { + orUpdate: ["total_usage"], + }, + ), + cloudStorageFilesDAO.insert(this.DBTransaction, { + payload: { + region: Region.CN_HZ, + convertStep: FileConvertStep.None, + }, + fileURL: getOSSFileURLPath(cnPPTXPath), + fileSize: cnFileSize, + fileUUID: cnFileUUID, + fileName: cnName, + resourceType: FileResourceType.WhiteboardProjector, + }), + cloudStorageFilesDAO.insert(this.DBTransaction, { + payload: { + region: Region.US_SV, + convertStep: FileConvertStep.None, + }, + fileURL: getOSSFileURLPath(enPPTXPath), + fileSize: enFileSize, + fileUUID: enFileUUID, + fileName: enName, + resourceType: FileResourceType.WhiteboardProjector, + }), + cloudStorageUserFilesDAO.insert(this.DBTransaction, { + user_uuid: userUUID, + file_uuid: cnFileUUID, + }), + cloudStorageUserFilesDAO.insert(this.DBTransaction, { + user_uuid: userUUID, + file_uuid: enFileUUID, + }), + ]); + } + + private async userUUIDByPhone(safePhone: string): Promise { + const result = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { + phone_number: safePhone, + }); + + 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; +}; + +export type PhoneLoginReturn = PhoneRegisterReturn; diff --git a/src/v2/services/user/update.ts b/src/v2/services/user/update.ts index ad011ad3..da399e42 100644 --- a/src/v2/services/user/update.ts +++ b/src/v2/services/user/update.ts @@ -2,6 +2,9 @@ import { EntityManager } from "typeorm"; 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 }, + ); + } } From 8f6fef7615da1d9e310c3791397fab171a6818b8 Mon Sep 17 00:00:00 2001 From: hyrious Date: Thu, 3 Aug 2023 15:11:11 +0800 Subject: [PATCH 13/21] fix: should use full phone string in database (#746) --- src/v2/services/user/phone.ts | 18 ++++++++---------- src/v2/services/user/rebind-phone.ts | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/v2/services/user/phone.ts b/src/v2/services/user/phone.ts index 704c7002..87d5d65b 100644 --- a/src/v2/services/user/phone.ts +++ b/src/v2/services/user/phone.ts @@ -42,7 +42,7 @@ export class UserPhoneService { if (await UserPhoneService.canSend(safePhone)) { const exist = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { - phone_number: safePhone, + phone_number: phone, }); if (exist) { throw new FError(ErrorCode.SMSAlreadyExist); @@ -66,7 +66,7 @@ export class UserPhoneService { if (await UserPhoneService.canSend(safePhone)) { const user = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { - phone_number: safePhone, + phone_number: phone, }); if (!user) { throw new FError(ErrorCode.UserNotFound); @@ -97,7 +97,7 @@ export class UserPhoneService { await UserPhoneService.assertCodeCorrect(safePhone, code); await UserPhoneService.clearTryRegisterCount(safePhone); - const userUUIDByPhone = await this.userUUIDByPhone(safePhone); + const userUUIDByPhone = await this.userUUIDByPhone(phone); if (userUUIDByPhone) { this.logger.info("register phone already exist", { userPhone: { phone } }); throw new FError(ErrorCode.SMSAlreadyExist); @@ -116,7 +116,7 @@ export class UserPhoneService { const createUserPhone = userPhoneDAO.insert(this.DBTransaction, { user_name: userName, user_uuid: userUUID, - phone_number: safePhone, + phone_number: phone, }); const setupGuidePPTX = this.setGuidePPTX(userUUID); @@ -145,7 +145,7 @@ export class UserPhoneService { await UserPhoneService.assertCodeCorrect(safePhone, code); await UserPhoneService.clearTryRegisterCount(safePhone); - const userUUIDByPhone = await this.userUUIDByPhone(safePhone); + const userUUIDByPhone = await this.userUUIDByPhone(phone); if (!userUUIDByPhone) { throw new FError(ErrorCode.UserNotFound); } @@ -166,9 +166,7 @@ export class UserPhoneService { ): Promise { password = hash(password); - const safePhone = SMSUtils.safePhone(phone); - - const userUUIDByPhone = await this.userUUIDByPhone(safePhone); + const userUUIDByPhone = await this.userUUIDByPhone(phone); if (!userUUIDByPhone) { throw new FError(ErrorCode.UserNotFound); } @@ -271,9 +269,9 @@ export class UserPhoneService { ]); } - private async userUUIDByPhone(safePhone: string): Promise { + private async userUUIDByPhone(phone: string): Promise { const result = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { - phone_number: safePhone, + phone_number: phone, }); return result ? result.user_uuid : null; diff --git a/src/v2/services/user/rebind-phone.ts b/src/v2/services/user/rebind-phone.ts index 31ad0236..580e0f88 100644 --- a/src/v2/services/user/rebind-phone.ts +++ b/src/v2/services/user/rebind-phone.ts @@ -108,7 +108,7 @@ export class UserRebindPhoneService { } const original = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { - phone_number: safePhone, + phone_number: phone, }); if (!original) { this.logger.info("not found user by phone", { rebindPhone: { phone, safePhone } }); From 7a7bb7853523e666a2c216662cd2085a81b86ab1 Mon Sep 17 00:00:00 2001 From: flat-bot <81130480+flat-bot@users.noreply.github.com> Date: Thu, 3 Aug 2023 16:24:46 +0800 Subject: [PATCH 14/21] chore: deploy sg via ci (for dev) (#747) * add sg ci * remove unuse step # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # Date: Thu Aug 3 16:22:13 2023 +0800 # # On branch sa/add_sg_ci # Changes to be committed: # modified: .gitlab-ci.yml # --------- Co-authored-by: siyu --- .gitlab-ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9897d062..3585884d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -23,6 +23,13 @@ stages: HOST_NAME: $HOST_NAME_CHINA KUBE_CONFIG: $KUBE_CONFIG_CHINA +.sg: + before_script: + - cp $PROJECT_CONFIG_SG ./helm/files/production.yaml + variables: + HOST_NAME: $HOST_NAME_SG + KUBE_CONFIG: $KUBE_CONFIG_SG + .docker_build: &DOCKER_BUILD stage: build_image image: docker:19.03.12 @@ -117,3 +124,10 @@ deploy_prod_cn: variables: DOCKER_TAG: $CI_COMMIT_SHA <<: *PROD + +deploy_prod_sg: + <<: *DEPLOY + extends: .sg + variables: + DOCKER_TAG: $CI_COMMIT_SHA + <<: *DEV From 76cf885ec4d59d5a85187be8ce459d5c733710d8 Mon Sep 17 00:00:00 2001 From: hyrious Date: Thu, 3 Aug 2023 18:11:21 +0800 Subject: [PATCH 15/21] feat: add email support (#745) * feat(v2): add email support feat: add SMTP support * add ajv email, return email send failed, email length = 100 * return phone send failed --- config/defaults.yaml | 17 + config/test.yaml | 17 + package.json | 3 + src/ErrorCode.ts | 6 + src/constants/Config.ts | 25 ++ src/constants/Project.ts | 1 + src/dao/index.ts | 3 + src/logger/LogConext.ts | 13 + src/logger/index.ts | 14 + src/model/index.ts | 2 + src/model/user/Constants.ts | 1 + src/model/user/Email.ts | 30 ++ src/plugins/Ajv.ts | 11 + src/thirdPartyService/TypeORMService.ts | 2 + src/utils/Email.ts | 175 ++++++++++ src/utils/ParseConfig.ts | 22 ++ src/utils/Redis.ts | 6 + src/utils/SMS.ts | 6 +- src/v1/controller/login/Login.ts | 3 + src/v1/controller/login/phone/SendMessage.ts | 8 +- src/v1/controller/user/binding/List.ts | 2 + src/v1/controller/user/binding/Remove.ts | 3 + .../user/binding/platform/email/Binding.ts | 132 ++++++++ .../user/binding/platform/email/Constants.ts | 2 + .../binding/platform/email/SendMessage.ts | 99 ++++++ .../binding/platform/phone/SendMessage.ts | 6 +- src/v1/controller/user/deleteAccount/index.ts | 3 + src/v1/service/user/UserEmail.ts | 81 +++++ src/v1/service/user/UserSensitive.ts | 17 + src/v2/__tests__/helpers/db/index.ts | 2 + src/v2/__tests__/helpers/db/user-email.ts | 24 ++ src/v2/controllers/login/email/index.ts | 41 +++ src/v2/controllers/login/routes.ts | 6 + src/v2/controllers/register/email/index.ts | 47 +++ .../register/email/send-message.ts | 28 ++ src/v2/controllers/register/routes.ts | 12 + src/v2/controllers/reset/email/index.ts | 33 ++ .../controllers/reset/email/send-message.ts | 28 ++ src/v2/controllers/reset/routes.ts | 12 + src/v2/dao/index.ts | 2 + src/v2/services/user/__tests__/email.test.ts | 209 ++++++++++++ src/v2/services/user/email.ts | 313 ++++++++++++++++++ src/v2/services/user/phone.ts | 98 +----- src/v2/services/user/rebind-phone.ts | 14 +- src/v2/services/user/utils.ts | 73 ++++ yarn.lock | 79 ++++- 46 files changed, 1642 insertions(+), 89 deletions(-) create mode 100644 src/model/user/Email.ts create mode 100644 src/utils/Email.ts create mode 100644 src/v1/controller/user/binding/platform/email/Binding.ts create mode 100644 src/v1/controller/user/binding/platform/email/Constants.ts create mode 100644 src/v1/controller/user/binding/platform/email/SendMessage.ts create mode 100644 src/v1/service/user/UserEmail.ts create mode 100644 src/v2/__tests__/helpers/db/user-email.ts create mode 100644 src/v2/controllers/login/email/index.ts create mode 100644 src/v2/controllers/register/email/index.ts create mode 100644 src/v2/controllers/register/email/send-message.ts create mode 100644 src/v2/controllers/reset/email/index.ts create mode 100644 src/v2/controllers/reset/email/send-message.ts create mode 100644 src/v2/services/user/__tests__/email.test.ts create mode 100644 src/v2/services/user/email.ts create mode 100644 src/v2/services/user/utils.ts diff --git a/config/defaults.yaml b/config/defaults.yaml index 161fa49e..0e3e7685 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -126,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: diff --git a/config/test.yaml b/config/test.yaml index 55899880..fac912fa 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -121,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: diff --git a/package.json b/package.json index a29c0a0e..9d6f844c 100644 --- a/package.json +++ b/package.json @@ -25,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", @@ -60,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", @@ -91,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 922234cf..5ad307a9 100644 --- a/src/ErrorCode.ts +++ b/src/ErrorCode.ts @@ -51,6 +51,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/constants/Config.ts b/src/constants/Config.ts index 19c040b0..2252c6ac 100644 --- a/src/constants/Config.ts +++ b/src/constants/Config.ts @@ -100,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, 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 49ac44e3..0f4f947c 100644 --- a/src/plugins/Ajv.ts +++ b/src/plugins/Ajv.ts @@ -108,6 +108,16 @@ const password: FormatDefinition = { }, }; +// 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); @@ -121,6 +131,7 @@ export const ajvSelfPlugin = (ajv: Ajv): void => { 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/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/ParseConfig.ts b/src/utils/ParseConfig.ts index 3b96556c..82f6d4cc 100644 --- a/src/utils/ParseConfig.ts +++ b/src/utils/ParseConfig.ts @@ -132,6 +132,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: { diff --git a/src/utils/Redis.ts b/src/utils/Redis.ts index a09e2982..dd9af4be 100644 --- a/src/utils/Redis.ts +++ b/src/utils/Redis.ts @@ -27,6 +27,9 @@ export const RedisKey = { 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}`, @@ -49,4 +52,7 @@ export const RedisKey = { 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..bf58ad87 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 true; } else { this.logger.debug("send message success"); + return false; } } diff --git a/src/v1/controller/login/Login.ts b/src/v1/controller/login/Login.ts index 08c615ce..86a37b0a 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), }; } 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/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/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 9a11d965..78a03025 100644 --- a/src/v2/__tests__/helpers/db/index.ts +++ b/src/v2/__tests__/helpers/db/index.ts @@ -11,6 +11,7 @@ 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 { @@ -26,5 +27,6 @@ export const testService = (t: EntityManager) => { 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/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/routes.ts b/src/v2/controllers/login/routes.ts index 41a499f4..a9fe9789 100644 --- a/src/v2/controllers/login/routes.ts +++ b/src/v2/controllers/login/routes.ts @@ -1,4 +1,5 @@ import { Server } from "../../../utils/registryRoutersV2"; +import { loginEmail, loginEmailSchema } from "./email"; import { loginPhone, loginPhoneSchema } from "./phone"; export const loginRouters = (server: Server): void => { @@ -6,4 +7,9 @@ export const loginRouters = (server: Server): void => { 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/routes.ts b/src/v2/controllers/register/routes.ts index a6b3d61d..feabf206 100644 --- a/src/v2/controllers/register/routes.ts +++ b/src/v2/controllers/register/routes.ts @@ -1,4 +1,6 @@ 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"; @@ -12,4 +14,14 @@ export const registerRouters = (server: Server): void => { 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/routes.ts b/src/v2/controllers/reset/routes.ts index 339d5242..ad034238 100644 --- a/src/v2/controllers/reset/routes.ts +++ b/src/v2/controllers/reset/routes.ts @@ -1,4 +1,6 @@ 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"; @@ -12,4 +14,14 @@ export const resetRouters = (server: Server): void => { 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/dao/index.ts b/src/v2/dao/index.ts index 1b5306ad..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"; @@ -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/user/__tests__/email.test.ts b/src/v2/services/user/__tests__/email.test.ts new file mode 100644 index 00000000..ee66b63b --- /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.UserNotFound}`, + }, + ); + + 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.UserNotFound}`, + }, + ); + + 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.UserPasswordIncorrect}`, + }, + ); + + 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/email.ts b/src/v2/services/user/email.ts new file mode 100644 index 00000000..b0db8cb6 --- /dev/null +++ b/src/v2/services/user/email.ts @@ -0,0 +1,313 @@ +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 = { + name: userName, + avatarURL: "", + userUUID, + token: await jwtSign(userUUID), + hasPhone: false, + }; + + 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.UserNotFound); + } + + 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.UserNotFound); + } + + if (!user.user_password) { + this.logger.info("login email user password is null", { + userEmail: { email, userUUIDByEmail }, + }); + throw new FError(ErrorCode.UserNotFound); + } + + if (user.user_password !== password) { + this.logger.info("login email password incorrect", { + userEmail: { email, userUUIDByEmail }, + }); + throw new FError(ErrorCode.UserPasswordIncorrect); + } + + return { + name: user.user_name, + avatarURL: user.avatar_url, + userUUID: userUUIDByEmail, + token: await jwtSign(userUUIDByEmail), + hasPhone: await this.hasPhone(userUUIDByEmail), + }; + } + + 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 { + if (language && language.startsWith("zh")) { + if (type === "register") { + return `${email},你好!

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

${code}



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

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

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

${code}



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

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

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, ${email}! 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; +}; + +export type EmailLoginReturn = EmailRegisterReturn; diff --git a/src/v2/services/user/phone.ts b/src/v2/services/user/phone.ts index 87d5d65b..532831d7 100644 --- a/src/v2/services/user/phone.ts +++ b/src/v2/services/user/phone.ts @@ -2,30 +2,16 @@ import { EntityManager } from "typeorm"; import { v4 } from "uuid"; import RedisService from "../../../thirdPartyService/RedisService"; -import { getDisposition, ossClient } from "../../../v1/controller/cloudStorage/alibabaCloud/Utils"; -import { - getFilePath, - getOSSFileURLPath, -} from "../../../v1/controller/cloudStorage/alibabaCloud/upload/Utils"; - -import { ErrorCode } from "../../../ErrorCode"; -import { AbstractLogin } from "../../../abstract/login"; import { PhoneSMS, Server } from "../../../constants/Config"; -import { Region } from "../../../constants/Project"; import { FError } from "../../../error/ControllerError"; +import { ErrorCode } from "../../../ErrorCode"; import { createLoggerService } from "../../../logger"; -import { FileConvertStep, FileResourceType } from "../../../model/cloudStorage/Constants"; import { hash } from "../../../utils/Hash"; import { RedisKey } from "../../../utils/Redis"; import { SMS, SMSUtils } from "../../../utils/SMS"; import { MessageExpirationSecond, MessageIntervalSecond } from "../../constants"; -import { - cloudStorageConfigsDAO, - cloudStorageFilesDAO, - cloudStorageUserFilesDAO, - userDAO, - userPhoneDAO, -} from "../../dao"; +import { userDAO, userPhoneDAO } from "../../dao"; +import { setGuidePPTX } from "./utils"; export class UserPhoneService { private readonly logger = createLoggerService<"userPhone">({ @@ -48,7 +34,11 @@ export class UserPhoneService { throw new FError(ErrorCode.SMSAlreadyExist); } - await sms.send(); + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + await RedisService.set( RedisKey.phoneRegisterOrReset(safePhone), sms.verificationCode, @@ -72,7 +62,11 @@ export class UserPhoneService { throw new FError(ErrorCode.UserNotFound); } - await sms.send(); + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + await RedisService.set( RedisKey.phoneRegisterOrReset(safePhone), sms.verificationCode, @@ -119,7 +113,7 @@ export class UserPhoneService { phone_number: phone, }); - const setupGuidePPTX = this.setGuidePPTX(userUUID); + const setupGuidePPTX = setGuidePPTX(this.DBTransaction, userUUID); await Promise.all([createUser, createUserPhone, setupGuidePPTX]); @@ -147,6 +141,7 @@ export class UserPhoneService { const userUUIDByPhone = await this.userUUIDByPhone(phone); if (!userUUIDByPhone) { + this.logger.info("reset phone not found", { userPhone: { phone } }); throw new FError(ErrorCode.UserNotFound); } @@ -168,6 +163,7 @@ export class UserPhoneService { const userUUIDByPhone = await this.userUUIDByPhone(phone); if (!userUUIDByPhone) { + this.logger.info("login phone not found", { userPhone: { phone } }); throw new FError(ErrorCode.UserNotFound); } @@ -207,68 +203,6 @@ export class UserPhoneService { }; } - private async setGuidePPTX(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( - this.DBTransaction, - { - user_uuid: userUUID, - total_usage: String(cnFileSize + enFileSize), - }, - { - orUpdate: ["total_usage"], - }, - ), - cloudStorageFilesDAO.insert(this.DBTransaction, { - payload: { - region: Region.CN_HZ, - convertStep: FileConvertStep.None, - }, - fileURL: getOSSFileURLPath(cnPPTXPath), - fileSize: cnFileSize, - fileUUID: cnFileUUID, - fileName: cnName, - resourceType: FileResourceType.WhiteboardProjector, - }), - cloudStorageFilesDAO.insert(this.DBTransaction, { - payload: { - region: Region.US_SV, - convertStep: FileConvertStep.None, - }, - fileURL: getOSSFileURLPath(enPPTXPath), - fileSize: enFileSize, - fileUUID: enFileUUID, - fileName: enName, - resourceType: FileResourceType.WhiteboardProjector, - }), - cloudStorageUserFilesDAO.insert(this.DBTransaction, { - user_uuid: userUUID, - file_uuid: cnFileUUID, - }), - cloudStorageUserFilesDAO.insert(this.DBTransaction, { - user_uuid: userUUID, - file_uuid: enFileUUID, - }), - ]); - } - private async userUUIDByPhone(phone: string): Promise { const result = await userPhoneDAO.findOne(this.DBTransaction, ["user_uuid"], { phone_number: phone, diff --git a/src/v2/services/user/rebind-phone.ts b/src/v2/services/user/rebind-phone.ts index 580e0f88..d3ce01ec 100644 --- a/src/v2/services/user/rebind-phone.ts +++ b/src/v2/services/user/rebind-phone.ts @@ -3,12 +3,13 @@ import { EntityManager } from "typeorm"; import RedisService from "../../../thirdPartyService/RedisService"; import { alreadyJoinedRoomCount } from "../../../v1/controller/user/deleteAccount/utils/AlreadyJoinedRoomCount"; -import { ErrorCode } from "../../../ErrorCode"; 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"; @@ -21,6 +22,7 @@ import { userAgoraDAO, userAppleDAO, userDAO, + userEmailDAO, userGithubDAO, userGoogleDAO, userPhoneDAO, @@ -31,6 +33,7 @@ import { type UserPlatform = | UserModel | UserPhoneModel + | UserEmailModel | UserWeChatModel | UserGithubModel | UserAppleModel @@ -70,7 +73,11 @@ export class UserRebindPhoneService { throw new FError(ErrorCode.SMSAlreadyExist); } - await sms.send(); + const success = await sms.send(); + if (!success) { + throw new FError(ErrorCode.SMSFailedToSendCode); + } + await RedisService.set( RedisKey.phoneBinding(safePhone), sms.verificationCode, @@ -125,6 +132,7 @@ export class UserRebindPhoneService { Github: RebindStatusVal.NotChanged, Google: RebindStatusVal.NotChanged, WeChat: RebindStatusVal.NotChanged, + Email: RebindStatusVal.NotChanged, }; await this.tryUpdate(userAgoraDAO, original.user_uuid, status, LoginPlatform.Agora); @@ -132,6 +140,7 @@ export class UserRebindPhoneService { 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([ @@ -142,6 +151,7 @@ export class UserRebindPhoneService { 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 => { 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== From e83c1a7ad52aec5c17e65bfe0eafdb7774b1272a Mon Sep 17 00:00:00 2001 From: hyrious Date: Fri, 4 Aug 2023 10:34:28 +0800 Subject: [PATCH 16/21] fix: wrong return value of send message (#749) --- src/utils/SMS.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/SMS.ts b/src/utils/SMS.ts index bf58ad87..11125ee8 100644 --- a/src/utils/SMS.ts +++ b/src/utils/SMS.ts @@ -116,10 +116,10 @@ export class SMS { if (resp.body.code !== "OK") { this.logger.error("send message failed"); - return true; + return false; } else { this.logger.debug("send message success"); - return false; + return true; } } From 383726be95742a79b1a90e609095608881fde7c6 Mon Sep 17 00:00:00 2001 From: hyrious Date: Fri, 4 Aug 2023 10:42:39 +0800 Subject: [PATCH 17/21] refactor: change all login errors (#748) --- src/ErrorCode.ts | 1 + src/v2/services/user/__tests__/email.test.ts | 6 +++--- src/v2/services/user/__tests__/phone.test.ts | 6 +++--- src/v2/services/user/email.ts | 8 ++++---- src/v2/services/user/phone.ts | 8 ++++---- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/ErrorCode.ts b/src/ErrorCode.ts index 5ad307a9..365d4be1 100644 --- a/src/ErrorCode.ts +++ b/src/ErrorCode.ts @@ -26,6 +26,7 @@ export enum ErrorCode { 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 diff --git a/src/v2/services/user/__tests__/email.test.ts b/src/v2/services/user/__tests__/email.test.ts index ee66b63b..f9e5ee36 100644 --- a/src/v2/services/user/__tests__/email.test.ts +++ b/src/v2/services/user/__tests__/email.test.ts @@ -153,7 +153,7 @@ test(`${namespace} - user email not found in login`, async ava => { () => new UserEmailService(ids(), t).login(`${v4()}@test.com`, v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); @@ -164,7 +164,7 @@ test(`${namespace} - user email not found in login`, async ava => { () => new UserEmailService(ids(), t).login(userEmailInfo.userEmail, v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); @@ -182,7 +182,7 @@ test(`${namespace} - user email wrong password`, async ava => { () => new UserEmailService(ids(), t).login(userEmailInfo.userEmail, v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserPasswordIncorrect}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); diff --git a/src/v2/services/user/__tests__/phone.test.ts b/src/v2/services/user/__tests__/phone.test.ts index 7bd06713..f012c24a 100644 --- a/src/v2/services/user/__tests__/phone.test.ts +++ b/src/v2/services/user/__tests__/phone.test.ts @@ -157,7 +157,7 @@ test(`${namespace} - user phone not found in login`, async ava => { () => new UserPhoneService(ids(), t).login(randomPhoneNumber(), v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); @@ -168,7 +168,7 @@ test(`${namespace} - user phone not found in login`, async ava => { () => new UserPhoneService(ids(), t).login(userPhoneInfo.phoneNumber, v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserNotFound}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); @@ -186,7 +186,7 @@ test(`${namespace} - user phone wrong password`, async ava => { () => new UserPhoneService(ids(), t).login(userPhoneInfo.phoneNumber, v4(), async () => ""), { instanceOf: FError, - message: `${Status.Failed}: ${ErrorCode.UserPasswordIncorrect}`, + message: `${Status.Failed}: ${ErrorCode.UserOrPasswordIncorrect}`, }, ); diff --git a/src/v2/services/user/email.ts b/src/v2/services/user/email.ts index b0db8cb6..ee368d88 100644 --- a/src/v2/services/user/email.ts +++ b/src/v2/services/user/email.ts @@ -165,7 +165,7 @@ export class UserEmailService { const userUUIDByEmail = await this.userUUIDByEmail(email); if (!userUUIDByEmail) { this.logger.info("login email not found", { userEmail: { email } }); - throw new FError(ErrorCode.UserNotFound); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } const user = await userDAO.findOne( @@ -177,21 +177,21 @@ export class UserEmailService { this.logger.info("login email not found user", { userEmail: { email, userUUIDByEmail }, }); - throw new FError(ErrorCode.UserNotFound); + 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.UserNotFound); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } if (user.user_password !== password) { this.logger.info("login email password incorrect", { userEmail: { email, userUUIDByEmail }, }); - throw new FError(ErrorCode.UserPasswordIncorrect); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } return { diff --git a/src/v2/services/user/phone.ts b/src/v2/services/user/phone.ts index 532831d7..6c5041ae 100644 --- a/src/v2/services/user/phone.ts +++ b/src/v2/services/user/phone.ts @@ -164,7 +164,7 @@ export class UserPhoneService { const userUUIDByPhone = await this.userUUIDByPhone(phone); if (!userUUIDByPhone) { this.logger.info("login phone not found", { userPhone: { phone } }); - throw new FError(ErrorCode.UserNotFound); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } const user = await userDAO.findOne( @@ -176,7 +176,7 @@ export class UserPhoneService { this.logger.info("login phone not found user", { userPhone: { phone, userUUIDByPhone }, }); - throw new FError(ErrorCode.UserNotFound); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } // User didn't set password, in this case we should not allow login with password @@ -184,14 +184,14 @@ export class UserPhoneService { this.logger.info("login phone user password is null", { userPhone: { phone, userUUIDByPhone }, }); - throw new FError(ErrorCode.UserNotFound); + 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.UserPasswordIncorrect); + throw new FError(ErrorCode.UserOrPasswordIncorrect); } return { From ea9e16a5ea20900098ebedf897ac0fff744d4bbc Mon Sep 17 00:00:00 2001 From: hyrious Date: Fri, 4 Aug 2023 11:20:32 +0800 Subject: [PATCH 18/21] refactor: add {hasPassword: boolean} to login responses (#744) --- src/abstract/login/index.ts | 2 ++ src/v1/controller/login/Login.ts | 2 ++ src/v1/controller/login/Process.ts | 2 ++ src/v1/controller/login/apple/jwt.ts | 3 +++ src/v1/controller/login/phone/Phone.ts | 2 ++ src/v1/controller/login/weChat/mobile/Callback.ts | 3 +++ src/v1/service/user/User.ts | 12 ++++++++++++ src/v2/services/user/email.ts | 5 ++++- src/v2/services/user/phone.ts | 5 ++++- src/v2/services/user/rebind-phone.ts | 9 +++++++++ 10 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/abstract/login/index.ts b/src/abstract/login/index.ts index 0ac362ed..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, ); diff --git a/src/v1/controller/login/Login.ts b/src/v1/controller/login/Login.ts index 86a37b0a..551a488e 100644 --- a/src/v1/controller/login/Login.ts +++ b/src/v1/controller/login/Login.ts @@ -90,6 +90,7 @@ export class Login extends AbstractController { }), userUUID: this.userUUID, hasPhone: await this.svc.userPhone.exist(), + hasPassword: await this.svc.user.hasPassword(), }, }; } @@ -138,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/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 { + 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/v2/services/user/email.ts b/src/v2/services/user/email.ts index ee368d88..daf639a6 100644 --- a/src/v2/services/user/email.ts +++ b/src/v2/services/user/email.ts @@ -120,12 +120,13 @@ export class UserEmailService { await Promise.all([createUser, createUserEmail, setupGuidePPTX]); - const result = { + const result: EmailRegisterReturn = { name: userName, avatarURL: "", userUUID, token: await jwtSign(userUUID), hasPhone: false, + hasPassword: true, }; await UserEmailService.clearVerificationCode(email); @@ -200,6 +201,7 @@ export class UserEmailService { userUUID: userUUIDByEmail, token: await jwtSign(userUUIDByEmail), hasPhone: await this.hasPhone(userUUIDByEmail), + hasPassword: true, }; } @@ -308,6 +310,7 @@ export type EmailRegisterReturn = { 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 index 6c5041ae..c13861b4 100644 --- a/src/v2/services/user/phone.ts +++ b/src/v2/services/user/phone.ts @@ -117,12 +117,13 @@ export class UserPhoneService { await Promise.all([createUser, createUserPhone, setupGuidePPTX]); - const result = { + const result: PhoneRegisterReturn = { name: userName, avatarURL: "", userUUID, token: await jwtSign(userUUID), hasPhone: true, + hasPassword: true, }; await UserPhoneService.clearVerificationCode(safePhone); @@ -200,6 +201,7 @@ export class UserPhoneService { userUUID: userUUIDByPhone, token: await jwtSign(userUUIDByPhone), hasPhone: true, + hasPassword: true, }; } @@ -272,6 +274,7 @@ export type PhoneRegisterReturn = { 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 index d3ce01ec..f1273e12 100644 --- a/src/v2/services/user/rebind-phone.ts +++ b/src/v2/services/user/rebind-phone.ts @@ -172,6 +172,7 @@ export class UserRebindPhoneService { userUUID: original.user_uuid, token: await jwtSign(original.user_uuid), hasPhone: true, + hasPassword: await this.hasPassword(original.user_uuid), rebind: status, }; } else { @@ -194,6 +195,13 @@ export class UserRebindPhoneService { 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, @@ -275,5 +283,6 @@ export type UserRebindReturn = { token: string; userUUID: string; hasPhone: boolean; + hasPassword: boolean; rebind: RebindStatus; }; From 8ede9c4728cffa9d1d815c33ac70239a271f1998 Mon Sep 17 00:00:00 2001 From: hyrious Date: Fri, 4 Aug 2023 14:25:06 +0800 Subject: [PATCH 19/21] refactor: update email templates (#750) --- src/v2/services/user/email.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/v2/services/user/email.ts b/src/v2/services/user/email.ts index daf639a6..419a85c1 100644 --- a/src/v2/services/user/email.ts +++ b/src/v2/services/user/email.ts @@ -219,17 +219,18 @@ export class UserEmailService { code: string, language?: string, ): string { + const name = email.split("@")[0]; if (language && language.startsWith("zh")) { if (type === "register") { - return `${email},你好!

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

${code}



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

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

Leo Yang
Flat 产品经理
yangliu02@agora.io`; + return `${name},你好!

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

${code}



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

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

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

${code}



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

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

${code}



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

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

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`; + 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, ${email}! Please enter the verification code within 10 minutes:

${code}




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

${code}




Thanks and Regards,
Leo Yang
Flat PM
yangliu02@agora.io`; } } } From 93abd3f98da48bcf89280aaa53f9293e7df12274 Mon Sep 17 00:00:00 2001 From: ooeyuna Date: Fri, 4 Aug 2023 19:01:26 +0800 Subject: [PATCH 20/21] fix sg deploy (#751) Co-authored-by: siyu --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3585884d..908d8e7b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -129,5 +129,5 @@ deploy_prod_sg: <<: *DEPLOY extends: .sg variables: - DOCKER_TAG: $CI_COMMIT_SHA + DOCKER_TAG: dev-$CI_COMMIT_SHA <<: *DEV From 801183fc2df6a6ee91ae1cd1909931b00988c855 Mon Sep 17 00:00:00 2001 From: hyrious Date: Tue, 8 Aug 2023 17:27:31 +0800 Subject: [PATCH 21/21] refactor: add more fields to region configs (#752) * refactor: add more fields to region configs * enforce filename style * add censorship config * add config hash --- config/defaults.yaml | 1 + config/test.yaml | 1 + src/constants/Config.ts | 1 + src/utils/ParseConfig.ts | 4 ++ ...RegionConfig.ts => fetch-region-config.ts} | 8 +-- .../{regionConfigs.ts => region-configs.ts} | 71 ++++++++++++++++--- src/v2/controllers/routes.ts | 2 +- 7 files changed, 72 insertions(+), 16 deletions(-) rename src/v2/controllers/configs/__tests__/{fetchRegionConfig.ts => fetch-region-config.ts} (88%) rename src/v2/controllers/configs/{regionConfigs.ts => region-configs.ts} (58%) diff --git a/config/defaults.yaml b/config/defaults.yaml index 0e3e7685..c0f611db 100644 --- a/config/defaults.yaml +++ b/config/defaults.yaml @@ -177,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 fac912fa..ec728439 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -172,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/src/constants/Config.ts b/src/constants/Config.ts index 2252c6ac..cc3533c6 100644 --- a/src/constants/Config.ts +++ b/src/constants/Config.ts @@ -161,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/utils/ParseConfig.ts b/src/utils/ParseConfig.ts index 82f6d4cc..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,6 +25,8 @@ 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 = { @@ -195,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/v2/controllers/configs/__tests__/fetchRegionConfig.ts b/src/v2/controllers/configs/__tests__/fetch-region-config.ts similarity index 88% rename from src/v2/controllers/configs/__tests__/fetchRegionConfig.ts rename to src/v2/controllers/configs/__tests__/fetch-region-config.ts index ea48b4ec..cfec61f1 100644 --- a/src/v2/controllers/configs/__tests__/fetchRegionConfig.ts +++ b/src/v2/controllers/configs/__tests__/fetch-region-config.ts @@ -1,13 +1,12 @@ import test from "ava"; import { HelperAPI } from "../../../__tests__/helpers/api"; -import { regionConfigs, regionConfigsRouters } from "../regionConfigs"; +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); @@ -16,8 +15,8 @@ test(`${namespace} - fetch region configs`, async ava => { method: "GET", url: "/v2/region/configs", }); - const s = resp.payload - console.log(s) + const s = resp.payload; + console.log(s); ava.is(resp.statusCode, 200); const data = (await resp.json()).data; ava.true(data.login.wechatWeb); @@ -31,5 +30,4 @@ test(`${namespace} - fetch region configs`, async ava => { ava.is(data.server.region, "CN"); ava.is(data.server.regionCode, 1); } - }); diff --git a/src/v2/controllers/configs/regionConfigs.ts b/src/v2/controllers/configs/region-configs.ts similarity index 58% rename from src/v2/controllers/configs/regionConfigs.ts rename to src/v2/controllers/configs/region-configs.ts index e1ce1ca1..2395b85f 100644 --- a/src/v2/controllers/configs/regionConfigs.ts +++ b/src/v2/controllers/configs/region-configs.ts @@ -4,12 +4,24 @@ 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 + 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; @@ -19,29 +31,49 @@ type regionConfigsResponseSchema = { 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; - totleSize: 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, @@ -58,17 +90,36 @@ export const regionConfigs = async (): Promise